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/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, }); 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..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,50 +57,8 @@ interface HostServiceProcess { status: HostServiceStatus; } -const HEALTH_POLL_INTERVAL = 200; -const HEALTH_POLL_TIMEOUT = 10_000; const ADOPTED_LIVENESS_INTERVAL = 5_000; -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>(); @@ -401,11 +365,50 @@ export class HostServiceCoordinator extends EventEmitter { this.instances.set(organizationId, instance); 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, - }); + 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: 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 { + // Best-effort — child has its own dup of the fd. + } + } + } const childPid = child.pid; if (!childPid) { @@ -415,14 +418,18 @@ 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()}`, - ); - }); + 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); @@ -441,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; +} 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]); +}