From 8759cd28db04294f4bf8e0063c215936969c2fa1 Mon Sep 17 00:00:00 2001 From: Kiet <31864905+Kitenite@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:22:57 -0700 Subject: [PATCH 1/4] fix(desktop): use --no-track instead of ^{commit} in v1 createWorktree (#3548) v1's createWorktree appended ^{commit} to the start point to prevent implicit upstream tracking. This fails with "fatal: invalid reference" when the ref isn't locally resolvable with that suffix (e.g. stale or missing remote-tracking ref, branches with slashes like feat/workstreams-view). Replace ^{commit} with --no-track, which has the same effect without fragile ref suffix manipulation. Matches v2's host-service approach. Closes #3448 --- .../trpc/routers/workspaces/utils/git.test.ts | 83 +++++++++++++++++++ .../lib/trpc/routers/workspaces/utils/git.ts | 10 +-- 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts index 24db36461ad..dbb62151dc9 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts @@ -441,6 +441,89 @@ describe("createWorktree hook tolerance", () => { createWorktree(repoPath, "feature/existing-path", worktreePath, "HEAD"), ).rejects.toThrow("already exists"); }, 10_000); + + test("works with remote-tracking ref as start point (no-track prevents upstream)", async () => { + // Set up a "remote" repo with a commit, then clone it so we have origin/ refs + const originPath = join(TEST_DIR, "worktree-notrack-origin"); + mkdirSync(originPath, { recursive: true }); + execSync("git init -b main", { cwd: originPath, stdio: "ignore" }); + execSync("git config user.email 'test@test.com'", { + cwd: originPath, + stdio: "ignore", + }); + execSync("git config user.name 'Test'", { + cwd: originPath, + stdio: "ignore", + }); + writeFileSync(join(originPath, "README.md"), "# test\n"); + execSync("git add . && git commit -m 'init'", { + cwd: originPath, + stdio: "ignore", + }); + + const clonePath = join(TEST_DIR, "worktree-notrack-clone"); + execSync(`git clone "${originPath}" "${clonePath}"`, { + stdio: "ignore", + }); + execSync("git config user.email 'test@test.com'", { + cwd: clonePath, + stdio: "ignore", + }); + execSync("git config user.name 'Test'", { + cwd: clonePath, + stdio: "ignore", + }); + + const worktreePath = join(TEST_DIR, "worktree-notrack-wt"); + await createWorktree( + clonePath, + "feature/no-track-test", + worktreePath, + "origin/main", + ); + + expect(existsSync(worktreePath)).toBe(true); + + // Verify the new branch does NOT track origin/main + const trackingResult = execSync( + "git config --get branch.feature/no-track-test.remote 2>&1 || true", + { cwd: worktreePath }, + ) + .toString() + .trim(); + expect(trackingResult).toBe(""); + }, 15_000); + + test("works with a branch name containing slashes as start point", async () => { + // Reproduces #3448: createWorktree previously appended ^{commit} to the + // start point, which can fail with "fatal: invalid reference" when the ref + // is not locally resolvable with that suffix. Using --no-track avoids this. + const repoPath = createTestRepo("worktree-slash-branch"); + seedCommit(repoPath); + + // Create a branch with slashes (like feat/workstreams-view) + execSync("git checkout -b feat/workstreams-view", { + cwd: repoPath, + stdio: "ignore", + }); + execSync("git checkout -", { cwd: repoPath, stdio: "ignore" }); + + const worktreePath = join(TEST_DIR, "worktree-slash-branch-wt"); + await createWorktree( + repoPath, + "feature/new-workspace", + worktreePath, + "feat/workstreams-view", + ); + + expect(existsSync(worktreePath)).toBe(true); + const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", { + cwd: worktreePath, + }) + .toString() + .trim(); + expect(currentBranch).toBe("feature/new-workspace"); + }, 10_000); }); describe("getCurrentBranch", () => { diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts index 16a7499fd29..ec573b09430 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -585,13 +585,13 @@ export async function createWorktree( mainRepoPath, "worktree", "add", - worktreePath, + // --no-track prevents the new branch from tracking the remote ref + // (e.g. origin/main); push.autoSetupRemote handles first-push tracking. + "--no-track", "-b", branch, - // Append ^{commit} to force Git to treat the startPoint as a commit, - // not a branch ref. This prevents implicit upstream tracking when - // creating a new branch from a remote branch like origin/main. - `${startPoint}^{commit}`, + worktreePath, + startPoint, ], worktreePath, }); From 89ebc9d83932adf371e9927809d2634502545e69 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 21 Apr 2026 13:15:06 -0700 Subject: [PATCH 2/4] fix(desktop): don't nuke host services on app update (#3620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squirrel.Mac's update flow SIGTERMs the old app's process group, which reached the host-service child and caused two nukes: the child's shutdown handler deleted its own manifest, and the child itself was in the parent's process group so it died with the app. On relaunch the new app found no manifests and spawned fresh host-services, losing all in-memory state. - Drop removeManifest from the child's SIGTERM handler. The coordinator already owns manifest lifecycle on intentional stops and observed exits. - Spawn with detached: true and file-backed stdio at /host-service.log, so the child lives in its own process group and doesn't depend on parent-held pipes. Mirrors the existing terminal-daemon pattern. Tray Stop / Quit & Stop Services are unchanged — coordinator.stop() signals by pid, independent of process group. --- apps/desktop/src/main/host-service/index.ts | 6 +-- .../src/main/lib/host-service-coordinator.ts | 43 +++++++++++++------ 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/main/host-service/index.ts b/apps/desktop/src/main/host-service/index.ts index d4224a576fa..0a757b2b55a 100644 --- a/apps/desktop/src/main/host-service/index.ts +++ b/apps/desktop/src/main/host-service/index.ts @@ -18,7 +18,7 @@ import { resolveTerminalBaseEnv, } from "@superset/host-service/terminal-env"; import { connectRelay } from "@superset/host-service/tunnel"; -import { removeManifest, writeManifest } from "main/lib/host-service-manifest"; +import { writeManifest } from "main/lib/host-service-manifest"; import { env } from "./env"; async function main(): Promise { @@ -81,10 +81,8 @@ async function main(): Promise { ); injectWebSocket(server); + // Manifest lifecycle belongs to the coordinator, not the child. const shutdown = () => { - if (env.ORGANIZATION_ID) { - removeManifest(env.ORGANIZATION_ID); - } server.close(); process.exit(0); }; diff --git a/apps/desktop/src/main/lib/host-service-coordinator.ts b/apps/desktop/src/main/lib/host-service-coordinator.ts index a39c1898dde..7ad475f02df 100644 --- a/apps/desktop/src/main/lib/host-service-coordinator.ts +++ b/apps/desktop/src/main/lib/host-service-coordinator.ts @@ -55,6 +55,18 @@ const HEALTH_POLL_INTERVAL = 200; const HEALTH_POLL_TIMEOUT = 10_000; const ADOPTED_LIVENESS_INTERVAL = 5_000; +function openLogFile(organizationId: string): number { + try { + const dir = manifestDir(organizationId); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + return fs.openSync(path.join(dir, "host-service.log"), "a", 0o600); + } catch { + return -1; + } +} + async function findFreePort(): Promise { return new Promise((resolve, reject) => { const server = createServer(); @@ -402,10 +414,25 @@ export class HostServiceCoordinator extends EventEmitter { this.emitStatus(organizationId, "starting", null); const env = await this.buildEnv(organizationId, port, secret, config); - const child = childProcess.spawn(process.execPath, [this.scriptPath], { - stdio: ["ignore", "pipe", "pipe"], - env, - }); + + // Detached + file-backed stdio so the child outlives the parent's process + // group (Squirrel.Mac SIGTERMs it during updates) and doesn't depend on + // parent-held stdout/stderr pipes. + const logFd = openLogFile(organizationId); + let child: childProcess.ChildProcess; + try { + child = childProcess.spawn(process.execPath, [this.scriptPath], { + detached: true, + stdio: logFd >= 0 ? ["ignore", logFd, logFd] : "ignore", + env, + }); + } finally { + if (logFd >= 0) { + try { + fs.closeSync(logFd); + } catch {} + } + } const childPid = child.pid; if (!childPid) { @@ -415,14 +442,6 @@ export class HostServiceCoordinator extends EventEmitter { instance.pid = childPid; - child.stdout?.on("data", (data: Buffer) => { - console.log(`[host-service:${organizationId}] ${data.toString().trim()}`); - }); - child.stderr?.on("data", (data: Buffer) => { - console.error( - `[host-service:${organizationId}] ${data.toString().trim()}`, - ); - }); child.on("exit", (code) => { console.log(`[host-service:${organizationId}] exited with code ${code}`); const current = this.instances.get(organizationId); From 501eb664128c48f04547d34d4700c65037b4433e Mon Sep 17 00:00:00 2001 From: Kiet <31864905+Kitenite@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:18:57 -0700 Subject: [PATCH 3/4] =?UTF-8?q?refactor(desktop):=20host-service=20detach?= =?UTF-8?q?=20=E2=80=94=20rotation,=20perms,=20windowsHide,=20dev=20pipes?= =?UTF-8?q?=20(#3616)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop): keep v2 host-service alive across app updates The host-service child was spawned without `detached: true`, so it shared the Electron main's process group. When Squirrel relaunched the app on auto-update, the old app's process group got killed — taking the host-service and all its PTYs with it. The whole manifest-adoption design in HOST_SERVICE_LIFECYCLE.md assumed the child survives, but the spawn options contradicted that. Mirror the v1 terminal-host daemon pattern (client.ts:1160-1221): `detached: true` in prod, stdio pointed at a per-org rotating log file at `~/.superset/host/{orgId}/host.log` (piped stdio would EPIPE once the parent exits). Dev mode keeps pipes for live console logs since enableDevReload restarts instances on bundle rebuild anyway. * address review: normalize log perms + windowsHide - chmodSync log file after open: openSync's mode arg only applies on create, so a pre-existing file rotated out-of-band keeps old perms. - windowsHide: true on the spawn so the detached Node child doesn't flash a CMD window on Windows. * refactor(desktop): extract pure host-service helpers to host-service-utils * address review feedback - Gate detach on app.isPackaged instead of NODE_ENV. NODE_ENV is ambient and could silently flip detach off in a packaged app (e.g. shell env), reintroducing the Squirrel kill-chain this PR exists to fix. - Log a warning on chmodSync failure so permission issues are observable instead of being swallowed silently. - Move clearTimeout into finally in pollHealthCheck so the 2s abort timer is also cleared when fetch rejects, not just on success. --- .../src/main/lib/host-service-coordinator.ts | 122 ++++++++---------- .../src/main/lib/host-service-utils.ts | 85 ++++++++++++ 2 files changed, 140 insertions(+), 67 deletions(-) create mode 100644 apps/desktop/src/main/lib/host-service-utils.ts diff --git a/apps/desktop/src/main/lib/host-service-coordinator.ts b/apps/desktop/src/main/lib/host-service-coordinator.ts index 7ad475f02df..efec5c71b99 100644 --- a/apps/desktop/src/main/lib/host-service-coordinator.ts +++ b/apps/desktop/src/main/lib/host-service-coordinator.ts @@ -2,7 +2,6 @@ import * as childProcess from "node:child_process"; import { randomBytes } from "node:crypto"; import { EventEmitter } from "node:events"; import * as fs from "node:fs"; -import { createServer } from "node:net"; import path from "node:path"; import { settings } from "@superset/local-db"; import { getDeviceName, getHashedDeviceId } from "@superset/shared/device-info"; @@ -19,6 +18,13 @@ import { readManifest, removeManifest, } from "./host-service-manifest"; +import { + findFreePort, + HEALTH_POLL_TIMEOUT_MS, + MAX_HOST_LOG_BYTES, + openRotatingLogFd, + pollHealthCheck, +} from "./host-service-utils"; import { localDb } from "./local-db"; import { HOOK_PROTOCOL_VERSION } from "./terminal/env"; @@ -51,62 +57,8 @@ interface HostServiceProcess { status: HostServiceStatus; } -const HEALTH_POLL_INTERVAL = 200; -const HEALTH_POLL_TIMEOUT = 10_000; const ADOPTED_LIVENESS_INTERVAL = 5_000; -function openLogFile(organizationId: string): number { - try { - const dir = manifestDir(organizationId); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - return fs.openSync(path.join(dir, "host-service.log"), "a", 0o600); - } catch { - return -1; - } -} - -async function findFreePort(): Promise { - return new Promise((resolve, reject) => { - const server = createServer(); - server.listen(0, "127.0.0.1", () => { - const addr = server.address(); - if (addr && typeof addr === "object") { - const { port } = addr; - server.close(() => resolve(port)); - } else { - server.close(() => reject(new Error("Could not get port"))); - } - }); - server.on("error", reject); - }); -} - -async function pollHealthCheck( - endpoint: string, - secret: string, - timeoutMs = HEALTH_POLL_TIMEOUT, -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 2_000); - const res = await fetch(`${endpoint}/trpc/health.check`, { - signal: controller.signal, - headers: { Authorization: `Bearer ${secret}` }, - }); - clearTimeout(timeout); - if (res.ok) return true; - } catch { - // Not ready yet - } - await new Promise((r) => setTimeout(r, HEALTH_POLL_INTERVAL)); - } - return false; -} - export class HostServiceCoordinator extends EventEmitter { private instances = new Map(); private pendingStarts = new Map>(); @@ -413,24 +365,48 @@ export class HostServiceCoordinator extends EventEmitter { this.instances.set(organizationId, instance); this.emitStatus(organizationId, "starting", null); - const env = await this.buildEnv(organizationId, port, secret, config); - - // Detached + file-backed stdio so the child outlives the parent's process - // group (Squirrel.Mac SIGTERMs it during updates) and doesn't depend on - // parent-held stdout/stderr pipes. - const logFd = openLogFile(organizationId); - let child: childProcess.ChildProcess; + const childEnv = await this.buildEnv(organizationId, port, secret, config); + // Gate on app.isPackaged — the authoritative "running from an installed + // bundle" signal. NODE_ENV is ambient (shell, wrappers, debug launches) + // and could silently flip detach off in a packaged app, which would + // re-introduce the exact Squirrel kill-chain this file exists to fix. + const isPackaged = app.isPackaged; + + // In packaged builds, detach so the child survives app relaunch: + // auto-updater's quitAndInstall would otherwise take the host-service + // (and its PTYs) down with the old app's process group. Stdio must + // point at real fds — piped stdio would EPIPE once the parent exits. + // Unpackaged (dev) keeps pipes so logs flow to the Electron console; + // enableDevReload restarts instances on rebuild, so survival isn't + // needed. + const logFd = isPackaged + ? openRotatingLogFd( + path.join(manifestDir(organizationId), "host-service.log"), + MAX_HOST_LOG_BYTES, + ) + : -1; + const stdio: childProcess.StdioOptions = !isPackaged + ? ["ignore", "pipe", "pipe"] + : logFd >= 0 + ? ["ignore", logFd, logFd] + : ["ignore", "ignore", "ignore"]; + + let child: ReturnType; try { child = childProcess.spawn(process.execPath, [this.scriptPath], { - detached: true, - stdio: logFd >= 0 ? ["ignore", logFd, logFd] : "ignore", - env, + detached: isPackaged, + stdio, + env: childEnv, + // Avoid a flashing CMD window on Windows for the detached child. + windowsHide: true, }); } finally { if (logFd >= 0) { try { fs.closeSync(logFd); - } catch {} + } catch { + // Best-effort — child has its own dup of the fd. + } } } @@ -442,6 +418,18 @@ export class HostServiceCoordinator extends EventEmitter { instance.pid = childPid; + if (!isPackaged) { + child.stdout?.on("data", (data: Buffer) => { + console.log( + `[host-service:${organizationId}] ${data.toString().trim()}`, + ); + }); + child.stderr?.on("data", (data: Buffer) => { + console.error( + `[host-service:${organizationId}] ${data.toString().trim()}`, + ); + }); + } child.on("exit", (code) => { console.log(`[host-service:${organizationId}] exited with code ${code}`); const current = this.instances.get(organizationId); @@ -460,7 +448,7 @@ export class HostServiceCoordinator extends EventEmitter { child.kill("SIGTERM"); this.instances.delete(organizationId); throw new Error( - `Host service failed to start within ${HEALTH_POLL_TIMEOUT}ms`, + `Host service failed to start within ${HEALTH_POLL_TIMEOUT_MS}ms`, ); } diff --git a/apps/desktop/src/main/lib/host-service-utils.ts b/apps/desktop/src/main/lib/host-service-utils.ts new file mode 100644 index 00000000000..e638c47a4d1 --- /dev/null +++ b/apps/desktop/src/main/lib/host-service-utils.ts @@ -0,0 +1,85 @@ +import * as fs from "node:fs"; +import { createServer } from "node:net"; +import path from "node:path"; + +/** Rotate per-org host-service.log once it exceeds this size. */ +export const MAX_HOST_LOG_BYTES = 5 * 1024 * 1024; + +export const HEALTH_POLL_TIMEOUT_MS = 10_000; + +const HEALTH_POLL_INTERVAL_MS = 200; + +/** + * Open an append-mode log fd, truncating first if it exceeds maxBytes. + * Returns -1 on failure so callers can fall back to ignoring child stdio. + */ +export function openRotatingLogFd(logPath: string, maxBytes: number): number { + try { + fs.mkdirSync(path.dirname(logPath), { recursive: true, mode: 0o700 }); + if (fs.existsSync(logPath)) { + try { + const { size } = fs.statSync(logPath); + if (size > maxBytes) { + fs.writeFileSync(logPath, "", { mode: 0o600 }); + } + } catch { + // Best-effort rotate + } + } + const fd = fs.openSync(logPath, "a", 0o600); + // openSync's mode arg only applies on create — normalize an existing + // file's perms in case it was rotated out-of-band with laxer bits. + try { + fs.chmodSync(logPath, 0o600); + } catch (error) { + console.warn( + `[host-service] Failed to chmod log file ${logPath}: ${error}`, + ); + } + return fd; + } catch (error) { + console.warn(`[host-service] Failed to open log file ${logPath}: ${error}`); + return -1; + } +} + +export async function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + if (addr && typeof addr === "object") { + const { port } = addr; + server.close(() => resolve(port)); + } else { + server.close(() => reject(new Error("Could not get port"))); + } + }); + server.on("error", reject); + }); +} + +export async function pollHealthCheck( + endpoint: string, + secret: string, + timeoutMs = HEALTH_POLL_TIMEOUT_MS, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 2_000); + try { + const res = await fetch(`${endpoint}/trpc/health.check`, { + signal: controller.signal, + headers: { Authorization: `Bearer ${secret}` }, + }); + if (res.ok) return true; + } catch { + // Not ready yet + } finally { + clearTimeout(timeout); + } + await new Promise((r) => setTimeout(r, HEALTH_POLL_INTERVAL_MS)); + } + return false; +} From 3da4dd01a37d32bd37534996697d8bcab17f7bb1 Mon Sep 17 00:00:00 2001 From: Kiet <31864905+Kitenite@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:31:24 -0700 Subject: [PATCH 4/4] feat(setup): clone v2 host-service DBs alongside v1 local DB (#3630) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(setup): clone v2 host-service DBs alongside v1 local DB Seeds ~/.superset/host//host.db into superset-dev-data/host// so v2 workspaces open with host-service state prefilled, matching the existing v1 local.db clone behavior. * feat(setup): seed Electron renderer Local Storage from prod userData The v2 sidebar reads pinned-workspace state from renderer localStorage (`v2WorkspaceLocalState`, etc.), which lives in the per-app userData dir. Each dev worktree gets a fresh `Superset ()` userData → empty sidebar even when the cloud + host.db clones have plenty of accessible workspaces. Copy `Local Storage/` from the prod app's userData using APFS clonefile so prod can keep running. * feat(desktop): auto-pin accessible v2 workspaces in dev sidebar Drop the renderer-state file-copy approach (Chromium localStorage is per-origin: dev runs on http://localhost:, prod is file://, so copying the leveldb seeds the wrong namespace). Instead, add a small dev-only hook in the dashboard layout that pins every accessible v2 workspace once per worktree's userData. A localStorage flag prevents re-pinning workspaces the user has explicitly unpinned later. * chore(deslop): tighten useDevSeedV2Sidebar JSDoc and drop restate-the-code comment * chore(deslop): drop restate-the-code comment in step_seed_host_dbs * Lint * fix(setup): clean up partial host DB copies on failure If host.db copies but a -wal/-shm sibling fails, leaving the lone host.db in place would make the next non-force run skip the org as "already seeded." Wipe the per-org dest before returning the error. Caught by coderabbitai on PR #3630. --- .superset/lib/setup/main.sh | 7 +- .superset/lib/setup/steps.sh | 90 +++++++++++++++++++ .../_authenticated/_dashboard/layout.tsx | 2 + .../hooks/useDevSeedV2Sidebar/index.ts | 1 + .../useDevSeedV2Sidebar.ts | 36 ++++++++ 5 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/hooks/useDevSeedV2Sidebar/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/hooks/useDevSeedV2Sidebar/useDevSeedV2Sidebar.ts diff --git a/.superset/lib/setup/main.sh b/.superset/lib/setup/main.sh index 681cc21ef17..3ad4e6cfa03 100644 --- a/.superset/lib/setup/main.sh +++ b/.superset/lib/setup/main.sh @@ -36,7 +36,12 @@ setup_main() { step_failed "Seed local DB" fi - # Step 5: Seed auth token into superset-dev-data/ + # Step 5: Seed host-service DBs into superset-dev-data/host/ + if ! step_seed_host_dbs; then + step_failed "Seed host-service DBs" + fi + + # Step 6: Seed auth token into superset-dev-data/ if ! step_seed_auth_token; then step_failed "Seed auth token" fi diff --git a/.superset/lib/setup/steps.sh b/.superset/lib/setup/steps.sh index 2043b023c95..e9df8723e6a 100644 --- a/.superset/lib/setup/steps.sh +++ b/.superset/lib/setup/steps.sh @@ -603,6 +603,96 @@ step_seed_auth_token() { return 0 } +step_seed_host_dbs() { + echo "🛰️ Seeding host-service DBs into superset-dev-data/host/..." + + local source_root="$HOME/.superset/host" + local dev_data_dir="superset-dev-data" + local dest_root="$dev_data_dir/host" + local force_overwrite="$FORCE_OVERWRITE_DATA" + + if [ ! -d "$source_root" ]; then + warn "No host-service DBs found at $source_root — skipping (host-service will create fresh DBs per org)" + step_skipped "Seed host-service DBs (no source dir)" + return 0 + fi + + local org_dirs=() + for org_dir in "$source_root"/*/; do + [ -d "$org_dir" ] || continue + local org_id + org_id="$(basename "$org_dir")" + if [ -f "${org_dir}host.db" ]; then + org_dirs+=("$org_id") + fi + done + + if [ ${#org_dirs[@]} -eq 0 ]; then + warn "No host.db files under $source_root — skipping" + step_skipped "Seed host-service DBs (no host.db files)" + return 0 + fi + + mkdir -p "$dest_root" + chmod 700 "$dev_data_dir" "$dest_root" + + local seeded=0 + local skipped=0 + for org_id in "${org_dirs[@]}"; do + local source_db="$source_root/$org_id/host.db" + local dest_org_dir="$dest_root/$org_id" + local dest_db="$dest_org_dir/host.db" + + if [ -f "$dest_db" ] && [ "$force_overwrite" != "1" ]; then + warn "Host DB already exists at $dest_db — skipping (use -f/--force)" + skipped=$((skipped + 1)) + continue + fi + + mkdir -p "$dest_org_dir" + chmod 700 "$dest_org_dir" + + # Clear stale WAL siblings when overwriting so we don't mix old WAL + # data with a freshly-copied DB (their page pointers won't match). + if [ "$force_overwrite" = "1" ]; then + rm -f "$dest_db" "${dest_db}-shm" "${dest_db}-wal" + fi + + # Copy all SQLite files so WAL data isn't lost when source is held open. + local copy_failed=0 + for ext in "" "-shm" "-wal"; do + local source_file="${source_db}${ext}" + local dest_file="${dest_db}${ext}" + + if [ -f "$source_file" ]; then + if ! cp "$source_file" "$dest_file"; then + error "Failed to copy $source_file to $dest_file" + copy_failed=1 + break + fi + chmod 600 "$dest_file" + fi + done + + if [ "$copy_failed" = "1" ]; then + # A lone host.db without its -wal/-shm siblings would make the next + # non-force run think this org is already seeded and skip it. + rm -f "$dest_db" "${dest_db}-shm" "${dest_db}-wal" + return 1 + fi + + # Checkpoint the copy's WAL (no lock contention since nothing else has it open). + if command -v sqlite3 &> /dev/null; then + sqlite3 "$dest_db" "PRAGMA wal_checkpoint(TRUNCATE);" &> /dev/null || true + fi + + seeded=$((seeded + 1)) + done + + success "Host-service DBs seeded ($seeded copied, $skipped skipped) from $source_root" + return 0 +} + step_seed_local_db() { echo "💾 Seeding local DB into superset-dev-data/..." diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx index 48cc0463905..859996e487c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx @@ -15,6 +15,7 @@ import { import { useHotkey } from "renderer/hotkeys"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { DashboardSidebar } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar"; +import { useDevSeedV2Sidebar } from "renderer/routes/_authenticated/hooks/useDevSeedV2Sidebar"; import { ResizablePanel } from "renderer/screens/main/components/ResizablePanel"; import { WorkspaceSidebar } from "renderer/screens/main/components/WorkspaceSidebar"; import { DeleteWorkspaceDialog } from "renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components"; @@ -36,6 +37,7 @@ function DashboardLayout() { const navigate = useNavigate(); const openNewWorkspaceModal = useOpenNewWorkspaceModal(); const { isV2CloudEnabled } = useIsV2CloudEnabled(); + useDevSeedV2Sidebar(); // Get current workspace from route to pre-select project in new workspace modal const matchRoute = useMatchRoute(); const currentWorkspaceMatch = matchRoute({ diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDevSeedV2Sidebar/index.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDevSeedV2Sidebar/index.ts new file mode 100644 index 00000000000..e8aea8edc06 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDevSeedV2Sidebar/index.ts @@ -0,0 +1 @@ +export { useDevSeedV2Sidebar } from "./useDevSeedV2Sidebar"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDevSeedV2Sidebar/useDevSeedV2Sidebar.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDevSeedV2Sidebar/useDevSeedV2Sidebar.ts new file mode 100644 index 00000000000..485d3791dfb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDevSeedV2Sidebar/useDevSeedV2Sidebar.ts @@ -0,0 +1,36 @@ +import { useEffect } from "react"; +import { env } from "renderer/env.renderer"; +import { useAccessibleV2Workspaces } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces"; +import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; + +const SEED_FLAG_KEY = "superset:dev:v2-sidebar-seeded"; + +/** + * Auto-pins accessible v2 workspaces in dev so a fresh worktree's sidebar + * isn't blank. Chromium's localStorage is per-origin: the dev Vite origin + * (`http://localhost:`) can't share data with the packaged `file://` + * origin, so copying prod's leveldb seeds the wrong namespace. We pin at + * runtime instead. The flag prevents re-pinning workspaces the user later + * unpins. + */ +export function useDevSeedV2Sidebar(): void { + const collections = useCollections(); + const { ensureWorkspaceInSidebar } = useDashboardSidebarState(); + const { all: accessibleWorkspaces } = useAccessibleV2Workspaces(); + + useEffect(() => { + if (env.NODE_ENV !== "development") return; + if (window.localStorage.getItem(SEED_FLAG_KEY) === "1") return; + if (accessibleWorkspaces.length === 0) return; + if (collections.v2WorkspaceLocalState.state.size > 0) { + window.localStorage.setItem(SEED_FLAG_KEY, "1"); + return; + } + + for (const workspace of accessibleWorkspaces) { + ensureWorkspaceInSidebar(workspace.id, workspace.projectId); + } + window.localStorage.setItem(SEED_FLAG_KEY, "1"); + }, [accessibleWorkspaces, collections, ensureWorkspaceInSidebar]); +}