diff --git a/apps/desktop/HOST_SERVICE_ARCHITECTURE.md b/apps/desktop/HOST_SERVICE_ARCHITECTURE.md new file mode 100644 index 00000000000..528479413f0 --- /dev/null +++ b/apps/desktop/HOST_SERVICE_ARCHITECTURE.md @@ -0,0 +1,62 @@ +# Host Service Architecture + +What a host service is, how it's layered, and what needs to change. + +## What is a host service? + +A process that runs workspaces on a machine — laptop or remote server. It clones repos, runs terminals, watches filesystems, runs AI chat, and registers itself with the cloud as a **host**. + +A **device** is anything that connects (phone, browser, desktop app). A **host** is something that runs workspaces. A MacBook is both. A phone is only a device. A remote server is only a host. + +The host service must be deployable standalone with zero Electron awareness. + +## Layering + +``` +┌──────────────────────────────────────────────────────────────┐ +│ ELECTRON DESKTOP (apps/desktop) │ +│ │ +│ Owns: │ +│ - Spawning / adopting / releasing host service processes │ +│ - Desktop-specific credential providers │ +│ - Session config (auth token, cloud API URL) │ +│ - System tray UI │ +│ - Quit flow (release vs stop) │ +│ - Manifest files (on-disk persistence for process adoption) │ +│ │ +│ Does NOT own: │ +│ - Workspace CRUD, host registration, terminal sessions │ +│ - Organization metadata (the host service knows its own) │ +│ - Any business logic a remote host would also need │ +├──────────────────────────────────────────────────────────────┤ +│ HOST SERVICE (packages/host-service) │ +│ │ +│ Owns: │ +│ - Workspace lifecycle (create, delete, list) │ +│ - Host registration with the cloud │ +│ - Terminal PTY management │ +│ - Filesystem watching │ +│ - Git operations │ +│ - AI chat runtime │ +│ - Its own identity and metadata (host.info endpoint) │ +│ │ +│ Does NOT own: │ +│ - How it was started (Electron vs systemd vs docker) │ +│ - Credential discovery (keychain, ~/.claude, git cred mgr) │ +│ - Default paths like ~/.superset/host.db │ +│ - Electron concepts (resourcesPath, manifests, etc.) │ +└──────────────────────────────────────────────────────────────┘ +``` + +## Host vs Device + +Rename in host service context: +- `deviceClientId` → `hostId` (generated internally from machine identity) +- `deviceName` → `hostName` (generated internally from `os.hostname()`) +- `device.ensureV2Host` → `host.register` + +Host identity is intrinsic — the host service generates it at startup, not passed in as config. + +--- + +For API shapes, boundaries, and concrete migration steps, see [HOST_SERVICE_BOUNDARIES.md](./HOST_SERVICE_BOUNDARIES.md). diff --git a/apps/desktop/HOST_SERVICE_BOUNDARIES.md b/apps/desktop/HOST_SERVICE_BOUNDARIES.md new file mode 100644 index 00000000000..21340029654 --- /dev/null +++ b/apps/desktop/HOST_SERVICE_BOUNDARIES.md @@ -0,0 +1,397 @@ +# Host Service Boundaries + +API shapes and boundaries between the host service, the Electron desktop layer, and the tray. + +--- + +## 1. Host Service (`packages/host-service`) + +### `createApp()` — the sole entry point + +```ts +createApp({ + config: { + dbPath: string, // where the SQLite database lives + cloudApiUrl: string, // where the cloud API is + migrationsPath: string, // where Drizzle migration files live + allowedOrigins: string[], // CORS allowlist + }, + providers: { + auth: ApiAuthProvider, // outbound: how to authenticate with the cloud API + hostAuth: HostAuthProvider, // inbound: how to validate requests to this service + credentials: GitCredentialProvider, // how to get git/GitHub credentials + modelResolver: ModelProviderResolver, // how to resolve AI model credentials + }, +}); +``` + +All fields required. No optional fields. No defaults that assume a desktop environment. + +**Config** = static values (strings, paths, URLs). **Providers** = injectable behavior (interfaces with different implementations per deployment). + +**Not config, not providers:** + +- `hostId` / `hostName` — generated internally by the host service from machine identity +- Version — the service reads its own version from package.json, not from a passed-in string. + +### Provider interfaces + +```ts +interface ApiAuthProvider { + getHeaders(): Promise>; +} + +interface HostAuthProvider { + validate(request: Request): Promise; + validateToken(token: string): Promise; +} + +interface GitCredentialProvider { + getToken(host: string): Promise; +} + +interface ModelProviderResolver { + resolve(cwd: string): Promise; + // Returns env vars — does NOT mutate process.env +} +``` + +### tRPC endpoints + +**Unauthenticated (liveness probes):** + +```ts +health.check → { status: "ok" } +``` + +**Authenticated (PSK) — host identity and metadata:** + +This is how the tray gets the information it needs. `host.info` is the single source of truth for "who is this host" — no metadata passed through the Electron layer. + +```ts +host.info → { + hostId: string, + hostName: string, + organization: { + id: string, + name: string, + slug: string, + }, + version: string, // from package.json + platform: string, + uptime: number, +} +``` + +**Authenticated (PSK) — workspace and project management:** + +```ts +workspace.create → ... +workspace.delete → ... +workspace.list → ... +project.remove → ... // renamed from removeFromDevice +``` + +**Authenticated (PSK) — WebSocket routes:** + +```ts +terminal/* → WebSocket +filesystem/* → WebSocket +``` + +### What the host service is NOT + +`createApp()` is a factory — it wires config + providers into a Hono server and returns it. There is no "host service manager" inside the package. The complexity of the current `createApp()` (~150 lines) is just plumbing: create DB, create git factory, create API client, register routes. Provider construction is one-liners (`new PskHostAuthProvider(secret)`, etc.) — the callers are simple. + +--- + +## 2. Electron Coordinator (`apps/desktop`) + +Manages host service child processes. This is the only complex piece on the Electron side. + +### Interface + +```ts +interface HostServiceCoordinator { + // Lifecycle + start(organizationId: string, config: SpawnConfig): Promise<{ port: number; secret: string }>; + stop(organizationId: string): void; + restart(organizationId: string, config: SpawnConfig): Promise<{ port: number; secret: string }>; + stopAll(): void; + releaseAll(): void; + + // Discovery + discoverAll(): Promise; // scan manifests, adopt running services + + // Queries + getConnection(organizationId: string): { port: number; secret: string } | null; + getProcessStatus(organizationId: string): ProcessStatus; + getActiveOrganizationIds(): string[]; + hasActiveInstances(): boolean; + + // Events + on(event: "status-changed", handler: (e: StatusEvent) => void): void; +} + +interface SpawnConfig { + authToken: string; + cloudApiUrl: string; + dbPath: string; + migrationsPath: string; + allowedOrigins: string[]; +} + +type ProcessStatus = "starting" | "running" | "degraded" | "restarting" | "stopped"; + +interface StatusEvent { + organizationId: string; + status: ProcessStatus; + previousStatus: ProcessStatus | null; +} +``` + +### Per-instance state + +After a service is running (whether spawned or adopted), the coordinator holds: + +```ts +{ + pid: number, // the OS process ID — used for liveness checks and SIGTERM + port: number, // from ready message (spawned) or manifest (adopted) + secret: string, // PSK for authenticating with this instance +} +``` + +That's the steady-state. During spawn, the coordinator picks a free port, passes it to the host service as config (env var), then polls `health.check` on that port until the service is up. No Node IPC channel needed — the host service just starts on the port it's told. Once healthy, the coordinator records the pid/port/secret and discards the `ChildProcess` handle (`unref`'d so it survives app quit). From that point, spawned and adopted processes are treated identically: just a PID to check liveness and signal, a port to connect to, and a secret to authenticate. + +### Where the complexity lives + +The coordinator is ~500 lines. This is irreducible complexity from managing processes that survive app restarts: + +| Concern | Why it's unavoidable | +|---------|---------------------| +| Spawn + health poll | Must start the child, poll health.check until ready, handle timeout | +| Adoption from manifests | Must read disk, health-check the process, verify it's reachable | +| Liveness polling | Adopted processes have no exit event — must poll PID | +| Restart with backoff | Crashed services need exponential backoff, not immediate retry | +| Pending start dedup | Concurrent `start()` calls for the same org must coalesce | +| Release vs stop | Quit flow needs to either detach or kill each service | + +The current 800-line manager mixes these with org metadata, session config, display formatting, compatibility checks, and version tracking. The coordinator drops all of that — it only manages processes. The ~300 lines saved aren't from removing complexity; they're from removing concerns that don't belong. + +### What the coordinator does NOT hold + +| Data | Where it lives instead | +|------|----------------------| +| Organization name/metadata | Host service (`host.info` endpoint) | +| Auth token, cloud API URL | Passed per-call as `SpawnConfig`, not stored | +| Service version | Host service (`host.info` endpoint) | +| Uptime | Host service (`host.info` endpoint) | +| Compatibility / pending restart | Derived at query time by comparing `host.info` version vs app version | + +### Config passing + +```ts +// Before (mutate-then-call anti-pattern) +manager.setAuthToken(token); +manager.setCloudApiUrl(url); +manager.setOrganizationName(organizationId, name); +await manager.start(organizationId); + +// After (pass config per-call) +await coordinator.start(organizationId, { + authToken: token, + cloudApiUrl: url, + dbPath: path.join(orgDir, "host.db"), + migrationsPath: getMigrationsPath(), + allowedOrigins: [`http://localhost:${vitePort}`], +}); +``` + +--- + +## 3. Tray (`apps/desktop`) + +Pure view. Reads from two sources, writes to coordinator. + +### Data sources + +``` +From host.info (HTTP to each service, authenticated with PSK): + - organization.name → menu section header + - version → display label + - uptime → display label + +From coordinator (in-process): + - status → "Running" / "Starting..." / "Degraded" + - hasActiveInstances → controls quit menu options +``` + +### Actions + +``` +Restart → coordinator.restart(organizationId, config) +Stop → coordinator.stop(organizationId) +Quit (keep services) → coordinator.releaseAll() + app.exit() +Quit (stop services) → coordinator.stopAll() + app.exit() +``` + +### Menu structure + +``` +Host Service (N) +├── ← from host.info +│ ├── Running (v1.2.3) ← status from coordinator, version from host.info +│ ├── Uptime: 2h 15m ← from host.info +│ ├── Restart +│ └── Stop +├── ───────── +├── +│ └── ... +├── ───────── +├── Open Superset +├── Settings +├── Check for Updates +├── ───────── +├── Quit (Keep Services Running) ← only if hasActiveInstances +└── Quit & Stop Services ← only if hasActiveInstances +``` + +--- + +## 4. Renderer HostServiceProvider (`apps/desktop`) + +Queries the coordinator for connection info, then talks directly to host services over HTTP/WS. + +```ts +// From coordinator (via tRPC IPC) +const { port, secret } = await trpc.hostService.getConnection.query({ organizationId }); + +// Direct to host service (HTTP/WS) +const client = createHostServiceClient(port, secret); +await client.workspace.list.query(); +``` + +The provider maintains `Map` — just connection info. No metadata caching. + +--- + +## 5. Manifest (`apps/desktop` — Electron-only concept) + +On-disk JSON file per org. Written by the coordinator once the spawned service reports it's ready (pid, port). Read by the coordinator for adoption on next app launch. The host service itself has no knowledge of manifests. + +```ts +interface Manifest { + pid: number, + endpoint: string, // e.g. "http://127.0.0.1:4832" + authToken: string, // PSK secret for this instance + startedAt: number, + organizationId: string, +} +``` + +Minimal — just enough to reconnect. No version or protocol fields; the coordinator queries `host.info` after adoption for metadata if needed. + +Lives at `~/.superset/host//manifest.json`. The coordinator writes and reads it. Remote deployments don't use manifests. + +--- + +## 6. What moves where + +### Out of `packages/host-service` + +| Item | Current location | Moves to | Reason | +| --- | --- | --- | --- | +| `process.resourcesPath` / `ELECTRON_RUN_AS_NODE` | `db.ts` | Electron entry point | `migrationsPath` is now required config | +| `ORGANIZATION_ID` from `process.env` | `health.ts` | Removed | Org info served via `host.info`, fetched from cloud at registration | +| `LocalModelProvider` as default | `app.ts` | Injected by caller | `modelResolver` is required, no default | +| `LocalGitCredentialProvider` as default | `app.ts` | Injected by caller | `credentials` is required, no default | +| Default `~/.superset/host.db` | `app.ts` | Injected by caller | `dbPath` is required, no default | +| `~/.superset/chat-anthropic-env.json` | `anthropic-runtime-env.ts` | Moves with `LocalModelProvider` | Desktop-only path | +| macOS Keychain reads | `resolveAnthropicCredential.ts` | Moves with `LocalModelProvider` | macOS-only | +| `~/.claude/` credential reads | `resolveAnthropicCredential.ts` | Moves with `LocalModelProvider` | Claude Desktop-only | +| `project.removeFromDevice` | `project.ts` | Rename to `project.remove` | "Device" framing is wrong | +| `process.env` mutations in `applyRuntimeEnv()` | `runtime-env.ts` | Model providers return env, don't mutate | Dangerous in multi-tenant context | +| `health.info` (current combined endpoint) | `health.ts` | Split into `health.check` + `host.info` | Liveness vs metadata are different concerns | + +### Stays in `packages/host-service` + +| Item | Why | +| --- | --- | +| Workspace CRUD | Core host responsibility | +| Host registration (renamed from device) | Host registers itself as a network node | +| Terminal PTY management | Core host responsibility | +| Filesystem watching | Core host responsibility | +| Git operations | Core host responsibility | +| AI chat runtime | Core host responsibility | +| `health.check` (liveness only) | Every service needs this | +| `host.info` (new, authenticated) | Host is the source of truth for its own identity | +| `PskHostAuthProvider` | Pure validation, works everywhere | +| `CloudGitCredentialProvider` / `CloudModelProvider` | Cloud-backed, environment-agnostic | +| Shell resolution (`process.platform` in terminal) | Terminals inherently need to know the OS | +| `terminal_sessions` table | Session tracking is host-service state | + +### Gaps to fix in standalone `serve.ts` + +| Gap | Fix | +| --- | --- | +| `auth` / `cloudApiUrl` not passed | Make required — standalone needs cloud connectivity | +| `credentials` defaults to `LocalGitCredentialProvider` | Use `CloudGitCredentialProvider` | +| `modelResolver` defaults to `LocalModelProvider` | Use `CloudModelProvider` | +| No terminal session reconciliation at startup | Mark orphaned `"active"` sessions as `"disposed"` on boot | +| `health.info` unauthenticated | Move metadata to `host.info` behind PSK auth | + +--- + +## 7. Entry point examples + +### Electron + +```ts +// apps/desktop/src/main/host-service/index.ts +import { createApp, PskHostAuthProvider, JwtApiAuthProvider } from "@superset/host-service"; +import { LocalGitCredentialProvider } from "@superset/host-service/providers/desktop"; +import { LocalModelProvider } from "@superset/host-service/providers/desktop"; + +createApp({ + config: { + dbPath: path.join(orgDir, "host.db"), + cloudApiUrl: env.CLOUD_API_URL, + migrationsPath: app.isPackaged + ? path.join(process.resourcesPath, "resources/host-migrations") + : path.join(app.getAppPath(), "../../packages/host-service/drizzle"), + allowedOrigins: [`http://localhost:${desktopVitePort}`], + }, + providers: { + auth: new JwtApiAuthProvider(authToken), + hostAuth: new PskHostAuthProvider(secret), + credentials: new LocalGitCredentialProvider(), + modelResolver: new LocalModelProvider(), + }, +}); +``` + +### Standalone + +```ts +// packages/host-service/src/serve.ts +import { createApp, PskHostAuthProvider, JwtApiAuthProvider, + CloudGitCredentialProvider, CloudModelProvider } from "./index"; + +createApp({ + config: { + dbPath: env.HOST_DB_PATH, + cloudApiUrl: env.CLOUD_API_URL, + migrationsPath: join(import.meta.dirname, "../../drizzle"), + allowedOrigins: env.CORS_ORIGINS, + }, + providers: { + auth: new JwtApiAuthProvider(env.AUTH_TOKEN), + hostAuth: new PskHostAuthProvider(env.HOST_SERVICE_SECRET), + credentials: new CloudGitCredentialProvider(), + modelResolver: new CloudModelProvider(), + }, +}); +``` + +No `if (process.resourcesPath)`. No `if (platform() === "darwin")`. No `~/.superset` defaults. The host service is a pure server; the caller decides how it's configured. diff --git a/apps/desktop/HOST_SERVICE_LIFECYCLE.md b/apps/desktop/HOST_SERVICE_LIFECYCLE.md new file mode 100644 index 00000000000..3daf0d05dc4 --- /dev/null +++ b/apps/desktop/HOST_SERVICE_LIFECYCLE.md @@ -0,0 +1,75 @@ +# Host Service Lifecycle + +## Architecture + +Electron main owns app lifecycle, tray, and host-service management. Host-services run as child processes that can outlive the app via manifest-based adoption. + +``` +┌─────────────────────────────────────────────────────┐ +│ Electron Main Process │ +│ │ +│ ┌──────────┐ ┌──────────────────────┐ ┌───────┐ │ +│ │ Tray │ │ HostServiceManager │ │Windows│ │ +│ │ (macOS) │ │ │ │ │ │ +│ │ │◄─┤ status events │ │ hide/ │ │ +│ │ restart │ │ start/stop/adopt │ │ show │ │ +│ │ stop │ │ per org │ │ │ │ +│ │ quit ────┼──┼──► requestQuit(mode) │ │ │ │ +│ └──────────┘ └──────┬───────────────┘ └───────┘ │ +└───────────────────────┼─────────────────────────────┘ + │ IPC + stdio + ┌─────────────┼─────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌────────────┐ ┌────────────┐ ┌────────────┐ + │host-service│ │host-service│ │host-service│ + │ (org A) │ │ (org B) │ │ (org C) │ + │ │ │ │ │ │ + │ HTTP/tRPC │ │ HTTP/tRPC │ │ HTTP/tRPC │ + │ port:rand │ │ port:rand │ │ port:rand │ + │ │ │ │ │ │ + │ writes │ │ writes │ │ writes │ + │ manifest │ │ manifest │ │ manifest │ + └────────────┘ └────────────┘ └────────────┘ + │ │ │ + ▼ ▼ ▼ + ~/.superset/host/{orgId}/manifest.json +``` + +### Quit modes + +All quit paths use a single `QuitMode` (`"release" | "stop"`): + +- **release** — detach from services, they keep running for re-adoption on next launch +- **stop** — SIGTERM all services, then exit +- **implicit** (Cmd+Q with active services on macOS) — hide windows to tray + +### Manifest adoption + +Each host-service child writes `~/.superset/host/{orgId}/manifest.json` on startup (pid, endpoint, authToken, version). It's a pidfile extended with connection info. + +- **Release quit** — children keep running, manifests stay on disk +- **Next launch** — `discoverAndAdoptAll()` scans manifests, health-checks each pid/endpoint, reconnects if healthy, removes and respawns if not +- **Stop quit** — SIGTERM children, they remove their own manifests on shutdown + +``` +App Launch App Quit (release) Next Launch +───────── ────────────────── ─────────── +spawn child ──► child writes parent detaches scan manifests + manifest.json manifests stay on disk health-check pid/endpoint + {pid, endpoint, child keeps running ├─ healthy → reconnect + authToken, ...} └─ dead/bad → remove, respawn +``` + +### v1 vs v2 terminal paths + +v1 terminals run on a separate **terminal-host daemon** (`src/main/terminal-host/`) — a persistent background process that owns PTYs over a Unix domain socket. It has its own survival and reconnection model independent of host-service. + +v2 terminals run through **host-service** child processes. The quit/adopt/tray lifecycle described here only applies to host-service instances. + +### Design decisions + +- **No supervisor process.** Electron main owns everything. Simpler while v1 and v2 coexist. +- **No tray on Windows/Linux.** Services still survive quit and are re-adopted, but there's no persistent UI to manage them. +- **Tray calls `requestQuit(mode)`.** One function, one codepath — no setter chains or flag mutation. +- **Manifest handling is single-sourced.** Both parent and child use `host-service-manifest.ts`. Files are written with 0o600 permissions. diff --git a/apps/desktop/src/lib/electron-app/factories/app/setup.ts b/apps/desktop/src/lib/electron-app/factories/app/setup.ts index 0b2a660a2df..05a1138209e 100644 --- a/apps/desktop/src/lib/electron-app/factories/app/setup.ts +++ b/apps/desktop/src/lib/electron-app/factories/app/setup.ts @@ -33,8 +33,10 @@ export async function makeAppSetup( if (!windows.length) { window = await createWindow(); } else { + // Show hidden windows (macOS hide-to-tray) or restore minimized ones for (window of windows.reverse()) { - window.restore(); + window.show(); + window.focus(); } } }); @@ -50,8 +52,10 @@ export async function makeAppSetup( }); }); + // macOS: keep the app alive (standard behavior) — tray/dock provide re-entry. + // Windows/Linux: quit the app UI. Host-services survive via releaseAll() + // and will be re-adopted on next launch. app.on("window-all-closed", () => !PLATFORM.IS_MAC && app.quit()); - app.on("before-quit", () => {}); return window; } diff --git a/apps/desktop/src/lib/trpc/routers/host-service-manager/index.ts b/apps/desktop/src/lib/trpc/routers/host-service-manager/index.ts index 5ee7b4f1ffe..005a5100837 100644 --- a/apps/desktop/src/lib/trpc/routers/host-service-manager/index.ts +++ b/apps/desktop/src/lib/trpc/routers/host-service-manager/index.ts @@ -1,5 +1,9 @@ +import { observable } from "@trpc/server/observable"; import { env } from "main/env.main"; -import { getHostServiceManager } from "main/lib/host-service-manager"; +import { + getHostServiceManager, + type HostServiceStatusEvent, +} from "main/lib/host-service-manager"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { loadToken } from "../auth/utils/auth-functions"; @@ -7,7 +11,12 @@ import { loadToken } from "../auth/utils/auth-functions"; export const createHostServiceManagerRouter = () => { return router({ getLocalPort: publicProcedure - .input(z.object({ organizationId: z.string() })) + .input( + z.object({ + organizationId: z.string(), + organizationName: z.string().optional(), + }), + ) .query(async ({ input }) => { const manager = getHostServiceManager(); const { token } = await loadToken(); @@ -15,6 +24,12 @@ export const createHostServiceManagerRouter = () => { manager.setAuthToken(token); } manager.setCloudApiUrl(env.NEXT_PUBLIC_API_URL); + if (input.organizationName) { + manager.setOrganizationName( + input.organizationId, + input.organizationName, + ); + } const port = await manager.start(input.organizationId); const secret = manager.getSecret(input.organizationId); return { port, secret }; @@ -27,5 +42,42 @@ export const createHostServiceManagerRouter = () => { const status = manager.getStatus(input.organizationId); return { status }; }), + + getServiceInfo: publicProcedure + .input(z.object({ organizationId: z.string() })) + .query(({ input }) => { + const manager = getHostServiceManager(); + return manager.getServiceInfo(input.organizationId); + }), + + restart: publicProcedure + .input(z.object({ organizationId: z.string() })) + .mutation(async ({ input }) => { + const manager = getHostServiceManager(); + const { token } = await loadToken(); + if (token) { + manager.setAuthToken(token); + } + manager.setCloudApiUrl(env.NEXT_PUBLIC_API_URL); + const port = await manager.restart(input.organizationId); + const secret = manager.getSecret(input.organizationId); + return { port, secret }; + }), + + onStatusChange: publicProcedure.subscription(() => { + return observable((emit) => { + const manager = getHostServiceManager(); + + const handler = (event: HostServiceStatusEvent) => { + emit.next(event); + }; + + manager.on("status-changed", handler); + + return () => { + manager.off("status-changed", handler); + }; + }); + }), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index 9a49802c7a7..5876d80fd22 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -17,7 +17,7 @@ import { } from "@superset/shared/agent-command"; import { TRPCError } from "@trpc/server"; import { app } from "electron"; -import { quitWithoutConfirmation } from "main/index"; +import { exitImmediately } from "main/index"; import { hasCustomRingtone } from "main/lib/custom-ringtones"; import { localDb } from "main/lib/local-db"; import { @@ -696,7 +696,7 @@ export const createSettingsRouter = () => { restartApp: publicProcedure.mutation(() => { app.relaunch(); - quitWithoutConfirmation(); + exitImmediately(); return { success: true }; }), diff --git a/apps/desktop/src/main/host-service/index.ts b/apps/desktop/src/main/host-service/index.ts index 484f661fb54..88f85fe793e 100644 --- a/apps/desktop/src/main/host-service/index.ts +++ b/apps/desktop/src/main/host-service/index.ts @@ -5,6 +5,9 @@ * * Starts the host-service HTTP server on a random local port. * The parent Electron process reads the port from the IPC channel. + * + * When KEEP_ALIVE_AFTER_PARENT=1, the service stays running even if the + * parent Electron process exits (out-of-app durability mode). */ import { serve } from "@hono/node-server"; @@ -14,6 +17,11 @@ import { LocalGitCredentialProvider, PskHostAuthProvider, } from "@superset/host-service"; +import { + HOST_SERVICE_PROTOCOL_VERSION, + removeManifest, + writeManifest, +} from "main/lib/host-service-manifest"; const authToken = process.env.AUTH_TOKEN; const cloudApiUrl = process.env.CLOUD_API_URL; @@ -21,7 +29,11 @@ const dbPath = process.env.HOST_DB_PATH; const deviceClientId = process.env.DEVICE_CLIENT_ID; const deviceName = process.env.DEVICE_NAME; const hostServiceSecret = process.env.HOST_SERVICE_SECRET; +const serviceVersion = process.env.HOST_SERVICE_VERSION ?? null; +const protocolVersion = HOST_SERVICE_PROTOCOL_VERSION; +const organizationId = process.env.ORGANIZATION_ID ?? ""; const desktopVitePort = process.env.DESKTOP_VITE_PORT ?? "5173"; +const keepAliveAfterParent = process.env.KEEP_ALIVE_AFTER_PARENT === "1"; const auth = authToken && cloudApiUrl ? new JwtApiAuthProvider(authToken) : undefined; @@ -37,21 +49,49 @@ const { app, injectWebSocket } = createApp({ dbPath, deviceClientId, deviceName, + serviceVersion, + protocolVersion, allowedOrigins: [ `http://localhost:${desktopVitePort}`, `http://127.0.0.1:${desktopVitePort}`, ], }); +const startedAt = Date.now(); + const server = serve( { fetch: app.fetch, port: 0, hostname: "127.0.0.1" }, (info: { port: number }) => { - process.send?.({ type: "ready", port: info.port }); + if (organizationId) { + try { + writeManifest({ + pid: process.pid, + endpoint: `http://127.0.0.1:${info.port}`, + authToken: hostServiceSecret ?? "", + serviceVersion: serviceVersion ?? "", + protocolVersion: protocolVersion ?? 0, + startedAt, + organizationId, + }); + } catch (error) { + console.error("[host-service] Failed to write manifest:", error); + } + } + process.send?.({ + type: "ready", + port: info.port, + serviceVersion, + protocolVersion, + startedAt, + }); }, ); injectWebSocket(server); const shutdown = () => { + if (organizationId) { + removeManifest(organizationId); + } server.close(); process.exit(0); }; @@ -59,15 +99,18 @@ const shutdown = () => { process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); -// Orphan cleanup: exit if parent Electron process dies -const parentPid = process.ppid; -const parentCheck = setInterval(() => { - try { - process.kill(parentPid, 0); - } catch { - clearInterval(parentCheck); - console.log("[host-service] Parent process exited, shutting down"); - shutdown(); - } -}, 2000); -parentCheck.unref(); +// Orphan cleanup: exit if parent Electron process dies. +// Disabled in keep-alive mode so the service survives app quit. +if (!keepAliveAfterParent) { + const parentPid = process.ppid; + const parentCheck = setInterval(() => { + try { + process.kill(parentPid, 0); + } catch { + clearInterval(parentCheck); + console.log("[host-service] Parent process exited, shutting down"); + shutdown(); + } + }, 2000); + parentCheck.unref(); +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 8e41a9236c7..331341a2395 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -94,7 +94,7 @@ function findDeepLinkInArgv(argv: string[]): string | undefined { return argv.find((arg) => arg.startsWith(`${PROTOCOL_SCHEME}://`)); } -function focusMainWindow(): void { +export function focusMainWindow(): void { const windows = BrowserWindow.getAllWindows(); if (windows.length > 0) { const mainWindow = windows[0]; @@ -103,6 +103,9 @@ function focusMainWindow(): void { } mainWindow.show(); mainWindow.focus(); + } else { + // Triggers window creation via makeAppSetup's activate handler + app.emit("activate"); } } @@ -147,8 +150,29 @@ app.on("open-url", async (event, url) => { } }); +export type QuitMode = "release" | "stop"; +let pendingQuitMode: QuitMode | null = null; let isQuitting = false; -let skipConfirmation = false; + +/** Request the app to quit. + * - "release": keep services running (re-adoptable on next launch) + * - "stop": terminate all services before exit */ +export function requestQuit(mode: QuitMode): void { + pendingQuitMode = mode; + app.quit(); +} + +/** Set quit mode without triggering quit. + * Use when another API (e.g. autoUpdater.quitAndInstall) triggers quit internally. */ +export function prepareQuit(mode: QuitMode): void { + pendingQuitMode = mode; +} + +/** Exit the process immediately, bypassing before-quit. + * Services are left running for adoption on next launch. */ +export function exitImmediately(): void { + app.exit(0); +} function getConfirmOnQuitSetting(): boolean { try { @@ -159,23 +183,30 @@ function getConfirmOnQuitSetting(): boolean { } } -export function setSkipQuitConfirmation(): void { - skipConfirmation = true; -} - -export function quitWithoutConfirmation(): void { - skipConfirmation = true; - app.exit(0); -} - app.on("before-quit", async (event) => { if (isQuitting) return; - const isDev = process.env.NODE_ENV === "development"; - const shouldConfirm = - !skipConfirmation && !isDev && getConfirmOnQuitSetting(); + // Consume the quit mode so it doesn't persist across aborted quits + const quitMode = pendingQuitMode; + pendingQuitMode = null; + + const manager = getHostServiceManager(); + + // macOS: close windows & keep tray alive when services should stay running + if ( + PLATFORM.IS_MAC && + (quitMode === null || quitMode === "release") && + manager.hasActiveInstances() + ) { + event.preventDefault(); + for (const win of BrowserWindow.getAllWindows()) { + win.destroy(); + } + return; + } - if (shouldConfirm) { + const isDev = process.env.NODE_ENV === "development"; + if (quitMode === null && !isDev && getConfirmOnQuitSetting()) { event.preventDefault(); try { @@ -188,16 +219,20 @@ app.on("before-quit", async (event) => { message: "Are you sure you want to quit?", }); - if (response === 1) return; + if (response === 1) { + return; + } } catch (error) { console.error("[main] Quit confirmation dialog failed:", error); } } - // Quit confirmed or no confirmation needed - exit immediately - // Let OS clean up child processes, tray, etc. isQuitting = true; - getHostServiceManager().stopAll(); + if (quitMode === "stop") { + manager.stopAll(); + } else { + manager.releaseAll(); + } disposeTray(); app.exit(0); }); @@ -317,7 +352,7 @@ if (!gotTheLock) { try { return await net.fetch(pathToFileURL(fontPath).toString()); } catch { - // Font not in this directory, try next + // Not in this directory } } return new Response("Not found", { status: 404 }); @@ -345,11 +380,14 @@ if (!gotTheLock) { console.error("[main] Failed to set up agent hooks:", error); } + // Discover and adopt host-services that survived a previous quit + // before the tray initializes, so it shows accurate status immediately. + await getHostServiceManager().discoverAndAdoptAll(); + await makeAppSetup(() => MainWindow()); setupAutoUpdater(); initTray(); - // Process any deep links from cold start const coldStartUrl = findDeepLinkInArgv(process.argv); if (coldStartUrl) { await processDeepLink(coldStartUrl); diff --git a/apps/desktop/src/main/lib/auto-updater.ts b/apps/desktop/src/main/lib/auto-updater.ts index 1d95ca60805..b985196bad9 100644 --- a/apps/desktop/src/main/lib/auto-updater.ts +++ b/apps/desktop/src/main/lib/auto-updater.ts @@ -2,7 +2,7 @@ import { EventEmitter } from "node:events"; import { app, dialog } from "electron"; import { autoUpdater } from "electron-updater"; import { env } from "main/env.main"; -import { setSkipQuitConfirmation } from "main/index"; +import { prepareQuit } from "main/index"; import { prerelease } from "semver"; import { AUTO_UPDATE_STATUS, type AutoUpdateStatus } from "shared/auto-update"; import { PLATFORM } from "shared/constants"; @@ -91,8 +91,8 @@ export function installUpdate(): void { emitStatus(AUTO_UPDATE_STATUS.IDLE); return; } - // Skip confirmation dialog - quitAndInstall internally calls app.quit() - setSkipQuitConfirmation(); + // quitAndInstall internally calls app.quit() — set mode beforehand + prepareQuit("release"); autoUpdater.quitAndInstall(false, true); } @@ -267,6 +267,15 @@ export function setupAutoUpdater(): void { `[auto-updater] Update downloaded: ${app.getVersion()} → ${info.version}. Ready to install.`, ); emitStatus(AUTO_UPDATE_STATUS.READY, info.version); + + // After an app update is ready, check if running host-service instances + // will need a restart once the new version is installed. + try { + const { getHostServiceManager } = require("../host-service-manager"); + getHostServiceManager().checkAllCompatibility(); + } catch { + // Host service manager may not be initialized yet + } }); const interval = setInterval(checkForUpdates, UPDATE_CHECK_INTERVAL_MS); diff --git a/apps/desktop/src/main/lib/host-service-manager.test.ts b/apps/desktop/src/main/lib/host-service-manager.test.ts index aa0342cbeba..5618eec4be5 100644 --- a/apps/desktop/src/main/lib/host-service-manager.test.ts +++ b/apps/desktop/src/main/lib/host-service-manager.test.ts @@ -26,6 +26,8 @@ class MockChildProcess extends EventEmitter { stdout = new EventEmitter(); stderr = new EventEmitter(); kill = mock(() => true); + disconnect = mock(() => {}); + unref = mock(() => {}); } const getProcessEnvWithShellPathMock = mock( @@ -37,6 +39,8 @@ const spawnMock = mock((..._args: unknown[]) => { return lastChild as unknown as ChildProcess; }); let HostServiceManager: typeof import("./host-service-manager").HostServiceManager; +let checkCompatibility: typeof import("./host-service-manager").checkCompatibility; +let HOST_SERVICE_PROTOCOL_VERSION: typeof import("./host-service-manifest").HOST_SERVICE_PROTOCOL_VERSION; describe("HostServiceManager", () => { beforeAll(async () => { @@ -58,10 +62,16 @@ describe("HostServiceManager", () => { app: { isPackaged: false, getAppPath: () => "/tmp/app", + getVersion: () => "1.0.0-test", }, })); - ({ HostServiceManager } = await import("./host-service-manager")); + ({ HostServiceManager, checkCompatibility } = await import( + "./host-service-manager" + )); + ({ HOST_SERVICE_PROTOCOL_VERSION } = await import( + "./host-service-manifest" + )); }); afterAll(() => { @@ -90,6 +100,9 @@ describe("HostServiceManager", () => { const secondStart = manager.start("org-1"); expect(manager.getStatus("org-1")).toBe("starting"); + + // Flush microtasks so tryAdopt completes (no manifest → falls through to spawn) + await new Promise((resolve) => setTimeout(resolve, 0)); expect(getProcessEnvWithShellPathMock.mock.calls).toHaveLength(1); pendingEnv.resolve({ PATH: "/usr/bin:/bin" }); @@ -107,4 +120,80 @@ describe("HostServiceManager", () => { expect(await secondStart).toBe(4242); expect(manager.getPort("org-1")).toBe(4242); }); + + it("stopAll() kills all instances", async () => { + const manager = new HostServiceManager(); + + const p1 = manager.start("org-1"); + await new Promise((resolve) => setTimeout(resolve, 0)); + const child1 = lastChild; + child1?.emit("message", { type: "ready", port: 4001 }); + await p1; + + const p2 = manager.start("org-2"); + await new Promise((resolve) => setTimeout(resolve, 0)); + const child2 = lastChild; + child2?.emit("message", { type: "ready", port: 4002 }); + await p2; + + manager.stopAll(); + + expect(child1?.kill).toHaveBeenCalledWith("SIGTERM"); + expect(child2?.kill).toHaveBeenCalledWith("SIGTERM"); + expect(manager.getStatus("org-1")).toBe("stopped"); + expect(manager.getStatus("org-2")).toBe("stopped"); + }); + + it("releaseAll() detaches without killing", async () => { + const manager = new HostServiceManager(); + + const p1 = manager.start("org-1"); + await new Promise((resolve) => setTimeout(resolve, 0)); + lastChild?.emit("message", { type: "ready", port: 4001 }); + await p1; + + const child = lastChild; + + manager.releaseAll(); + + expect(child?.kill).not.toHaveBeenCalled(); + expect(manager.getStatus("org-1")).toBe("stopped"); + }); + + describe("checkCompatibility", () => { + it("returns null when protocol version is unknown", () => { + const result = checkCompatibility({ + protocolVersion: null, + serviceVersion: null, + }); + expect(result).toBeNull(); + }); + + it("detects protocol mismatch", () => { + const result = checkCompatibility({ + protocolVersion: 999, + serviceVersion: "1.0.0", + }); + expect(result).toEqual({ + compatible: false, + reason: expect.stringContaining("Protocol mismatch"), + }); + }); + + it("detects compatible with update available", () => { + const result = checkCompatibility({ + protocolVersion: HOST_SERVICE_PROTOCOL_VERSION, + serviceVersion: "0.0.1-old", + }); + expect(result).toEqual({ compatible: true, updateAvailable: true }); + }); + + it("detects compatible with same version", () => { + const result = checkCompatibility({ + protocolVersion: HOST_SERVICE_PROTOCOL_VERSION, + serviceVersion: "1.0.0-test", + }); + expect(result).toEqual({ compatible: true, updateAvailable: false }); + }); + }); }); diff --git a/apps/desktop/src/main/lib/host-service-manager.ts b/apps/desktop/src/main/lib/host-service-manager.ts index b70099981fc..75062557811 100644 --- a/apps/desktop/src/main/lib/host-service-manager.ts +++ b/apps/desktop/src/main/lib/host-service-manager.ts @@ -1,15 +1,55 @@ import type { ChildProcess } from "node:child_process"; import * as childProcess from "node:child_process"; import { randomBytes } from "node:crypto"; +import { EventEmitter } from "node:events"; import path from "node:path"; import { app } from "electron"; import { getProcessEnvWithShellPath } from "../../lib/trpc/routers/workspaces/utils/shell-env"; -import { SUPERSET_HOME_DIR } from "./app-environment"; import { getDeviceName, getHashedDeviceId } from "./device-info"; +import { + HOST_SERVICE_PROTOCOL_VERSION, + type HostServiceManifest, + isProcessAlive, + listManifests, + manifestDir, + readManifest, + removeManifest, +} from "./host-service-manifest"; + +export type HostServiceStatus = + | "starting" + | "running" + | "degraded" + | "restarting" + | "stopped"; + +export type CompatibilityResult = + | { compatible: true; updateAvailable: boolean } + | { compatible: false; reason: string }; + +export interface HostServiceInfo { + organizationId: string; + organizationName: string | null; + status: HostServiceStatus; + port: number | null; + serviceVersion: string | null; + protocolVersion: number | null; + startedAt: number | null; + uptime: number | null; + restartCount: number; + pendingRestart: boolean; + compatibility: CompatibilityResult | null; + adopted: boolean; +} -type HostServiceStatus = "starting" | "running" | "crashed"; +export interface HostServiceStatusEvent { + organizationId: string; + status: HostServiceStatus; + previousStatus: HostServiceStatus | null; +} interface HostServiceProcess { + /** null when the instance was adopted from a manifest (no child handle). */ process: ChildProcess | null; port: number | null; secret: string | null; @@ -17,6 +57,14 @@ interface HostServiceProcess { restartCount: number; lastCrash?: number; organizationId: string; + startedAt: number | null; + serviceVersion: string | null; + protocolVersion: number | null; + pendingRestart: boolean; + /** True when this instance was adopted from a running manifest rather than spawned. */ + adopted: boolean; + /** PID of the adopted process (for liveness checks). */ + adoptedPid: number | null; } interface PendingStart { @@ -30,6 +78,9 @@ interface PendingStart { const MAX_RESTART_DELAY = 30_000; const BASE_RESTART_DELAY = 1_000; +/** Interval for checking liveness of adopted (non-child) processes. */ +const ADOPTED_LIVENESS_INTERVAL = 5_000; + function createPortDeferred(): { promise: Promise; resolve: (port: number) => void; @@ -45,9 +96,59 @@ function createPortDeferred(): { return { promise, resolve, reject }; } -export class HostServiceManager { +/** Check whether a host-service instance is compatible with this app version. */ +export function checkCompatibility(instance: { + protocolVersion: number | null; + serviceVersion: string | null; +}): CompatibilityResult | null { + if (instance.protocolVersion === null) return null; + + if (instance.protocolVersion !== HOST_SERVICE_PROTOCOL_VERSION) { + return { + compatible: false, + reason: `Protocol mismatch: service=${instance.protocolVersion}, app=${HOST_SERVICE_PROTOCOL_VERSION}`, + }; + } + + const currentVersion = app.getVersion(); + const updateAvailable = + instance.serviceVersion !== null && + instance.serviceVersion !== currentVersion; + + return { compatible: true, updateAvailable }; +} + +async function buildHostServiceEnv( + organizationId: string, + secret: string, +): Promise> { + const orgDir = manifestDir(organizationId); + return getProcessEnvWithShellPath({ + ...(process.env as Record), + ELECTRON_RUN_AS_NODE: "1", + ORGANIZATION_ID: organizationId, + DEVICE_CLIENT_ID: getHashedDeviceId(), + DEVICE_NAME: getDeviceName(), + HOST_SERVICE_SECRET: secret, + HOST_SERVICE_VERSION: app.getVersion(), + HOST_MANIFEST_DIR: orgDir, + KEEP_ALIVE_AFTER_PARENT: "1", + HOST_DB_PATH: path.join(orgDir, "host.db"), + HOST_MIGRATIONS_PATH: app.isPackaged + ? path.join(process.resourcesPath, "resources/host-migrations") + : path.join(app.getAppPath(), "../../packages/host-service/drizzle"), + }); +} + +export class HostServiceManager extends EventEmitter { private instances = new Map(); private pendingStarts = new Map(); + private scheduledRestarts = new Map>(); + private adoptedLivenessTimers = new Map< + string, + ReturnType + >(); + private organizationNames = new Map(); private scriptPath = path.join(__dirname, "host-service.js"); private authToken: string | null = null; private cloudApiUrl: string | null = null; @@ -60,27 +161,66 @@ export class HostServiceManager { this.cloudApiUrl = url; } + setOrganizationName(organizationId: string, name: string): void { + this.organizationNames.set(organizationId, name); + } + + getOrganizationName(organizationId: string): string | null { + return this.organizationNames.get(organizationId) ?? null; + } + async start(organizationId: string): Promise { const existing = this.instances.get(organizationId); if (existing?.status === "running" && existing.port !== null) { return existing.port; } - const pendingStart = this.pendingStarts.get(organizationId); - if (pendingStart) { - return pendingStart.promise; + const existingPending = this.pendingStarts.get(organizationId); + if (existingPending) { + return existingPending.promise; + } + + this.cancelScheduledRestart(organizationId); + + // Register a pending start BEFORE the async tryAdopt so that concurrent + // callers see it and dedupe instead of racing through adoption + spawn. + const deferred = createPortDeferred(); + this.pendingStarts.set(organizationId, deferred); + + const adopted = await this.tryAdopt(organizationId); + if (adopted !== null) { + if (this.pendingStarts.get(organizationId) === deferred) { + this.pendingStarts.delete(organizationId); + } + deferred.resolve(adopted); + return adopted; } + // Adoption failed — spawn() will reuse the deferred already in pendingStarts. return this.spawn(organizationId); } stop(organizationId: string): void { const instance = this.instances.get(organizationId); + this.cancelScheduledRestart(organizationId); + this.cancelPendingStart(organizationId, new Error("Host service stopped")); + this.stopAdoptedLivenessCheck(organizationId); + if (!instance) return; - instance.status = "crashed"; // prevent restart - this.cancelPendingStart(organizationId, new Error("Host service stopped")); - instance.process?.kill("SIGTERM"); + const previousStatus = instance.status; + instance.status = "stopped"; + if (instance.adopted && instance.adoptedPid) { + try { + process.kill(instance.adoptedPid, "SIGTERM"); + } catch { + // Already dead + } + } else { + instance.process?.kill("SIGTERM"); + } this.instances.delete(organizationId); + removeManifest(organizationId); + this.emitStatus(organizationId, "stopped", previousStatus); } stopAll(): void { @@ -89,6 +229,62 @@ export class HostServiceManager { } } + /** Release all instances without killing the underlying processes. + * The services keep running and can be re-adopted on next app start. */ + releaseAll(): void { + for (const [id] of this.instances) { + this.release(id); + } + } + + /** Scan for on-disk manifests and adopt any running services. + * Call during startup so the tray shows accurate state immediately. */ + async discoverAndAdoptAll(): Promise { + const manifests = listManifests(); + for (const manifest of manifests) { + if (this.instances.has(manifest.organizationId)) continue; + try { + await this.tryAdopt(manifest.organizationId); + } catch (error) { + console.error( + `[host-service:${manifest.organizationId}] Failed to adopt, removing bad manifest:`, + error, + ); + removeManifest(manifest.organizationId); + } + } + } + + async restart(organizationId: string): Promise { + const instance = this.instances.get(organizationId); + if (instance) { + const previousStatus = instance.status; + instance.status = "restarting"; + this.emitStatus(organizationId, "restarting", previousStatus); + + this.cancelScheduledRestart(organizationId); + this.cancelPendingStart( + organizationId, + new Error("Host service restarting"), + ); + this.stopAdoptedLivenessCheck(organizationId); + + if (instance.adopted && instance.adoptedPid) { + try { + process.kill(instance.adoptedPid, "SIGTERM"); + } catch { + // Already dead + } + } else { + instance.process?.kill("SIGTERM"); + } + this.instances.delete(organizationId); + removeManifest(organizationId); + } + + return this.spawn(organizationId); + } + getPort(organizationId: string): number | null { return this.instances.get(organizationId)?.port ?? null; } @@ -97,29 +293,260 @@ export class HostServiceManager { return this.instances.get(organizationId)?.secret ?? null; } - getStatus(organizationId: string): HostServiceStatus | null { + getStatus(organizationId: string): HostServiceStatus { if (this.pendingStarts.has(organizationId)) { return "starting"; } - return this.instances.get(organizationId)?.status ?? null; + return this.instances.get(organizationId)?.status ?? "stopped"; + } + + getServiceInfo(organizationId: string): HostServiceInfo { + const organizationName = this.getOrganizationName(organizationId); + const instance = this.instances.get(organizationId); + if (!instance) { + return { + organizationId, + organizationName, + status: this.pendingStarts.has(organizationId) ? "starting" : "stopped", + port: null, + serviceVersion: null, + protocolVersion: null, + startedAt: null, + uptime: null, + restartCount: 0, + pendingRestart: false, + compatibility: null, + adopted: false, + }; + } + + return { + organizationId, + organizationName, + status: instance.status, + port: instance.port, + serviceVersion: instance.serviceVersion, + protocolVersion: instance.protocolVersion, + startedAt: instance.startedAt, + uptime: instance.startedAt + ? Math.floor((Date.now() - instance.startedAt) / 1000) + : null, + restartCount: instance.restartCount, + pendingRestart: instance.pendingRestart, + compatibility: checkCompatibility(instance), + adopted: instance.adopted, + }; } + hasActiveInstances(): boolean { + for (const instance of this.instances.values()) { + if (instance.status === "running" || instance.status === "starting") { + return true; + } + } + return this.pendingStarts.size > 0; + } + + getActiveOrganizationIds(): string[] { + const ids: string[] = []; + for (const [id, instance] of this.instances) { + if (instance.status !== "stopped") { + ids.push(id); + } + } + return ids; + } + + /** Mark a host-service instance for restart when it becomes idle. */ + markPendingRestart(organizationId: string): void { + const instance = this.instances.get(organizationId); + if (!instance) return; + instance.pendingRestart = true; + this.emitStatus(organizationId, instance.status, instance.status); + } + + /** Check all instances for compatibility and mark incompatible ones for restart. */ + checkAllCompatibility(): void { + for (const [orgId, instance] of this.instances) { + if (instance.status !== "running") continue; + const result = checkCompatibility(instance); + if (result && !result.compatible) { + console.log(`[host-service:${orgId}] Incompatible: ${result.reason}`); + instance.pendingRestart = true; + this.emitStatus(orgId, instance.status, instance.status); + } + } + } + + // ── Discovery / Adoption ────────────────────────────────────────── + + /** Try to adopt an already-running host-service from its on-disk manifest. */ + private async tryAdopt(organizationId: string): Promise { + const manifest = readManifest(organizationId); + if (!manifest) return null; + + if (!isProcessAlive(manifest.pid)) { + console.log( + `[host-service:${organizationId}] Manifest process ${manifest.pid} is dead, removing stale manifest`, + ); + removeManifest(organizationId); + return null; + } + + const healthy = await this.healthCheck(manifest); + if (!healthy) { + console.log( + `[host-service:${organizationId}] Manifest endpoint ${manifest.endpoint} not reachable, removing stale manifest`, + ); + removeManifest(organizationId); + return null; + } + + const compat = checkCompatibility({ + protocolVersion: manifest.protocolVersion, + serviceVersion: manifest.serviceVersion, + }); + + if (compat && !compat.compatible) { + console.log( + `[host-service:${organizationId}] Manifest service incompatible: ${compat.reason}. Will kill and respawn.`, + ); + try { + process.kill(manifest.pid, "SIGTERM"); + } catch { + // Already dead + } + removeManifest(organizationId); + return null; + } + + const url = new URL(manifest.endpoint); + const port = Number(url.port); + const pendingRestart = + compat !== null && "updateAvailable" in compat && compat.updateAvailable; + + const instance: HostServiceProcess = { + process: null, + port, + secret: manifest.authToken, + status: "running", + restartCount: 0, + organizationId, + startedAt: manifest.startedAt, + serviceVersion: manifest.serviceVersion, + protocolVersion: manifest.protocolVersion, + pendingRestart, + adopted: true, + adoptedPid: manifest.pid, + }; + this.instances.set(organizationId, instance); + this.startAdoptedLivenessCheck(organizationId, manifest.pid); + + console.log( + `[host-service:${organizationId}] Adopted existing service pid=${manifest.pid} port=${port} v${manifest.serviceVersion}`, + ); + this.emitStatus(organizationId, "running", null); + return port; + } + + private async healthCheck(manifest: HostServiceManifest): Promise { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3_000); + const res = await fetch(`${manifest.endpoint}/trpc/health.check`, { + signal: controller.signal, + headers: { + Authorization: `Bearer ${manifest.authToken}`, + }, + }); + clearTimeout(timeout); + return res.ok; + } catch { + return false; + } + } + + private startAdoptedLivenessCheck(organizationId: string, pid: number): void { + this.stopAdoptedLivenessCheck(organizationId); + + const timer = setInterval(() => { + if (!isProcessAlive(pid)) { + console.log( + `[host-service:${organizationId}] Adopted process ${pid} died`, + ); + this.stopAdoptedLivenessCheck(organizationId); + + const current = this.instances.get(organizationId); + if (current?.adopted && current.status !== "stopped") { + current.status = "degraded"; + current.lastCrash = Date.now(); + this.emitStatus(organizationId, "degraded", "running"); + this.scheduleRestart(organizationId); + } + } + }, ADOPTED_LIVENESS_INTERVAL); + timer.unref(); + this.adoptedLivenessTimers.set(organizationId, timer); + } + + private stopAdoptedLivenessCheck(organizationId: string): void { + const timer = this.adoptedLivenessTimers.get(organizationId); + if (timer) { + clearInterval(timer); + this.adoptedLivenessTimers.delete(organizationId); + } + } + + /** Release an instance without killing it. Allows the process to keep running. */ + private release(organizationId: string): void { + this.cancelScheduledRestart(organizationId); + this.cancelPendingStart(organizationId, new Error("Host service released")); + this.stopAdoptedLivenessCheck(organizationId); + + const instance = this.instances.get(organizationId); + if (!instance) return; + + if (instance.process) { + instance.process.disconnect?.(); + instance.process.unref?.(); + instance.process = null; + } + this.instances.delete(organizationId); + // Leave the manifest on disk — next app start will adopt it. + } + + // ── Spawn ───────────────────────────────────────────────────────── + private async spawn(organizationId: string): Promise { - const pendingStart = createPortDeferred(); + // Reuse a pending start registered by start(), or create a fresh one + // (e.g. when called directly from restart/scheduleRestart). + const pendingStart = + this.pendingStarts.get(organizationId) ?? createPortDeferred(); const secret = randomBytes(32).toString("hex"); + + const previousInstance = this.instances.get(organizationId); + const restartCount = previousInstance?.restartCount ?? 0; + const instance: HostServiceProcess = { process: null, port: null, secret, status: "starting", - restartCount: 0, + restartCount, organizationId, + startedAt: null, + serviceVersion: null, + protocolVersion: null, + pendingRestart: false, + adopted: false, + adoptedPid: null, }; this.instances.set(organizationId, instance); this.pendingStarts.set(organizationId, pendingStart); + this.emitStatus(organizationId, "starting", null); try { - const env = await this.buildHostServiceEnv(organizationId, secret); + const env = await buildHostServiceEnv(organizationId, secret); if (this.authToken) { env.AUTH_TOKEN = this.authToken; } @@ -158,29 +585,6 @@ export class HostServiceManager { } } - private async buildHostServiceEnv( - organizationId: string, - secret: string, - ): Promise> { - return getProcessEnvWithShellPath({ - ...(process.env as Record), - ELECTRON_RUN_AS_NODE: "1", - ORGANIZATION_ID: organizationId, - DEVICE_CLIENT_ID: getHashedDeviceId(), - DEVICE_NAME: getDeviceName(), - HOST_SERVICE_SECRET: secret, - HOST_DB_PATH: path.join( - SUPERSET_HOME_DIR, - "host", - organizationId, - "host.db", - ), - HOST_MIGRATIONS_PATH: app.isPackaged - ? path.join(process.resourcesPath, "resources/host-migrations") - : path.join(app.getAppPath(), "../../packages/host-service/drizzle"), - }); - } - private attachProcessHandlers( instance: HostServiceProcess, child: ChildProcess, @@ -203,7 +607,7 @@ export class HostServiceManager { if ( !current || current.process !== child || - current.status === "crashed" + current.status === "stopped" ) { return; } @@ -214,8 +618,17 @@ export class HostServiceManager { new Error("Host service exited before reporting port"), ); } - current.status = "crashed"; + + const previousStatus = current.status; + // If we were restarting, a new spawn is already in flight — don't + // schedule another restart or overwrite the status. + if (previousStatus === "restarting") { + return; + } + + current.status = "degraded"; current.lastCrash = Date.now(); + this.emitStatus(organizationId, "degraded", previousStatus); this.scheduleRestart(organizationId); }); } @@ -226,10 +639,14 @@ export class HostServiceManager { error: Error, ): void { this.clearPendingStart(instance.organizationId, pendingStart); - instance.status = "crashed"; + const previousStatus = instance.status; + instance.status = "degraded"; pendingStart.reject(error); - instance.process?.kill("SIGTERM"); + const child = instance.process; + instance.process = null; + child?.kill("SIGTERM"); instance.lastCrash = Date.now(); + this.emitStatus(instance.organizationId, "degraded", previousStatus); this.scheduleRestart(instance.organizationId); } @@ -252,9 +669,35 @@ export class HostServiceManager { this.clearPendingStart(instance.organizationId, pendingStart); instance.port = message.port; instance.status = "running"; + instance.startedAt = Date.now(); + instance.restartCount = 0; + + if ( + "serviceVersion" in message && + typeof message.serviceVersion === "string" + ) { + instance.serviceVersion = message.serviceVersion; + } + if ( + "protocolVersion" in message && + typeof message.protocolVersion === "number" + ) { + instance.protocolVersion = message.protocolVersion; + } + console.log( - `[host-service:${instance.organizationId}] listening on port ${message.port}`, + `[host-service:${instance.organizationId}] listening on port ${message.port} (v${instance.serviceVersion}, protocol=${instance.protocolVersion})`, ); + + const compat = checkCompatibility(instance); + if (compat && !compat.compatible) { + console.warn( + `[host-service:${instance.organizationId}] ${compat.reason} — marking for restart`, + ); + instance.pendingRestart = true; + } + + this.emitStatus(instance.organizationId, "running", "starting"); pendingStart.resolve(message.port); }; @@ -296,10 +739,20 @@ export class HostServiceManager { } } + private cancelScheduledRestart(organizationId: string): void { + const timer = this.scheduledRestarts.get(organizationId); + if (timer) { + clearTimeout(timer); + this.scheduledRestarts.delete(organizationId); + } + } + private scheduleRestart(organizationId: string): void { const instance = this.instances.get(organizationId); if (!instance) return; + this.cancelScheduledRestart(organizationId); + const delay = Math.min( BASE_RESTART_DELAY * 2 ** instance.restartCount, MAX_RESTART_DELAY, @@ -310,10 +763,11 @@ export class HostServiceManager { `[host-service:${organizationId}] restarting in ${delay}ms (attempt ${instance.restartCount})`, ); - setTimeout(() => { + const timer = setTimeout(() => { + this.scheduledRestarts.delete(organizationId); const current = this.instances.get(organizationId); - if (current?.status === "crashed") { - this.instances.delete(organizationId); + if (current?.status === "degraded") { + // Don't delete the instance — spawn() reads restartCount from it this.spawn(organizationId).catch((err) => { console.error( `[host-service:${organizationId}] restart failed:`, @@ -322,6 +776,20 @@ export class HostServiceManager { }); } }, delay); + this.scheduledRestarts.set(organizationId, timer); + } + + private emitStatus( + organizationId: string, + status: HostServiceStatus, + previousStatus: HostServiceStatus | null, + ): void { + const event: HostServiceStatusEvent = { + organizationId, + status, + previousStatus, + }; + this.emit("status-changed", event); } } diff --git a/apps/desktop/src/main/lib/host-service-manifest.ts b/apps/desktop/src/main/lib/host-service-manifest.ts new file mode 100644 index 00000000000..171a112a2f1 --- /dev/null +++ b/apps/desktop/src/main/lib/host-service-manifest.ts @@ -0,0 +1,117 @@ +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { SUPERSET_HOME_DIR } from "./app-environment"; + +/** Protocol version for the IPC contract between manager and host-service. + * Bump when the ready message shape, env contract, or health API + * changes in a backwards-incompatible way. */ +export const HOST_SERVICE_PROTOCOL_VERSION = 1; + +export interface HostServiceManifest { + pid: number; + endpoint: string; + authToken: string; + serviceVersion: string; + protocolVersion: number; + startedAt: number; + organizationId: string; +} + +export function manifestDir(organizationId: string): string { + return join(SUPERSET_HOME_DIR, "host", organizationId); +} + +function manifestPath(organizationId: string): string { + return join(manifestDir(organizationId), "manifest.json"); +} + +export function writeManifest(manifest: HostServiceManifest): void { + const dir = manifestDir(manifest.organizationId); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + writeFileSync( + manifestPath(manifest.organizationId), + JSON.stringify(manifest), + { + encoding: "utf-8", + mode: 0o600, + }, + ); +} + +export function readManifest( + organizationId: string, +): HostServiceManifest | null { + const filePath = manifestPath(organizationId); + if (!existsSync(filePath)) return null; + + try { + const raw = readFileSync(filePath, "utf-8"); + const data = JSON.parse(raw); + + if ( + typeof data.pid !== "number" || + typeof data.endpoint !== "string" || + typeof data.authToken !== "string" || + typeof data.serviceVersion !== "string" || + typeof data.protocolVersion !== "number" || + typeof data.startedAt !== "number" || + typeof data.organizationId !== "string" + ) { + return null; + } + + return data as HostServiceManifest; + } catch { + return null; + } +} + +/** Scan the host directory for all valid manifests on disk. */ +export function listManifests(): HostServiceManifest[] { + const hostDir = join(SUPERSET_HOME_DIR, "host"); + if (!existsSync(hostDir)) return []; + + const manifests: HostServiceManifest[] = []; + try { + for (const entry of readdirSync(hostDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const manifest = readManifest(entry.name); + if (manifest) { + manifests.push(manifest); + } + } + } catch { + // Best-effort scan + } + return manifests; +} + +export function removeManifest(organizationId: string): void { + const filePath = manifestPath(organizationId); + try { + if (existsSync(filePath)) { + unlinkSync(filePath); + } + } catch { + // Best-effort removal + } +} + +/** Check whether a process with the given PID is alive. */ +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} diff --git a/apps/desktop/src/main/lib/tray/index.ts b/apps/desktop/src/main/lib/tray/index.ts index 0ab352c1fc2..0cc335788ba 100644 --- a/apps/desktop/src/main/lib/tray/index.ts +++ b/apps/desktop/src/main/lib/tray/index.ts @@ -1,24 +1,19 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; -import { workspaces } from "@superset/local-db"; -import { eq } from "drizzle-orm"; import { app, - BrowserWindow, - dialog, Menu, type MenuItemConstructorOptions, nativeImage, Tray, } from "electron"; -import { localDb } from "main/lib/local-db"; -import { menuEmitter } from "main/lib/menu-events"; +import { focusMainWindow, requestQuit } from "main/index"; import { - restartDaemon as restartDaemonShared, - tryListExistingDaemonSessions, -} from "main/lib/terminal"; -import { getTerminalHostClient } from "main/lib/terminal-host/client"; -import type { ListSessionsResponse } from "main/lib/terminal-host/types"; + getHostServiceManager, + type HostServiceStatus, + type HostServiceStatusEvent, +} from "main/lib/host-service-manager"; +import { menuEmitter } from "main/lib/menu-events"; const POLL_INTERVAL_MS = 5000; @@ -85,215 +80,179 @@ function createTrayIcon(): Electron.NativeImage | null { } } -function showWindow(): void { - const windows = BrowserWindow.getAllWindows(); - - if (windows.length > 0) { - const mainWindow = windows[0]; - if (mainWindow.isMinimized()) { - mainWindow.restore(); - } - mainWindow.show(); - mainWindow.focus(); - } else { - // Triggers window creation via makeAppSetup's activate handler - app.emit("activate"); - } -} - function openSettings(): void { - showWindow(); + focusMainWindow(); menuEmitter.emit("open-settings"); } -function openTerminalSettings(): void { - showWindow(); - menuEmitter.emit("open-settings", "terminal"); -} - -function openSessionInSuperset(workspaceId: string): void { - showWindow(); - menuEmitter.emit("open-workspace", workspaceId); -} - -async function killSession(paneId: string): Promise { - try { - const client = getTerminalHostClient(); - const connected = await client.tryConnectAndAuthenticate(); - if (connected) { - await client.kill({ sessionId: paneId }); - console.log(`[Tray] Killed session: ${paneId}`); - } - } catch (error) { - console.error(`[Tray] Failed to kill session ${paneId}:`, error); +function formatStatusLabel(status: HostServiceStatus): string { + switch (status) { + case "running": + return "Running"; + case "starting": + return "Starting..."; + case "degraded": + return "Degraded"; + case "restarting": + return "Restarting..."; + case "stopped": + return "Stopped"; } - - await updateTrayMenu(); } -function getWorkspaceName(workspaceId: string): string { - try { - const workspace = localDb - .select({ name: workspaces.name }) - .from(workspaces) - .where(eq(workspaces.id, workspaceId)) - .get(); - return workspace?.name || workspaceId.slice(0, 8); - } catch { - return workspaceId.slice(0, 8); - } -} - -function formatSessionLabel( - session: ListSessionsResponse["sessions"][0], -): string { - const attached = session.attachedClients > 0 ? " (attached)" : ""; - const shellName = session.shell?.split("/").pop() || "shell"; - return `${shellName}${attached}`; -} - -function buildSessionsSubmenu( - sessions: ListSessionsResponse["sessions"], -): MenuItemConstructorOptions[] { - const aliveSessions = sessions.filter((s) => s.isAlive); +function buildHostServiceSubmenu(): MenuItemConstructorOptions[] { + const manager = getHostServiceManager(); + const orgIds = manager.getActiveOrganizationIds(); const menuItems: MenuItemConstructorOptions[] = []; - if (aliveSessions.length === 0) { - menuItems.push({ label: "No active sessions", enabled: false }); + if (orgIds.length === 0) { + menuItems.push({ label: "No active services", enabled: false }); } else { - const byWorkspace = new Map(); - for (const session of aliveSessions) { - const existing = byWorkspace.get(session.workspaceId) || []; - existing.push(session); - byWorkspace.set(session.workspaceId, existing); - } - let isFirst = true; - for (const [workspaceId, workspaceSessions] of byWorkspace) { - const workspaceName = getWorkspaceName(workspaceId); - + for (const orgId of orgIds) { if (!isFirst) { menuItems.push({ type: "separator" }); } + isFirst = false; + + const info = manager.getServiceInfo(orgId); + const orgName = info.organizationName ?? orgId.slice(0, 8); + const statusLabel = formatStatusLabel(info.status); + const versionSuffix = info.serviceVersion + ? ` (v${info.serviceVersion})` + : ""; + const isRunning = info.status === "running"; + + menuItems.push({ + label: orgName, + enabled: false, + }); + menuItems.push({ - label: workspaceName, + label: ` ${statusLabel}${versionSuffix}`, enabled: false, }); - for (const session of workspaceSessions) { + if (info.uptime !== null) { + const uptimeStr = formatUptime(info.uptime); menuItems.push({ - label: formatSessionLabel(session), - submenu: [ - { - label: "Open in Superset", - click: () => openSessionInSuperset(session.workspaceId), - }, - { - label: "Kill", - click: () => killSession(session.paneId), - }, - ], + label: ` Uptime: ${uptimeStr}`, + enabled: false, }); } - isFirst = false; - } - } - - menuItems.push({ type: "separator" }); - menuItems.push({ - label: "Terminal Settings", - click: openTerminalSettings, - }); - - return menuItems; -} - -async function quitApp(): Promise { - const { sessions } = await tryListExistingDaemonSessions(); - const hasActiveSessions = sessions.some((s) => s.isAlive); + if (info.restartCount > 0) { + menuItems.push({ + label: ` Restarts: ${info.restartCount}`, + enabled: false, + }); + } - if (!hasActiveSessions) { - app.quit(); - return; - } + if (info.pendingRestart) { + menuItems.push({ + label: " Update required — restart to apply", + enabled: false, + }); + } else if ( + info.compatibility && + "updateAvailable" in info.compatibility && + info.compatibility.updateAvailable + ) { + menuItems.push({ + label: " Update available", + enabled: false, + }); + } - const { response } = await dialog.showMessageBox({ - type: "question", - buttons: ["Cancel", "Keep Sessions", "Kill Sessions"], - defaultId: 1, - cancelId: 0, - title: "Quit Superset?", - message: "Quit Superset?", - detail: - "Keep sessions running in the background, or kill all sessions and shut down the daemon?", - }); - - if (response === 0) { - return; - } + menuItems.push({ + label: " Restart", + enabled: isRunning, + click: () => { + manager.restart(orgId).catch((err) => { + console.error( + `[Tray] Failed to restart host-service for ${orgId}:`, + err, + ); + }); + updateTrayMenu(); + }, + }); - if (response === 2) { - try { - await restartDaemonShared(); - } catch (error) { - console.warn( - "[Tray] Failed to restart terminal daemon during quit:", - error, - ); - await dialog - .showMessageBox({ - type: "error", - buttons: ["OK"], - defaultId: 0, - title: "Failed to kill sessions", - message: "Superset could not kill terminal sessions.", - detail: - "The app will stay open so you can retry or quit while keeping sessions running in the background.", - }) - .catch((dialogError) => { - console.warn( - "[Tray] Failed to show terminal quit error dialog:", - dialogError, - ); - }); - return; + menuItems.push({ + label: " Stop", + enabled: isRunning, + click: () => { + manager.stop(orgId); + updateTrayMenu(); + }, + }); } } - app.quit(); + return menuItems; +} + +function formatUptime(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; } -async function updateTrayMenu(): Promise { +function updateTrayMenu(): void { if (!tray) return; - const { sessions } = await tryListExistingDaemonSessions(); - const sessionCount = sessions.filter((s) => s.isAlive).length; + const manager = getHostServiceManager(); + const orgIds = manager.getActiveOrganizationIds(); - const sessionsSubmenu = buildSessionsSubmenu(sessions); - const sessionsLabel = - sessionCount > 0 - ? `Background Sessions (${sessionCount})` - : "Background Sessions"; + const hasActive = orgIds.length > 0; + const hostServiceLabel = hasActive + ? `Host Service (${orgIds.length})` + : "Host Service"; + + const hostServiceSubmenu = buildHostServiceSubmenu(); const menu = Menu.buildFromTemplate([ { - label: sessionsLabel, - submenu: sessionsSubmenu, + label: hostServiceLabel, + submenu: hostServiceSubmenu, }, { type: "separator" }, { label: "Open Superset", - click: showWindow, + click: focusMainWindow, }, { label: "Settings", click: openSettings, }, { - label: "Quit", - click: quitApp, + label: "Check for Updates", + click: () => { + // Imported lazily to avoid circular dependency + const { checkForUpdatesInteractive } = require("../auto-updater"); + checkForUpdatesInteractive(); + }, }, + { type: "separator" }, + ...(hasActive + ? [ + { + label: "Quit (Keep Services Running)", + click: () => requestQuit("release"), + }, + { + label: "Quit & Stop Services", + click: () => requestQuit("stop"), + }, + ] + : [ + { + label: "Quit", + click: () => requestQuit("release"), + }, + ]), ]); tray.setContextMenu(menu); @@ -320,14 +279,16 @@ export function initTray(): void { tray = new Tray(icon); tray.setToolTip("Superset"); - updateTrayMenu().catch((error) => { - console.error("[Tray] Failed to build initial menu:", error); + updateTrayMenu(); + + const manager = getHostServiceManager(); + manager.on("status-changed", (_event: HostServiceStatusEvent) => { + updateTrayMenu(); }); + // Periodic refresh as a fallback pollIntervalId = setInterval(() => { - updateTrayMenu().catch((error) => { - console.error("[Tray] Failed to update menu:", error); - }); + updateTrayMenu(); }, POLL_INTERVAL_MS); // Don't keep Electron alive just for tray updates pollIntervalId.unref(); diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index e932fc634e2..8ef58e05fa8 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -298,7 +298,7 @@ export async function MainWindow() { console.error(` Error:`, error); }); - window.on("close", () => { + window.on("close", (event) => { // Save window state first, before any cleanup const isMaximized = window.isMaximized(); const bounds = isMaximized ? window.getNormalBounds() : window.getBounds(); @@ -313,13 +313,20 @@ export async function MainWindow() { }); persistedZoomLevel = zoomLevel; + // macOS: hide instead of destroy so "Open Superset" can reshow instantly. + // The quit flow uses app.exit(0) which bypasses close events entirely, + // so this hide path only runs for Cmd+W / red-X. + if (PLATFORM.IS_MAC) { + event.preventDefault(); + window.hide(); + return; + } + browserManager.unregisterAll(); server.close(); notificationManager.dispose(); notificationsEmitter.removeAllListeners(); - // Remove terminal listeners to prevent duplicates when window reopens on macOS getWorkspaceRuntimeRegistry().getDefault().terminal.detachAllListeners(); - // Detach window from IPC handler (handler stays alive for window reopen) ipcHandler?.detachWindow(window); currentWindow = null; }); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts index e522a0b8304..1a469c70ab5 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts @@ -15,6 +15,14 @@ export interface TerminalTransport { currentUrl: string | null; onDataDisposable: { dispose(): void } | null; stateListeners: Set<() => void>; + /** Internal: auto-reconnect timer. */ + _reconnectTimer: ReturnType | null; + /** Internal: reconnect attempt count for backoff. */ + _reconnectAttempt: number; + /** The xterm instance used for reconnection. */ + _terminal: XTerm | null; + /** Set when the server sends an exit message — no reconnect after this. */ + _exited: boolean; } function setConnectionState( @@ -27,6 +35,10 @@ function setConnectionState( } } +const MAX_RECONNECT_DELAY = 10_000; +const BASE_RECONNECT_DELAY = 500; +const MAX_RECONNECT_ATTEMPTS = 10; + export function createTransport(): TerminalTransport { return { socket: null, @@ -34,9 +46,44 @@ export function createTransport(): TerminalTransport { currentUrl: null, onDataDisposable: null, stateListeners: new Set(), + _reconnectTimer: null, + _reconnectAttempt: 0, + _terminal: null, + _exited: false, }; } +function scheduleReconnect(transport: TerminalTransport) { + if (transport._reconnectTimer) return; + if (transport._exited) return; + if (!transport.currentUrl || !transport._terminal) return; + if (transport._reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) return; + + const delay = Math.min( + BASE_RECONNECT_DELAY * 2 ** transport._reconnectAttempt, + MAX_RECONNECT_DELAY, + ); + transport._reconnectAttempt++; + + transport._reconnectTimer = setTimeout(() => { + transport._reconnectTimer = null; + if ( + transport.connectionState === "closed" && + transport.currentUrl && + transport._terminal + ) { + connect(transport, transport._terminal, transport.currentUrl); + } + }, delay); +} + +function cancelReconnect(transport: TerminalTransport) { + if (transport._reconnectTimer) { + clearTimeout(transport._reconnectTimer); + transport._reconnectTimer = null; + } +} + export function connect( transport: TerminalTransport, terminal: XTerm, @@ -53,13 +100,17 @@ export function connect( transport.socket = null; } + cancelReconnect(transport); transport.currentUrl = wsUrl; + transport._terminal = terminal; + transport._exited = false; setConnectionState(transport, "connecting"); const socket = new WebSocket(wsUrl); transport.socket = socket; socket.addEventListener("open", () => { if (transport.socket !== socket) return; + transport._reconnectAttempt = 0; setConnectionState(transport, "open"); sendResize(transport, terminal.cols, terminal.rows); }); @@ -85,6 +136,8 @@ export function connect( } if (message.type === "exit") { + transport._exited = true; + cancelReconnect(transport); terminal.writeln( `\r\n[terminal] exited with code ${message.exitCode} (signal ${message.signal})`, ); @@ -95,6 +148,8 @@ export function connect( if (transport.socket !== socket) return; setConnectionState(transport, "closed"); transport.socket = null; + // Auto-reconnect on unexpected close (host-service restart, network blip) + scheduleReconnect(transport); }); socket.addEventListener("error", () => { @@ -110,11 +165,14 @@ export function connect( } export function disconnect(transport: TerminalTransport) { + cancelReconnect(transport); if (transport.socket) { transport.socket.close(); transport.socket = null; } transport.currentUrl = null; + transport._terminal = null; + transport._reconnectAttempt = 0; setConnectionState(transport, "disconnected"); transport.onDataDisposable?.dispose(); transport.onDataDisposable = null; @@ -137,11 +195,14 @@ export function sendDispose(transport: TerminalTransport) { } export function disposeTransport(transport: TerminalTransport) { + cancelReconnect(transport); if (transport.socket) { transport.socket.close(); transport.socket = null; } transport.currentUrl = null; + transport._terminal = null; + transport._reconnectAttempt = 0; transport.onDataDisposable?.dispose(); transport.onDataDisposable = null; transport.stateListeners.clear(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx index e368a46a88c..e5093073abd 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx @@ -52,8 +52,12 @@ export function HostServiceProvider({ children }: { children: ReactNode }) { // Start a host service for every org useEffect(() => { for (const orgId of orgIds) { + const org = organizations?.find((o) => o.id === orgId); utils.hostServiceManager.getLocalPort - .ensureData({ organizationId: orgId }) + .ensureData({ + organizationId: orgId, + organizationName: org?.name ?? undefined, + }) .catch((err) => { console.error( `[host-service] Failed to start for org ${orgId}:`, @@ -61,12 +65,18 @@ export function HostServiceProvider({ children }: { children: ReactNode }) { ); }); } - }, [orgIds, utils]); + }, [orgIds, organizations, utils]); // Query the active org's port reactively + const activeOrgName = organizations?.find( + (o) => o.id === activeOrganizationId, + )?.name; const { data: activePortData } = electronTrpc.hostServiceManager.getLocalPort.useQuery( - { organizationId: activeOrganizationId as string }, + { + organizationId: activeOrganizationId as string, + organizationName: activeOrgName ?? undefined, + }, { enabled: !!activeOrganizationId }, ); @@ -87,8 +97,10 @@ export function HostServiceProvider({ children }: { children: ReactNode }) { }; for (const orgId of orgIds) { + const org = organizations?.find((o) => o.id === orgId); const cached = utils.hostServiceManager.getLocalPort.getData({ organizationId: orgId, + organizationName: org?.name ?? undefined, }); if (cached?.port) { addOrg(orgId, cached.port, cached.secret ?? null); @@ -109,7 +121,7 @@ export function HostServiceProvider({ children }: { children: ReactNode }) { } return map; - }, [orgIds, utils, activeOrganizationId, activePortData]); + }, [orgIds, organizations, utils, activeOrganizationId, activePortData]); const value = useMemo(() => ({ services }), [services]); diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index 89da8f230f1..05aaedf05a1 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -33,6 +33,8 @@ export interface CreateAppOptions { dbPath?: string; deviceClientId?: string; deviceName?: string; + serviceVersion?: string | null; + protocolVersion?: number | null; allowedOrigins?: string[]; } @@ -133,6 +135,8 @@ export function createApp(options?: CreateAppOptions): CreateAppResult { runtime, deviceClientId: options?.deviceClientId ?? null, deviceName: options?.deviceName ?? null, + serviceVersion: options?.serviceVersion ?? null, + protocolVersion: options?.protocolVersion ?? null, isAuthenticated, } as Record; }, diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index bf97a5e5583..bb52ab16c12 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -229,6 +229,33 @@ export function registerWorkspaceTerminalRoute({ return c.json({ terminalId: result.terminalId, status: "active" }); }); + // REST dispose — does not require an open WebSocket + app.delete("/terminal/sessions/:terminalId", (c) => { + const terminalId = c.req.param("terminalId"); + if (!terminalId) { + return c.json({ error: "Missing terminalId" }, 400); + } + + const session = sessions.get(terminalId); + if (!session) { + return c.json({ error: "Session not found" }, 404); + } + + disposeSession(terminalId, db); + return c.json({ terminalId, status: "disposed" }); + }); + + // REST list — enumerate live terminal sessions + app.get("/terminal/sessions", (c) => { + const result = Array.from(sessions.values()).map((s) => ({ + terminalId: s.terminalId, + exited: s.exited, + exitCode: s.exitCode, + attached: s.socket !== null, + })); + return c.json({ sessions: result }); + }); + app.get( "/terminal/:terminalId", upgradeWebSocket((c) => { diff --git a/packages/host-service/src/trpc/router/health/health.ts b/packages/host-service/src/trpc/router/health/health.ts index a1a1d3d3c36..65353be7aa2 100644 --- a/packages/host-service/src/trpc/router/health/health.ts +++ b/packages/host-service/src/trpc/router/health/health.ts @@ -1,17 +1,23 @@ import os from "node:os"; import { publicProcedure, router } from "../../index"; +const processStartedAt = Date.now(); + export const healthRouter = router({ check: publicProcedure.query(() => { return { status: "ok" as const }; }), - info: publicProcedure.query(() => { + info: publicProcedure.query(({ ctx }) => { return { platform: os.platform(), arch: os.arch(), nodeVersion: process.version, uptime: process.uptime(), + serviceVersion: ctx.serviceVersion ?? null, + protocolVersion: ctx.protocolVersion ?? null, + organizationId: process.env.ORGANIZATION_ID ?? null, + startedAt: processStartedAt, }; }), }); diff --git a/packages/host-service/src/types.ts b/packages/host-service/src/types.ts index b988590a598..7088867583f 100644 --- a/packages/host-service/src/types.ts +++ b/packages/host-service/src/types.ts @@ -23,5 +23,7 @@ export interface HostServiceContext { runtime: HostServiceRuntime; deviceClientId: string | null; deviceName: string | null; + serviceVersion: string | null; + protocolVersion: number | null; isAuthenticated: boolean; }