diff --git a/desktop/README.md b/desktop/README.md
index 593d0d3..dd0e68f 100644
--- a/desktop/README.md
+++ b/desktop/README.md
@@ -27,6 +27,7 @@ npm start
## Current Tabs
- Chat
+- Experiments
- Launch
- Runs
- Candidates
@@ -42,3 +43,4 @@ npm start
- The shell can now review recent launch jobs and explain the latest failure from local stdout/stderr logs.
- The shell now persists decision state locally in `outputs/desktop/candidates_shortlist.json`.
- `Run`, `Compare`, `Artifacts`, `Candidates`, and `Paper Ops` are now shell-native tabs designed to support launch -> inspect -> compare -> decide continuity.
+- `Experiments` is now a shell-native workspace for local sweep configs and recent sweep outputs under `configs/experiments` and `outputs/sweeps`.
diff --git a/desktop/main.js b/desktop/main.js
index b0b1e01..2c1aa66 100644
--- a/desktop/main.js
+++ b/desktop/main.js
@@ -58,7 +58,10 @@ function normalizeCandidatesStore(store) {
function assertPathInsideProject(targetPath) {
const resolvedProjectRoot = path.resolve(PROJECT_ROOT);
- const resolvedTarget = path.resolve(String(targetPath || ""));
+ const rawTarget = String(targetPath || "").trim();
+ const resolvedTarget = path.isAbsolute(rawTarget)
+ ? path.resolve(rawTarget)
+ : path.resolve(PROJECT_ROOT, rawTarget);
const relative = path.relative(resolvedProjectRoot, resolvedTarget);
if (!resolvedTarget || relative.startsWith("..") || path.isAbsolute(relative) && relative === resolvedTarget) {
throw new Error("Requested path is outside the QuantLab workspace.");
@@ -127,6 +130,28 @@ async function listDirectoryEntries(targetPath, maxDepth = 2) {
};
}
+async function readProjectText(targetPath) {
+ const safePath = assertPathInsideProject(targetPath);
+ const stats = await fsp.stat(safePath);
+ if (!stats.isFile()) {
+ throw new Error("Requested path is not a file.");
+ }
+ return fsp.readFile(safePath, "utf8");
+}
+
+async function readProjectJson(targetPath) {
+ const raw = await readProjectText(targetPath);
+ 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 appendLog(line) {
if (!line) return;
workspaceState.logs = [...workspaceState.logs.slice(-49), line];
@@ -288,6 +313,14 @@ ipcMain.handle("quantlab:list-directory", async (_event, targetPath, maxDepth =
return listDirectoryEntries(targetPath, maxDepth);
});
+ipcMain.handle("quantlab:read-project-text", async (_event, targetPath) => {
+ return readProjectText(targetPath);
+});
+
+ipcMain.handle("quantlab:read-project-json", async (_event, targetPath) => {
+ return readProjectJson(targetPath);
+});
+
ipcMain.handle("quantlab:post-json", async (_event, relativePath, payload) => {
if (!workspaceState.serverUrl) {
throw new Error("Research UI server is not ready yet.");
diff --git a/desktop/preload.js b/desktop/preload.js
index fda130b..f0c2561 100644
--- a/desktop/preload.js
+++ b/desktop/preload.js
@@ -7,6 +7,8 @@ contextBridge.exposeInMainWorld("quantlabDesktop", {
getCandidatesStore: () => ipcRenderer.invoke("quantlab:get-candidates-store"),
saveCandidatesStore: (store) => ipcRenderer.invoke("quantlab:save-candidates-store", store),
listDirectory: (targetPath, maxDepth) => ipcRenderer.invoke("quantlab:list-directory", targetPath, maxDepth),
+ readProjectText: (targetPath) => ipcRenderer.invoke("quantlab:read-project-text", targetPath),
+ readProjectJson: (targetPath) => ipcRenderer.invoke("quantlab:read-project-json", targetPath),
postJson: (relativePath, payload) => ipcRenderer.invoke("quantlab:post-json", relativePath, payload),
openExternal: (url) => ipcRenderer.invoke("quantlab:open-external", url),
openPath: (targetPath) => ipcRenderer.invoke("quantlab:open-path", targetPath),
diff --git a/desktop/renderer/app.js b/desktop/renderer/app.js
index 99bff31..c247a87 100644
--- a/desktop/renderer/app.js
+++ b/desktop/renderer/app.js
@@ -1,6 +1,7 @@
import * as decisionStore from "./modules/decision-store.js";
import {
absoluteUrl as buildAbsoluteUrl,
+ basenamePath as basenameValue,
buildRunArtifactHref as buildArtifactHref,
escapeHtml as escapeMarkup,
escapeRegex as escapePattern,
@@ -10,6 +11,7 @@ import {
formatLogPreview as formatLogText,
formatNumber as formatNumericValue,
formatPercent as formatPercentValue,
+ parseCsvRows as parseCsvPreviewRows,
runtimeChip as renderRuntimeChip,
shortCommit as shortenCommit,
stripWrappingQuotes as stripQuotes,
@@ -23,6 +25,7 @@ import {
renderCandidatesTab as renderCandidatesTabView,
renderCandidateFlags as renderCandidateFlagsView,
renderCompareTab as renderCompareTabView,
+ renderExperimentsTab as renderExperimentsTabView,
renderJobTab as renderJobTabView,
renderPaperOpsTab as renderPaperOpsTabView,
renderRunTab as renderRunTabView,
@@ -36,11 +39,16 @@ const CONFIG = {
brokerHealthPath: "/api/broker-submissions-health",
stepbitWorkspacePath: "/api/stepbit-workspace",
detailArtifacts: ["report.json", "run_report.json"],
+ experimentsConfigDir: "configs/experiments",
+ sweepsOutputDir: "outputs/sweeps",
refreshIntervalMs: 15000,
maxWorklistRuns: 8,
maxRecentJobs: 4,
maxLogPreviewChars: 5000,
maxCandidateCompare: 4,
+ maxExperimentsConfigs: 12,
+ maxRecentSweeps: 8,
+ maxSweepRows: 5,
};
const state = {
@@ -50,6 +58,8 @@ const state = {
candidatesLoaded: false,
selectedRunIds: [],
detailCache: new Map(),
+ experimentsWorkspace: { status: "idle", configs: [], sweeps: [], error: null, updatedAt: null },
+ experimentConfigPreviewCache: new Map(),
isSubmittingLaunch: false,
launchFeedback: "Use deterministic inputs or ask from chat.",
refreshTimer: null,
@@ -57,7 +67,7 @@ const state = {
{
role: "assistant",
content:
- "QuantLab Desktop now supports a real workflow.\n\nTry:\n- launch run ticker ETH-USD start 2023-01-01 end 2024-01-01\n- open latest run\n- compare selected\n- show artifacts\n- open latest failed launch\n- explain latest failure",
+ "QuantLab Desktop now supports a real workflow.\n\nTry:\n- open experiments\n- launch run ticker ETH-USD start 2023-01-01 end 2024-01-01\n- launch sweep config configs/experiments/eth_2023_grid.yaml\n- open latest run\n- compare selected\n- show artifacts\n- open latest failed launch\n- explain latest failure",
},
],
tabs: [],
@@ -108,6 +118,7 @@ const elements = {
const paletteActions = [
["chat", "Open Chat", "Return focus to the command bus.", () => focusChat()],
+ ["experiments", "Open Experiments", "Open the native experiments and sweeps workspace.", () => openExperimentsTab()],
["launch", "Open Launch", "Open the QuantLab launch surface.", () => openResearchTab("launch", "Launch", "#/launch")],
["runs", "Open Runs", "Open the run explorer.", () => openResearchTab("runs", "Runs", "#/")],
["candidates", "Open Candidates", "Open the shortlist and baseline surface.", () => openCandidatesTab()],
@@ -153,6 +164,7 @@ function bindEvents() {
button.addEventListener("click", () => {
const action = button.dataset.action;
if (action === "open-chat") focusChat();
+ if (action === "open-experiments") openExperimentsTab();
if (action === "open-launch") openResearchTab("launch", "Launch", "#/launch");
if (action === "open-runs") openResearchTab("runs", "Runs", "#/");
if (action === "open-candidates") openCandidatesTab();
@@ -255,6 +267,9 @@ async function refreshSnapshot() {
renderWorkflow();
refreshLiveJobTabs();
rerenderContextualTabs();
+ if (state.tabs.some((tab) => tab.kind === "experiments")) {
+ refreshExperimentsWorkspace({ focusTab: false, silent: true });
+ }
} catch (_error) {
// Keep the shell usable even if optional surfaces are down.
}
@@ -331,6 +346,8 @@ function renderTabs() {
syncNav(activeTab.navKind || activeTab.kind);
if (activeTab.kind === "iframe") {
elements.tabContent.innerHTML = ``;
+ } else if (activeTab.kind === "experiments") {
+ elements.tabContent.innerHTML = renderExperimentsTab(activeTab);
} else if (activeTab.kind === "run") {
elements.tabContent.innerHTML = renderRunTab(activeTab);
} else if (activeTab.kind === "compare") {
@@ -514,6 +531,49 @@ function bindTabChromeEvents() {
}
function bindTabContentEvents(tab) {
+ if (tab.kind === "experiments") {
+ elements.tabContent.querySelectorAll("[data-experiments-refresh]").forEach((button) => {
+ button.addEventListener("click", () => refreshExperimentsWorkspace({ focusTab: true, silent: false }));
+ });
+ elements.tabContent.querySelectorAll("[data-experiment-config]").forEach((button) => {
+ button.addEventListener("click", () => upsertTab({ id: tab.id, selectedConfigPath: button.dataset.experimentConfig }));
+ });
+ elements.tabContent.querySelectorAll("[data-experiment-launch-config]").forEach((button) => {
+ button.addEventListener("click", async () => {
+ const configPath = button.dataset.experimentLaunchConfig;
+ if (!configPath) return;
+ await submitLaunchRequest({ command: "sweep", params: { config_path: configPath } }, "experiments");
+ await refreshExperimentsWorkspace({ focusTab: true, silent: true });
+ upsertTab({ id: tab.id, selectedConfigPath: configPath });
+ });
+ });
+ elements.tabContent.querySelectorAll("[data-experiment-open-path]").forEach((button) => {
+ button.addEventListener("click", () => {
+ if (button.dataset.experimentOpenPath) window.quantlabDesktop.openPath(button.dataset.experimentOpenPath);
+ });
+ });
+ elements.tabContent.querySelectorAll("[data-experiment-open-file]").forEach((button) => {
+ button.addEventListener("click", () => {
+ if (button.dataset.experimentOpenFile) window.quantlabDesktop.openPath(button.dataset.experimentOpenFile);
+ });
+ });
+ elements.tabContent.querySelectorAll("[data-open-path]").forEach((button) => {
+ button.addEventListener("click", () => {
+ if (button.dataset.openPath) window.quantlabDesktop.openPath(button.dataset.openPath);
+ });
+ });
+ elements.tabContent.querySelectorAll("[data-experiment-sweep]").forEach((button) => {
+ button.addEventListener("click", () => upsertTab({ id: tab.id, selectedSweepId: button.dataset.experimentSweep }));
+ });
+ elements.tabContent.querySelectorAll("[data-experiment-relaunch]").forEach((button) => {
+ button.addEventListener("click", async () => {
+ const configPath = button.dataset.experimentRelaunch;
+ if (!configPath) return;
+ await submitLaunchRequest({ command: "sweep", params: { config_path: configPath } }, "experiments");
+ await refreshExperimentsWorkspace({ focusTab: true, silent: true });
+ });
+ });
+ }
if (tab.kind === "run") {
bindRunContextActions(elements.tabContent, tab.runId);
}
@@ -615,6 +675,15 @@ function bindTabContentEvents(tab) {
function handleChatPrompt(prompt) {
pushMessage("user", prompt);
const normalized = prompt.trim().toLowerCase();
+ if (normalized.includes("open experiments") || normalized.includes("open sweeps") || normalized === "experiments") {
+ openExperimentsTab();
+ pushMessage("assistant", "Opened the native experiments workspace.");
+ return;
+ }
+ if (normalized.includes("refresh experiments") || normalized.includes("refresh sweeps")) {
+ refreshExperimentsWorkspace({ focusTab: true, silent: false });
+ return;
+ }
if (normalized.includes("open launch") || normalized === "launch") {
openResearchTab("launch", "Launch", "#/launch");
pushMessage("assistant", "Opened the Launch surface inside a desktop tab.");
@@ -696,7 +765,7 @@ function handleChatPrompt(prompt) {
submitLaunchRequest(launchSweepPayload, "chat");
return;
}
- pushMessage("assistant", "This shell now supports real backend-backed actions. Try:\n- launch run ticker ETH-USD start 2023-01-01 end 2024-01-01\n- launch sweep config configs/sweeps/example.yaml\n- open candidates\n- mark candidate \n- mark baseline \n- open latest run\n- compare selected\n- show artifacts\n- open latest failed launch\n- explain latest failure");
+ pushMessage("assistant", "This shell now supports real backend-backed actions. Try:\n- open experiments\n- launch run ticker ETH-USD start 2023-01-01 end 2024-01-01\n- launch sweep config configs/experiments/eth_2023_grid.yaml\n- open candidates\n- mark candidate \n- mark baseline \n- open latest run\n- compare selected\n- show artifacts\n- open latest failed launch\n- explain latest failure");
}
async function submitLaunchRequest(payload, source) {
@@ -742,6 +811,19 @@ function focusChat() {
pushMessage("assistant", "Chat stays at the center of the shell. Use it to launch work, open runs, compare, or inspect artifacts.");
}
+async function openExperimentsTab() {
+ const current = state.tabs.find((tab) => tab.id === "experiments");
+ upsertTab({
+ id: "experiments",
+ kind: "experiments",
+ navKind: "experiments",
+ title: "Experiments",
+ selectedConfigPath: current?.selectedConfigPath || state.experimentsWorkspace.configs[0]?.path || null,
+ selectedSweepId: current?.selectedSweepId || state.experimentsWorkspace.sweeps[0]?.run_id || null,
+ });
+ await refreshExperimentsWorkspace({ focusTab: true, silent: true });
+}
+
function openResearchTab(navKind, title, hash) {
if (!state.workspace.serverUrl) {
pushMessage("assistant", "The local research surface is still starting. Wait a moment and retry.");
@@ -1017,6 +1099,176 @@ function openArtifactsForJob(requestId) {
pushMessage("assistant", `Job ${requestId} does not expose artifacts yet.`);
}
+async function refreshExperimentsWorkspace({ focusTab = false, silent = true } = {}) {
+ state.experimentsWorkspace = {
+ ...state.experimentsWorkspace,
+ status: "loading",
+ error: null,
+ };
+ if (focusTab) renderTabs();
+ try {
+ const workspace = await buildExperimentsWorkspace();
+ state.experimentsWorkspace = {
+ status: "ready",
+ configs: workspace.configs,
+ sweeps: workspace.sweeps,
+ error: null,
+ updatedAt: new Date().toISOString(),
+ };
+ const experimentsTab = state.tabs.find((tab) => tab.id === "experiments");
+ if (experimentsTab) {
+ const nextTab = {
+ ...experimentsTab,
+ selectedConfigPath: experimentsTab.selectedConfigPath || workspace.configs[0]?.path || null,
+ selectedSweepId: experimentsTab.selectedSweepId || workspace.sweeps[0]?.run_id || null,
+ };
+ if (focusTab) {
+ upsertTab(nextTab);
+ } else {
+ state.tabs = state.tabs.map((tab) => (tab.id === "experiments" ? nextTab : tab));
+ if (state.activeTabId === "experiments") renderTabs();
+ }
+ } else if (focusTab) {
+ renderTabs();
+ }
+ if (!silent) {
+ pushMessage(
+ "assistant",
+ `Refreshed experiments workspace: ${workspace.configs.length} configs and ${workspace.sweeps.length} recent sweeps.`,
+ );
+ }
+ } catch (error) {
+ state.experimentsWorkspace = {
+ ...state.experimentsWorkspace,
+ status: "error",
+ error: error.message || "Could not refresh the experiments workspace.",
+ };
+ if (focusTab) renderTabs();
+ if (!silent) pushMessage("assistant", state.experimentsWorkspace.error);
+ }
+}
+
+async function buildExperimentsWorkspace() {
+ const [configsListing, sweepsListing] = await Promise.all([
+ window.quantlabDesktop.listDirectory(CONFIG.experimentsConfigDir, 0),
+ window.quantlabDesktop.listDirectory(CONFIG.sweepsOutputDir, 0),
+ ]);
+
+ const configEntries = (configsListing.entries || [])
+ .filter((entry) => entry.kind === "file" && /\.ya?ml$/i.test(entry.name))
+ .sort((left, right) => String(right.modified_at || "").localeCompare(String(left.modified_at || "")))
+ .slice(0, CONFIG.maxExperimentsConfigs);
+
+ const configs = await Promise.all(configEntries.map(async (entry) => ({
+ name: entry.name,
+ path: entry.path,
+ relativePath: entry.relative_path || entry.name,
+ modifiedAt: entry.modified_at,
+ sizeBytes: entry.size_bytes,
+ previewText: await loadExperimentConfigPreview(entry.path),
+ })));
+
+ const sweepDirectories = (sweepsListing.entries || [])
+ .filter((entry) => entry.kind === "directory" && entry.depth === 0)
+ .sort((left, right) => String(right.modified_at || "").localeCompare(String(left.modified_at || "")))
+ .slice(0, CONFIG.maxRecentSweeps);
+
+ const sweeps = await Promise.all(sweepDirectories.map((entry) => buildSweepSummary(entry)));
+ return {
+ configs,
+ sweeps: sweeps.filter(Boolean),
+ };
+}
+
+async function buildSweepSummary(entry) {
+ const rootPath = entry.path;
+ const fileListing = await window.quantlabDesktop.listDirectory(rootPath, 0).catch(() => ({ entries: [], truncated: false }));
+ const metaPath = `${rootPath}\\meta.json`;
+ const leaderboardPath = `${rootPath}\\leaderboard.csv`;
+ const experimentsPath = `${rootPath}\\experiments.csv`;
+ const walkforwardSummaryPath = `${rootPath}\\walkforward_summary.csv`;
+ const configResolvedPath = `${rootPath}\\config_resolved.yaml`;
+
+ const [meta, leaderboardText, experimentsText, walkforwardText] = await Promise.all([
+ readOptionalProjectJson(metaPath),
+ readOptionalProjectText(leaderboardPath),
+ readOptionalProjectText(experimentsPath),
+ readOptionalProjectText(walkforwardSummaryPath),
+ ]);
+
+ const leaderboardRows = parseCsvPreviewRows(leaderboardText, CONFIG.maxSweepRows);
+ const walkforwardRows = parseCsvPreviewRows(walkforwardText, CONFIG.maxSweepRows);
+ const experimentsRows = parseCsvPreviewRows(experimentsText, 1);
+ const firstRow = leaderboardRows[0] || experimentsRows[0] || null;
+
+ return {
+ run_id: meta?.run_id || basenameValue(rootPath),
+ path: rootPath,
+ modifiedAt: entry.modified_at,
+ createdAt: meta?.created_at || entry.modified_at,
+ mode: meta?.mode || inferSweepModeFromName(entry.name),
+ configPath: meta?.config_path || "",
+ configName: basenameValue(meta?.config_path || ""),
+ nRuns: meta?.n_runs ?? meta?.n_train_runs ?? null,
+ nSelected: meta?.n_selected ?? null,
+ nTrainRuns: meta?.n_train_runs ?? null,
+ nTestRuns: meta?.n_test_runs ?? null,
+ topResults: Array.isArray(meta?.top10) ? meta.top10.slice(0, CONFIG.maxSweepRows) : [],
+ leaderboardRows,
+ walkforwardRows,
+ files: fileListing.entries || [],
+ filesTruncated: Boolean(fileListing.truncated),
+ metaPath,
+ leaderboardPath,
+ experimentsPath,
+ walkforwardSummaryPath,
+ configResolvedPath,
+ headlineReturn: coerceNumber(firstRow?.total_return),
+ headlineSharpe: coerceNumber(firstRow?.sharpe_simple || firstRow?.best_test_sharpe),
+ headlineDrawdown: coerceNumber(firstRow?.max_drawdown),
+ };
+}
+
+async function readOptionalProjectText(targetPath) {
+ try {
+ return await window.quantlabDesktop.readProjectText(targetPath);
+ } catch (_error) {
+ return "";
+ }
+}
+
+async function readOptionalProjectJson(targetPath) {
+ try {
+ return await window.quantlabDesktop.readProjectJson(targetPath);
+ } catch (_error) {
+ return null;
+ }
+}
+
+function coerceNumber(value) {
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
+ const parsed = Number(String(value ?? "").trim());
+ return Number.isFinite(parsed) ? parsed : null;
+}
+
+function inferSweepModeFromName(value) {
+ const normalized = String(value || "").toLowerCase();
+ if (normalized.includes("walkforward")) return "walkforward";
+ if (normalized.includes("grid")) return "grid";
+ return "sweep";
+}
+
+async function loadExperimentConfigPreview(configPath) {
+ if (!configPath) return "";
+ if (state.experimentConfigPreviewCache.has(configPath)) {
+ return state.experimentConfigPreviewCache.get(configPath);
+ }
+ const raw = await readOptionalProjectText(configPath);
+ const previewText = raw ? raw.split(/\r?\n/).slice(0, 48).join("\n") : "";
+ if (previewText) state.experimentConfigPreviewCache.set(configPath, previewText);
+ return previewText;
+}
+
function closeTab(tabId) {
state.tabs = state.tabs.filter((tab) => tab.id !== tabId);
if (state.activeTabId === tabId) state.activeTabId = state.tabs[0]?.id || null;
@@ -1025,7 +1277,7 @@ function closeTab(tabId) {
function syncNav(kind) {
document.querySelectorAll(".nav-item").forEach((item) => item.classList.remove("is-active"));
- const mapping = { chat: "open-chat", launch: "open-launch", runs: "open-runs", candidates: "open-candidates", compare: "open-compare", ops: "open-ops" };
+ const mapping = { chat: "open-chat", experiments: "open-experiments", launch: "open-launch", runs: "open-runs", candidates: "open-candidates", compare: "open-compare", ops: "open-ops" };
const target = document.querySelector(`.nav-item[data-action="${mapping[kind] || "open-chat"}"]`);
if (target) target.classList.add("is-active");
}
@@ -1111,6 +1363,10 @@ function renderRunTab(tab) {
return renderRunTabView(tab, getRendererContext());
}
+function renderExperimentsTab(tab) {
+ return renderExperimentsTabView(tab, getRendererContext());
+}
+
function renderCompareTab(tab) {
return renderCompareTabView(tab, getRendererContext());
}
@@ -1143,6 +1399,7 @@ function getRendererContext() {
return {
store: state.candidatesStore,
snapshot: state.snapshot,
+ experimentsWorkspace: state.experimentsWorkspace,
maxLogPreviewChars: CONFIG.maxLogPreviewChars,
decision: {
getCandidateEntry: (store, runId) => decisionStore.getCandidateEntry(store, runId),
@@ -1172,7 +1429,7 @@ function upsertTab(nextTab) {
}
function rerenderContextualTabs() {
- if (state.tabs.some((tab) => ["run", "compare", "artifacts", "candidates", "paper", "job"].includes(tab.kind))) renderTabs();
+ if (state.tabs.some((tab) => ["experiments", "run", "compare", "artifacts", "candidates", "paper", "job"].includes(tab.kind))) renderTabs();
}
function refreshLiveJobTabs() {
@@ -1231,6 +1488,7 @@ function getBrowserUrlForActiveContext() {
if (!state.workspace.serverUrl) return "";
const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId);
if (activeTab?.kind === "iframe") return activeTab.url;
+ if (activeTab?.kind === "experiments") return `${state.workspace.serverUrl}/research_ui/index.html#/launch`;
if (activeTab?.kind === "run") return getBrowserUrlForRun(activeTab.runId);
if (activeTab?.kind === "paper") return `${state.workspace.serverUrl}/research_ui/index.html#/ops`;
if (activeTab?.kind === "job") return `${state.workspace.serverUrl}/research_ui/index.html#/launch`;
diff --git a/desktop/renderer/index.html b/desktop/renderer/index.html
index 1be153b..44c3d87 100644
--- a/desktop/renderer/index.html
+++ b/desktop/renderer/index.html
@@ -22,6 +22,7 @@