From c90c924b9bdd4ee2375fcf5216bb0e4aa1ab6f79 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 18 May 2026 22:34:12 -0700 Subject: [PATCH] fix sidebar notifications after host restart --- .../lib/agent-setup/agent-wrappers-copilot.ts | 2 +- .../lib/agent-setup/agent-wrappers-cursor.ts | 2 +- .../lib/agent-setup/agent-wrappers-gemini.ts | 2 +- .../lib/agent-setup/agent-wrappers.test.ts | 10 +- .../main/lib/agent-setup/notify-hook.test.ts | 9 +- .../src/main/lib/agent-setup/notify-hook.ts | 2 +- .../templates/copilot-hook.template.sh | 3 +- .../templates/cursor-hook.template.sh | 3 +- .../templates/gemini-hook.template.sh | 3 +- .../templates/notify-hook.template.sh | 4 +- .../main/lib/host-service-coordinator.test.ts | 55 ++++++++ .../src/main/lib/host-service-coordinator.ts | 98 ++++++++++++- .../src/main/lib/host-service-utils.ts | 36 ++++- .../notification-manager.test.ts | 9 +- .../lib/notifications/notification-manager.ts | 1 + .../src/main/lib/notifications/server.ts | 3 + apps/desktop/src/main/windows/main.ts | 10 ++ .../V2NotificationController.tsx | 131 +++++++++++++++--- .../lib/lifecycleEvents.ts | 43 +++++- .../lib/resolveV2NotificationTarget.test.ts | 13 ++ apps/desktop/src/shared/notification-types.ts | 1 + 21 files changed, 403 insertions(+), 37 deletions(-) diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-copilot.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-copilot.ts index 1a9b34964d6..a0a9d9eb8e2 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-copilot.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-copilot.ts @@ -11,7 +11,7 @@ import { HOOKS_DIR } from "./paths"; export const COPILOT_HOOK_SCRIPT_NAME = "copilot-hook.sh"; const COPILOT_HOOK_SIGNATURE = "# Superset copilot hook"; -const COPILOT_HOOK_VERSION = "v1"; +const COPILOT_HOOK_VERSION = "v2"; export const COPILOT_HOOK_MARKER = `${COPILOT_HOOK_SIGNATURE} ${COPILOT_HOOK_VERSION}`; const COPILOT_HOOK_TEMPLATE_PATH = path.join( diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-cursor.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-cursor.ts index 4e47760df52..2237c21c709 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-cursor.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-cursor.ts @@ -14,7 +14,7 @@ import { HOOKS_DIR } from "./paths"; export const CURSOR_HOOK_SCRIPT_NAME = "cursor-hook.sh"; const CURSOR_HOOK_SIGNATURE = "# Superset cursor hook"; -const CURSOR_HOOK_VERSION = "v2"; +const CURSOR_HOOK_VERSION = "v3"; export const CURSOR_HOOK_MARKER = `${CURSOR_HOOK_SIGNATURE} ${CURSOR_HOOK_VERSION}`; const CURSOR_HOOK_TEMPLATE_PATH = path.join( diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-gemini.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-gemini.ts index d1ef6cedd77..6529f6f32a5 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-gemini.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-gemini.ts @@ -14,7 +14,7 @@ import { HOOKS_DIR } from "./paths"; export const GEMINI_HOOK_SCRIPT_NAME = "gemini-hook.sh"; const GEMINI_HOOK_SIGNATURE = "# Superset gemini hook"; -const GEMINI_HOOK_VERSION = "v2"; +const GEMINI_HOOK_VERSION = "v3"; export const GEMINI_HOOK_MARKER = `${GEMINI_HOOK_SIGNATURE} ${GEMINI_HOOK_VERSION}`; const GEMINI_HOOK_TEMPLATE_PATH = path.join( diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts index ad62d4b13e4..e6528132ceb 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts @@ -32,7 +32,7 @@ mock.module("shared/env.shared", () => ({ mock.module("./notify-hook", () => ({ NOTIFY_SCRIPT_NAME: "notify.sh", - NOTIFY_SCRIPT_MARKER: "# Superset agent notification hook", + NOTIFY_SCRIPT_MARKER: "# Superset agent notification hook v3", getNotifyScriptPath: () => path.join(TEST_HOOKS_DIR, "notify.sh"), getNotifyScriptContent: () => "#!/bin/bash\nexit 0\n", createNotifyScript: () => {}, @@ -66,6 +66,7 @@ const { createClaudeSettingsJson, createCodexHooksJson, createCodexWrapper, + COPILOT_HOOK_MARKER, CURSOR_HOOK_MARKER, createDroidSettingsJson, createDroidWrapper, @@ -688,9 +689,10 @@ exit 0 expect(JSON.parse(content2)).toEqual(JSON.parse(content)); }); - it("bumps Cursor and Gemini hook script markers when hook semantics change", () => { - expect(CURSOR_HOOK_MARKER).toBe("# Superset cursor hook v2"); - expect(GEMINI_HOOK_MARKER).toBe("# Superset gemini hook v2"); + it("bumps hook script markers when hook semantics change", () => { + expect(COPILOT_HOOK_MARKER).toBe("# Superset copilot hook v2"); + expect(CURSOR_HOOK_MARKER).toBe("# Superset cursor hook v3"); + expect(GEMINI_HOOK_MARKER).toBe("# Superset gemini hook v3"); }); it("replaces stale Mastra hook commands from old superset paths", () => { diff --git a/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts b/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts index 01e09c02ab6..b4dc0e9496b 100644 --- a/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts +++ b/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts @@ -1,8 +1,13 @@ import { describe, expect, it } from "bun:test"; import { readFileSync } from "node:fs"; import path from "node:path"; +import { NOTIFY_SCRIPT_MARKER } from "./notify-hook"; describe("getNotifyScriptContent", () => { + it("bumps the notify hook marker when hook semantics change", () => { + expect(NOTIFY_SCRIPT_MARKER).toBe("# Superset agent notification hook v3"); + }); + it("emits the v2 host-service payload with full agent identity", () => { const script = readFileSync( path.join(import.meta.dir, "templates", "notify-hook.template.sh"), @@ -41,9 +46,10 @@ describe("getNotifyScriptContent", () => { 'if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ] && [ -n "$SUPERSET_TERMINAL_ID" ]; then', ); expect(script).toContain( - '[ -z "$SUPERSET_TAB_ID" ] && [ -z "$SESSION_ID" ] && exit 0', + '[ -z "$SUPERSET_TAB_ID" ] && [ -z "$SESSION_ID" ] && [ -z "$SUPERSET_TERMINAL_ID" ] && exit 0', ); expect(script).toContain("/hook/complete"); + expect(script).toContain("terminalId=$SUPERSET_TERMINAL_ID"); expect(script).toContain("SUPERSET_TAB_ID"); expect(script).toContain("SUPERSET_PANE_ID"); }); @@ -71,6 +77,7 @@ describe("per-agent hook scripts dispatch to v2", () => { expect(script).toContain("/hook/complete"); expect(script).toContain('V1_EVENT_TYPE="$EVENT_TYPE"'); expect(script).toContain("eventType=$V1_EVENT_TYPE"); + expect(script).toContain("terminalId=$SUPERSET_TERMINAL_ID"); expect(script).toContain("SUPERSET_TAB_ID"); expect(script).toContain("SUPERSET_PANE_ID"); }); diff --git a/apps/desktop/src/main/lib/agent-setup/notify-hook.ts b/apps/desktop/src/main/lib/agent-setup/notify-hook.ts index 19968b1385d..4dda200742e 100644 --- a/apps/desktop/src/main/lib/agent-setup/notify-hook.ts +++ b/apps/desktop/src/main/lib/agent-setup/notify-hook.ts @@ -4,7 +4,7 @@ import { env } from "shared/env.shared"; import { HOOKS_DIR } from "./paths"; export const NOTIFY_SCRIPT_NAME = "notify.sh"; -export const NOTIFY_SCRIPT_MARKER = "# Superset agent notification hook"; +export const NOTIFY_SCRIPT_MARKER = "# Superset agent notification hook v3"; const NOTIFY_SCRIPT_TEMPLATE_PATH = path.join( __dirname, diff --git a/apps/desktop/src/main/lib/agent-setup/templates/copilot-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/copilot-hook.template.sh index 768a837e072..ba0f09c39f8 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/copilot-hook.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/copilot-hook.template.sh @@ -46,13 +46,14 @@ if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ] && [ -n "$SUPERSET_TERMINAL_ID" ]; the esac fi -[ -z "$SUPERSET_TAB_ID" ] && exit 0 +[ -z "$SUPERSET_TAB_ID" ] && [ -z "$SUPERSET_TERMINAL_ID" ] && exit 0 curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \ --connect-timeout 1 --max-time 2 \ --data-urlencode "paneId=$SUPERSET_PANE_ID" \ --data-urlencode "tabId=$SUPERSET_TAB_ID" \ --data-urlencode "workspaceId=$SUPERSET_WORKSPACE_ID" \ + --data-urlencode "terminalId=$SUPERSET_TERMINAL_ID" \ --data-urlencode "sessionId=$HOOK_SESSION_ID" \ --data-urlencode "hookSessionId=$HOOK_SESSION_ID" \ --data-urlencode "eventType=$V1_EVENT_TYPE" \ diff --git a/apps/desktop/src/main/lib/agent-setup/templates/cursor-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/cursor-hook.template.sh index cbfbbae638d..d2691e3c4db 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/cursor-hook.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/cursor-hook.template.sh @@ -44,13 +44,14 @@ if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ] && [ -n "$SUPERSET_TERMINAL_ID" ]; the esac fi -[ -z "$SUPERSET_TAB_ID" ] && exit 0 +[ -z "$SUPERSET_TAB_ID" ] && [ -z "$SUPERSET_TERMINAL_ID" ] && exit 0 curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \ --connect-timeout 1 --max-time 2 \ --data-urlencode "paneId=$SUPERSET_PANE_ID" \ --data-urlencode "tabId=$SUPERSET_TAB_ID" \ --data-urlencode "workspaceId=$SUPERSET_WORKSPACE_ID" \ + --data-urlencode "terminalId=$SUPERSET_TERMINAL_ID" \ --data-urlencode "sessionId=$HOOK_SESSION_ID" \ --data-urlencode "hookSessionId=$HOOK_SESSION_ID" \ --data-urlencode "eventType=$V1_EVENT_TYPE" \ diff --git a/apps/desktop/src/main/lib/agent-setup/templates/gemini-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/gemini-hook.template.sh index 4277737d1c8..628940c8700 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/gemini-hook.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/gemini-hook.template.sh @@ -46,13 +46,14 @@ if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ] && [ -n "$SUPERSET_TERMINAL_ID" ]; the esac fi -[ -z "$SUPERSET_TAB_ID" ] && exit 0 +[ -z "$SUPERSET_TAB_ID" ] && [ -z "$SUPERSET_TERMINAL_ID" ] && exit 0 curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \ --connect-timeout 1 --max-time 2 \ --data-urlencode "paneId=$SUPERSET_PANE_ID" \ --data-urlencode "tabId=$SUPERSET_TAB_ID" \ --data-urlencode "workspaceId=$SUPERSET_WORKSPACE_ID" \ + --data-urlencode "terminalId=$SUPERSET_TERMINAL_ID" \ --data-urlencode "sessionId=$HOOK_SESSION_ID" \ --data-urlencode "hookSessionId=$HOOK_SESSION_ID" \ --data-urlencode "eventType=$V1_EVENT_TYPE" \ diff --git a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh index 3073abd42d4..d3ef7d940de 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh @@ -93,7 +93,7 @@ if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ] && [ -n "$SUPERSET_TERMINAL_ID" ]; the fi # v1 fallback: Electron localhost hook server. Kept while v1 terminals exist. -[ -z "$SUPERSET_TAB_ID" ] && [ -z "$SESSION_ID" ] && exit 0 +[ -z "$SUPERSET_TAB_ID" ] && [ -z "$SESSION_ID" ] && [ -z "$SUPERSET_TERMINAL_ID" ] && exit 0 if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then STATUS_CODE=$(curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \ @@ -101,6 +101,7 @@ if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then --data-urlencode "paneId=$SUPERSET_PANE_ID" \ --data-urlencode "tabId=$SUPERSET_TAB_ID" \ --data-urlencode "workspaceId=$SUPERSET_WORKSPACE_ID" \ + --data-urlencode "terminalId=$SUPERSET_TERMINAL_ID" \ --data-urlencode "sessionId=$SESSION_ID" \ --data-urlencode "hookSessionId=$HOOK_SESSION_ID" \ --data-urlencode "resourceId=$RESOURCE_ID" \ @@ -117,6 +118,7 @@ else --data-urlencode "paneId=$SUPERSET_PANE_ID" \ --data-urlencode "tabId=$SUPERSET_TAB_ID" \ --data-urlencode "workspaceId=$SUPERSET_WORKSPACE_ID" \ + --data-urlencode "terminalId=$SUPERSET_TERMINAL_ID" \ --data-urlencode "sessionId=$SESSION_ID" \ --data-urlencode "hookSessionId=$HOOK_SESSION_ID" \ --data-urlencode "resourceId=$RESOURCE_ID" \ diff --git a/apps/desktop/src/main/lib/host-service-coordinator.test.ts b/apps/desktop/src/main/lib/host-service-coordinator.test.ts index a2dcb9f5861..24d2e9bf8c0 100644 --- a/apps/desktop/src/main/lib/host-service-coordinator.test.ts +++ b/apps/desktop/src/main/lib/host-service-coordinator.test.ts @@ -112,6 +112,11 @@ const baseManifest = (pid: number, endpoint = "http://127.0.0.1:55555") => ({ const spawnConfig = { authToken: "token", cloudApiUrl: "https://api.example" }; +interface HostServiceCoordinatorInternals { + getPreferredPorts(organizationId: string): number[]; + rememberPort(organizationId: string, port: number): void; +} + describe("HostServiceCoordinator.tryAdopt — adoption health check", () => { let coordinator: InstanceType; let spawnMock: ReturnType; @@ -258,6 +263,56 @@ describe("HostServiceCoordinator.tryAdopt — adoption health check", () => { }); }); +describe("HostServiceCoordinator preferred ports", () => { + let coordinator: InstanceType; + + beforeEach(() => { + manifestStore.current = null; + readManifestMock.mockClear(); + removeManifestMock.mockClear(); + isProcessAliveMock.mockClear(); + listManifestsMock.mockClear(); + killProcessMock.mockClear(); + pollHealthCheckMock.mockClear(); + + testManifestRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hsc-test-")); + coordinator = new HostServiceCoordinator(); + }); + + afterEach(() => { + coordinator.releaseAll(); + if (testManifestRoot) { + fs.rmSync(testManifestRoot, { recursive: true, force: true }); + testManifestRoot = ""; + } + }); + + test("prefers the last known port, then the manifest port, then a stable org port", () => { + manifestStore.current = baseManifest(1234, "http://127.0.0.1:45555"); + const internals = coordinator as unknown as HostServiceCoordinatorInternals; + internals.rememberPort("org-1", 46666); + + const ports = internals.getPreferredPorts("org-1"); + + expect(ports[0]).toBe(46666); + expect(ports[1]).toBe(45555); + expect(ports[2]).toBeGreaterThanOrEqual(48_000); + expect(ports[2]).toBeLessThan(49_000); + }); + + test("uses a deterministic stable port when no previous port exists", () => { + const internals = coordinator as unknown as HostServiceCoordinatorInternals; + + const ports = internals.getPreferredPorts("org-1"); + const secondRead = internals.getPreferredPorts("org-1"); + + expect(ports).toEqual(secondRead); + expect(ports).toHaveLength(1); + expect(ports[0]).toBeGreaterThanOrEqual(48_000); + expect(ports[0]).toBeLessThan(49_000); + }); +}); + describe("HostServiceCoordinator.reset", () => { let coordinator: InstanceType; let spawnMock: ReturnType; diff --git a/apps/desktop/src/main/lib/host-service-coordinator.ts b/apps/desktop/src/main/lib/host-service-coordinator.ts index ddb5055c434..e160ad5200b 100644 --- a/apps/desktop/src/main/lib/host-service-coordinator.ts +++ b/apps/desktop/src/main/lib/host-service-coordinator.ts @@ -57,6 +57,37 @@ interface HostServiceProcess { } const ADOPTED_LIVENESS_INTERVAL = 5_000; +// High, uncommon user-space range: above usual web/dev server ports and below +// macOS's default ephemeral range, while still falling back if occupied. +const STABLE_PORT_BASE = 48_000; +const STABLE_PORT_COUNT = 1_000; + +function getStablePortForOrganization(organizationId: string): number { + let hash = 2_166_136_261; + for (let index = 0; index < organizationId.length; index++) { + hash ^= organizationId.charCodeAt(index); + hash = Math.imul(hash, 16_777_619); + } + return STABLE_PORT_BASE + ((hash >>> 0) % STABLE_PORT_COUNT); +} + +function getPortFromEndpoint(endpoint: string): number | undefined { + try { + const port = Number(new URL(endpoint).port); + return isValidPort(port) ? port : undefined; + } catch { + return undefined; + } +} + +function isValidPort(port: number | null | undefined): port is number { + return ( + typeof port === "number" && + Number.isInteger(port) && + port > 0 && + port <= 65_535 + ); +} /** * Cap how long an adoption health check can take before we decide the adopted @@ -68,6 +99,7 @@ const ADOPT_HEALTH_CHECK_TIMEOUT_MS = 2_000; export class HostServiceCoordinator extends EventEmitter { private instances = new Map(); private pendingStarts = new Map>(); + private lastKnownPorts = new Map(); private adoptedLivenessTimers = new Map< string, ReturnType @@ -83,6 +115,14 @@ export class HostServiceCoordinator extends EventEmitter { async start( organizationId: string, config: SpawnConfig, + ): Promise { + return this.startWithPreferredPorts(organizationId, config); + } + + private async startWithPreferredPorts( + organizationId: string, + config: SpawnConfig, + preferredPorts?: Iterable, ): Promise { const existing = this.instances.get(organizationId); if (existing?.status === "running") { @@ -97,9 +137,11 @@ export class HostServiceCoordinator extends EventEmitter { if (pending) return pending; const startPromise = (async (): Promise => { + const resolvedPreferredPorts = + preferredPorts ?? this.getPreferredPorts(organizationId); const adopted = await this.tryAdopt(organizationId); if (adopted) return adopted; - return this.spawn(organizationId, config); + return this.spawn(organizationId, config, resolvedPreferredPorts); })(); this.pendingStarts.set(organizationId, startPromise); @@ -110,6 +152,45 @@ export class HostServiceCoordinator extends EventEmitter { } } + private getPreferredPorts(organizationId: string): number[] { + const ports = [ + this.instances.get(organizationId)?.port, + this.lastKnownPorts.get(organizationId), + this.getManifestPort(organizationId), + getStablePortForOrganization(organizationId), + ]; + const uniquePorts: number[] = []; + const seen = new Set(); + + for (const port of ports) { + if (!isValidPort(port) || seen.has(port)) continue; + seen.add(port); + uniquePorts.push(port); + } + + return uniquePorts; + } + + private getManifestPort(organizationId: string): number | undefined { + const manifest = readManifest(organizationId); + if (!manifest) return undefined; + return this.rememberManifestPort(organizationId, manifest); + } + + private rememberManifestPort( + organizationId: string, + manifest: HostServiceManifest, + ): number | undefined { + const port = getPortFromEndpoint(manifest.endpoint); + if (port) this.rememberPort(organizationId, port); + return port; + } + + private rememberPort(organizationId: string, port: number): void { + if (!isValidPort(port)) return; + this.lastKnownPorts.set(organizationId, port); + } + stop(organizationId: string): void { const instance = this.instances.get(organizationId); this.stopAdoptedLivenessCheck(organizationId); @@ -118,6 +199,7 @@ export class HostServiceCoordinator extends EventEmitter { const previousStatus = instance.status; instance.status = "stopped"; + this.rememberPort(organizationId, instance.port); try { killProcess(instance.pid, "SIGTERM"); @@ -172,8 +254,9 @@ export class HostServiceCoordinator extends EventEmitter { organizationId: string, config: SpawnConfig, ): Promise { + const preferredPorts = this.getPreferredPorts(organizationId); this.stop(organizationId); - return this.start(organizationId, config); + return this.startWithPreferredPorts(organizationId, config, preferredPorts); } /** @@ -191,6 +274,7 @@ export class HostServiceCoordinator extends EventEmitter { // Capture the manifest pid *before* stop() — stop() removes the manifest // for tracked instances and only sends SIGTERM, which a wedged process // can ignore. We escalate to SIGKILL on whatever pid the manifest named. + const preferredPorts = this.getPreferredPorts(organizationId); const manifestPid = readManifest(organizationId)?.pid; this.stop(organizationId); @@ -208,7 +292,7 @@ export class HostServiceCoordinator extends EventEmitter { removeManifest(organizationId); - return this.start(organizationId, config); + return this.startWithPreferredPorts(organizationId, config, preferredPorts); } getConnection(organizationId: string): Connection | null { @@ -342,6 +426,7 @@ export class HostServiceCoordinator extends EventEmitter { const url = new URL(manifest.endpoint); const port = Number(url.port); + this.rememberPort(organizationId, port); const currentAppVersion = app.getVersion(); if (manifest.spawnedByAppVersion !== currentAppVersion) { @@ -406,6 +491,7 @@ export class HostServiceCoordinator extends EventEmitter { ): HostServiceManifest | null { const manifest = readManifest(organizationId); if (!manifest) return null; + this.rememberManifestPort(organizationId, manifest); if (!isProcessAlive(manifest.pid)) { removeManifest(organizationId); @@ -439,8 +525,10 @@ export class HostServiceCoordinator extends EventEmitter { private async spawn( organizationId: string, config: SpawnConfig, + preferredPorts: Iterable = this.getPreferredPorts(organizationId), ): Promise { - const port = await findFreePort(); + const port = await findFreePort(preferredPorts); + this.rememberPort(organizationId, port); const secret = randomBytes(32).toString("hex"); const instance: HostServiceProcess = { @@ -519,6 +607,7 @@ export class HostServiceCoordinator extends EventEmitter { if (!current || current.pid !== childPid || current.status === "stopped") return; + this.rememberPort(organizationId, current.port); this.instances.delete(organizationId); removeManifest(organizationId); this.emitStatus(organizationId, "stopped", "running"); @@ -606,6 +695,7 @@ export class HostServiceCoordinator extends EventEmitter { log.info( `[host-service:${organizationId}] Adopted process ${pid} died`, ); + this.rememberPort(organizationId, instance.port); this.instances.delete(organizationId); removeManifest(organizationId); this.emitStatus(organizationId, "stopped", "running"); diff --git a/apps/desktop/src/main/lib/host-service-utils.ts b/apps/desktop/src/main/lib/host-service-utils.ts index e638c47a4d1..675aa9ba003 100644 --- a/apps/desktop/src/main/lib/host-service-utils.ts +++ b/apps/desktop/src/main/lib/host-service-utils.ts @@ -43,7 +43,17 @@ export function openRotatingLogFd(logPath: string, maxBytes: number): number { } } -export async function findFreePort(): Promise { +export async function findFreePort( + preferredPorts: Iterable = [], +): Promise { + const triedPorts = new Set(); + for (const port of preferredPorts) { + const normalizedPort = normalizePort(port); + if (!normalizedPort || triedPorts.has(normalizedPort)) continue; + triedPorts.add(normalizedPort); + if (await canBindPort(normalizedPort)) return normalizedPort; + } + return new Promise((resolve, reject) => { const server = createServer(); server.listen(0, "127.0.0.1", () => { @@ -59,6 +69,30 @@ export async function findFreePort(): Promise { }); } +function normalizePort(port: number): number | null { + if (!Number.isInteger(port) || port <= 0 || port > 65_535) return null; + return port; +} + +function canBindPort(port: number): Promise { + return new Promise((resolve) => { + const server = createServer(); + const finish = (available: boolean) => { + server.removeAllListeners("error"); + server.removeAllListeners("listening"); + if (!available) { + resolve(false); + return; + } + server.close(() => resolve(true)); + }; + + server.once("error", () => finish(false)); + server.once("listening", () => finish(true)); + server.listen(port, "127.0.0.1"); + }); +} + export async function pollHealthCheck( endpoint: string, secret: string, diff --git a/apps/desktop/src/main/lib/notifications/notification-manager.test.ts b/apps/desktop/src/main/lib/notifications/notification-manager.test.ts index 412345fd2c8..16317bb832f 100644 --- a/apps/desktop/src/main/lib/notifications/notification-manager.test.ts +++ b/apps/desktop/src/main/lib/notifications/notification-manager.test.ts @@ -155,11 +155,18 @@ describe("NotificationManager", () => { tabId: "t1", workspaceId: "w1", sessionId: "s1", + terminalId: "term-1", }); manager.handleAgentLifecycle(event); lastNotification(deps).trigger("click"); expect(deps.clickedIds).toEqual([ - { paneId: "p1", tabId: "t1", workspaceId: "w1", sessionId: "s1" }, + { + paneId: "p1", + tabId: "t1", + workspaceId: "w1", + sessionId: "s1", + terminalId: "term-1", + }, ]); }); diff --git a/apps/desktop/src/main/lib/notifications/notification-manager.ts b/apps/desktop/src/main/lib/notifications/notification-manager.ts index 3434b9ee84a..542887689ba 100644 --- a/apps/desktop/src/main/lib/notifications/notification-manager.ts +++ b/apps/desktop/src/main/lib/notifications/notification-manager.ts @@ -88,6 +88,7 @@ export class NotificationManager { tabId: event.tabId, workspaceId: event.workspaceId, sessionId: event.sessionId, + ...(event.terminalId ? { terminalId: event.terminalId } : {}), }); this.untrack(key, notification); }); diff --git a/apps/desktop/src/main/lib/notifications/server.ts b/apps/desktop/src/main/lib/notifications/server.ts index d28fa5bb235..54749ea195b 100644 --- a/apps/desktop/src/main/lib/notifications/server.ts +++ b/apps/desktop/src/main/lib/notifications/server.ts @@ -55,6 +55,7 @@ app.get("/hook/complete", (req, res) => { tabId, workspaceId, sessionId, + terminalId, hookSessionId, resourceId, eventType, @@ -101,6 +102,7 @@ app.get("/hook/complete", (req, res) => { paneId: resolvedPaneId, tabId: tabId as string | undefined, workspaceId: workspaceId as string | undefined, + terminalId: terminalId as string | undefined, eventType: mappedEventType, }; @@ -112,6 +114,7 @@ app.get("/hook/complete", (req, res) => { tabId: tabId as string | undefined, workspaceId: workspaceId as string | undefined, sessionId: sessionId as string | undefined, + terminalId: terminalId as string | undefined, hookSessionId: hookSessionId as string | undefined, resourceId: resourceId as string | undefined, resolvedPaneId, diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 90279858241..0e4527b74c8 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -196,6 +196,16 @@ export async function MainWindow() { onNotificationClick: (ids) => { window.show(); window.focus(); + if (ids.workspaceId && ids.terminalId) { + notificationsEmitter.emit( + NOTIFICATION_EVENTS.FOCUS_V2_NOTIFICATION_SOURCE, + { + workspaceId: ids.workspaceId, + source: { type: "terminal", id: ids.terminalId }, + }, + ); + return; + } notificationsEmitter.emit(NOTIFICATION_EVENTS.FOCUS_TAB, ids); }, getVisibilityContext: () => ({ diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/V2NotificationController.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/V2NotificationController.tsx index ec758ed625e..d007a826e87 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/V2NotificationController.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/V2NotificationController.tsx @@ -1,15 +1,19 @@ import type { WorkspaceState } from "@superset/panes"; import { buildHostRoutingKey } from "@superset/shared/host-routing"; import { useLiveQuery } from "@tanstack/react-db"; -import { useMemo } from "react"; +import { useEffectEvent, useMemo } from "react"; import { useRelayUrl } from "renderer/hooks/useRelayUrl"; +import { electronTrpc } from "renderer/lib/electron-trpc"; import type { PaneViewerData } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { NOTIFICATION_EVENTS } from "shared/constants"; +import type { AgentLifecycleEvent } from "shared/notification-types"; import { HostNotificationSubscriber, type HostNotificationWorkspaceState, } from "./components/HostNotificationSubscriber"; +import { handleV2AgentLifecycleStatusEvent } from "./lib/lifecycleEvents"; interface WorkspaceHostRow { workspaceId: string; @@ -24,6 +28,22 @@ interface HostNotificationSubscriberGroup { workspaces: HostNotificationWorkspaceState[]; } +type ElectronNotificationEventName = + (typeof NOTIFICATION_EVENTS)[keyof typeof NOTIFICATION_EVENTS]; + +type ElectronNotificationEvent = + | { + type: typeof NOTIFICATION_EVENTS.AGENT_LIFECYCLE; + data?: AgentLifecycleEvent; + } + | { + type: Exclude< + ElectronNotificationEventName, + typeof NOTIFICATION_EVENTS.AGENT_LIFECYCLE + >; + data?: unknown; + }; + /** * Mounts one v2 notification listener per host-service URL so backgrounded * workspaces update their sidebar status indicator and play the finish sound. @@ -60,18 +80,55 @@ export function V2NotificationController() { })), [collections], ); + const workspaceStatesById = useMemo( + () => + getNotificationWorkspaceStatesById({ + workspaceHosts, + localWorkspaceRows, + }), + [workspaceHosts, localWorkspaceRows], + ); const hostGroups = useMemo( () => groupWorkspacesByHostUrl({ workspaceHosts, - localWorkspaceRows, + workspaceStatesById, machineId, activeHostUrl, relayUrl, }), - [workspaceHosts, localWorkspaceRows, machineId, activeHostUrl, relayUrl], + [workspaceHosts, workspaceStatesById, machineId, activeHostUrl, relayUrl], + ); + + const handleElectronAgentLifecycle = useEffectEvent( + (event: ElectronNotificationEvent) => { + if (event.type !== NOTIFICATION_EVENTS.AGENT_LIFECYCLE) return; + const data = event.data; + if (!data?.workspaceId || !data.terminalId) return; + const workspace = workspaceStatesById.get(data.workspaceId); + if (!workspace) return; + + // Adopted shells keep their launch-time host-service hook URL. When + // that URL is stale, the Electron fallback still has terminal context. + handleV2AgentLifecycleStatusEvent({ + workspaceId: data.workspaceId, + payload: { + eventType: + data.eventType === "PendingQuestion" + ? "PermissionRequest" + : data.eventType, + terminalId: data.terminalId, + occurredAt: Date.now(), + }, + paneLayout: workspace.paneLayout, + }); + }, ); + electronTrpc.notifications.subscribe.useSubscription(undefined, { + onData: handleElectronAgentLifecycle, + }); + return ( <> {hostGroups.map((group) => ( @@ -85,29 +142,61 @@ export function V2NotificationController() { ); } -function groupWorkspacesByHostUrl({ +function getNotificationWorkspaceStatesById({ workspaceHosts, localWorkspaceRows, - machineId, - activeHostUrl, - relayUrl, }: { workspaceHosts: WorkspaceHostRow[]; localWorkspaceRows: Array<{ workspaceId: string; paneLayout: unknown; }>; - machineId: string | null; - activeHostUrl: string | null; - relayUrl: string; -}): HostNotificationSubscriberGroup[] { +}): Map { const paneLayoutsByWorkspaceId = new Map( localWorkspaceRows.map((row) => [ row.workspaceId, row.paneLayout as WorkspaceState, ]), ); + + const statesById = new Map( + localWorkspaceRows.map((row) => [ + row.workspaceId, + { + workspaceId: row.workspaceId, + workspaceName: "Workspace", + paneLayout: paneLayoutsByWorkspaceId.get(row.workspaceId) ?? null, + }, + ]), + ); + + for (const workspace of workspaceHosts) { + statesById.set(workspace.workspaceId, { + workspaceId: workspace.workspaceId, + workspaceName: + workspace.name.trim() || workspace.branch.trim() || "Workspace", + paneLayout: paneLayoutsByWorkspaceId.get(workspace.workspaceId) ?? null, + }); + } + + return statesById; +} + +function groupWorkspacesByHostUrl({ + workspaceHosts, + workspaceStatesById, + machineId, + activeHostUrl, + relayUrl, +}: { + workspaceHosts: WorkspaceHostRow[]; + workspaceStatesById: Map; + machineId: string | null; + activeHostUrl: string | null; + relayUrl: string; +}): HostNotificationSubscriberGroup[] { const groups = new Map(); + const hostedWorkspaceIds = new Set(); for (const workspace of workspaceHosts) { const hostUrl = getHostUrlForWorkspace({ @@ -120,13 +209,21 @@ function groupWorkspacesByHostUrl({ if (!hostUrl) continue; const group = groups.get(hostUrl) ?? []; - group.push({ - workspaceId: workspace.workspaceId, - workspaceName: - workspace.name.trim() || workspace.branch.trim() || "Workspace", - paneLayout: paneLayoutsByWorkspaceId.get(workspace.workspaceId) ?? null, - }); + const state = workspaceStatesById.get(workspace.workspaceId); + if (state) group.push(state); groups.set(hostUrl, group); + hostedWorkspaceIds.add(workspace.workspaceId); + } + + if (activeHostUrl) { + const localGroup = groups.get(activeHostUrl) ?? []; + for (const state of workspaceStatesById.values()) { + if (hostedWorkspaceIds.has(state.workspaceId)) continue; + localGroup.push(state); + } + if (localGroup.length > 0) { + groups.set(activeHostUrl, localGroup); + } } return [...groups.entries()].map(([hostUrl, workspaces]) => ({ diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts index 841d9c27f81..1ed83695a6e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts @@ -46,7 +46,12 @@ export function handleV2AgentLifecycleEvent({ payload, paneLayout, }); - updatePaneStatus(workspaceId, payload, target, paneLayout); + updateV2AgentLifecycleStatus({ + workspaceId, + payload, + paneLayout, + target, + }); // Only Stop and PermissionRequest deserve sound. Start fires per-prompt // (the working spinner is feedback enough); Attached/Detached fire on @@ -72,6 +77,28 @@ export function handleV2AgentLifecycleEvent({ }); } +export function handleV2AgentLifecycleStatusEvent({ + workspaceId, + payload, + paneLayout, +}: { + workspaceId: string; + payload: AgentLifecyclePayload; + paneLayout: WorkspaceState | null | undefined; +}): void { + const target = resolveV2NotificationTarget({ + workspaceId, + payload, + paneLayout, + }); + updateV2AgentLifecycleStatus({ + workspaceId, + payload, + paneLayout, + target, + }); +} + export function handleV2TerminalLifecycleEvent({ workspaceId, payload, @@ -115,6 +142,20 @@ function updatePaneStatus( } } +function updateV2AgentLifecycleStatus({ + workspaceId, + payload, + paneLayout, + target, +}: { + workspaceId: string; + payload: AgentLifecyclePayload; + paneLayout: WorkspaceState | null | undefined; + target: V2NotificationTarget; +}): void { + updatePaneStatus(workspaceId, payload, target, paneLayout); +} + function getCurrentWorkspaceId(): string | null { try { // Matches both `/workspace/` and `/v2-workspace/` route shapes. diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/resolveV2NotificationTarget.test.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/resolveV2NotificationTarget.test.ts index 2dffbbd62e6..20b4db90dd5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/resolveV2NotificationTarget.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/resolveV2NotificationTarget.test.ts @@ -88,6 +88,19 @@ describe("resolveV2NotificationTarget", () => { }); }); + it("falls back to a terminal-only target before pane layout exists", () => { + const target = resolveV2NotificationTarget({ + workspaceId: WORKSPACE_ID, + payload: payload({ terminalId: "terminal-early" }), + paneLayout: null, + }); + + expect(target).toEqual({ + workspaceId: WORKSPACE_ID, + terminalId: "terminal-early", + }); + }); + it("only reports visible for the active tab and active pane", () => { const terminalTarget = resolveTerminalTarget({ workspaceId: WORKSPACE_ID, diff --git a/apps/desktop/src/shared/notification-types.ts b/apps/desktop/src/shared/notification-types.ts index 4e68e6a7979..70594aa452b 100644 --- a/apps/desktop/src/shared/notification-types.ts +++ b/apps/desktop/src/shared/notification-types.ts @@ -8,6 +8,7 @@ export interface NotificationIds { tabId?: string; workspaceId?: string; sessionId?: string; + terminalId?: string; } export interface AgentLifecycleEvent extends NotificationIds {