Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
"@superset/macos-process-metrics": "workspace:*",
"@superset/macos-window-blur": "workspace:*",
"@superset/panes": "workspace:*",
"@superset/port-scanner": "workspace:*",
"@superset/shared": "workspace:*",
"@superset/trpc": "workspace:*",
"@superset/ui": "workspace:*",
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/plans/20260108-2251-static-ports-json.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Static Ports Configuration via ports.json

> Superseded semantics: this plan documents the original static-port replacement
> design. Current behavior treats `.superset/ports.json` as supplemental label
> metadata only: it names dynamically detected listening ports, but does not
> create port rows, hide unlabeled ports, or replace dynamic discovery. See
> `plans/20260422-v2-remote-ports.md` and `apps/docs/content/docs/ports.mdx`
> for the current contract.

This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.

Reference: This plan follows conventions from AGENTS.md and the ExecPlan template at `.agents/commands/create-plan.md`.
Expand Down
10 changes: 5 additions & 5 deletions apps/desktop/src/lib/trpc/routers/browser-automation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ import {
workspaces,
worktrees,
} from "@superset/local-db";
import { observable } from "@trpc/server/observable";
import { and, eq, ne } from "drizzle-orm";
import { app } from "electron";
import { localDb } from "main/lib/local-db";
import {
getProcessCommand,
getProcessName,
getProcessTree,
} from "main/lib/terminal/port-scanner";
} from "@superset/port-scanner";
import { observable } from "@trpc/server/observable";
import { and, eq, ne } from "drizzle-orm";
import { app } from "electron";
import { localDb } from "main/lib/local-db";
import { getTerminalHostClient } from "main/lib/terminal-host/client";
import { z } from "zod";
import { publicProcedure, router } from "../..";
Expand Down
153 changes: 153 additions & 0 deletions apps/desktop/src/lib/trpc/routers/ports/label-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { statSync } from "node:fs";
import { join } from "node:path";
import { workspaces } from "@superset/local-db";
import { eq } from "drizzle-orm";
import { localDb } from "main/lib/local-db";
import { loadStaticPorts } from "main/lib/static-ports";
import { PORTS_FILE_NAME, PROJECT_SUPERSET_DIR_NAME } from "shared/constants";
import { getWorkspacePath } from "../workspaces/utils/worktree";

interface LabelCacheEntry {
labels: Map<number, string> | null;
portsFileSignature: string | null;
worktreePath: string | null;
}

function getPortsFileSignature(worktreePath: string): string | null {
try {
const stat = statSync(
join(worktreePath, PROJECT_SUPERSET_DIR_NAME, PORTS_FILE_NAME),
);
return `${stat.mtimeMs}:${stat.size}`;
} catch (error) {
if (isMissingPathError(error)) return null;
throw error;
}
}

function isMissingPathError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
return code === "ENOENT" || code === "ENOTDIR";
}

function safeGetPortsFileSignature(worktreePath: string): string | null {
try {
return getPortsFileSignature(worktreePath);
} catch (error) {
console.warn("[ports] Failed to stat static port labels:", {
worktreePath,
error,
});
return null;
}
}

function safeLoadLabelsForWorktree(
worktreePath: string,
): Map<number, string> | null {
try {
return loadLabelsForWorktree(worktreePath);
} catch (error) {
console.warn("[ports] Failed to load static port labels:", {
worktreePath,
error,
});
return null;
}
}

/**
* Resolve `ports.json` labels per workspace on demand, then memoize.
*
* Why memoize: `getAll` runs on every `port:add`/`port:remove` event (the
* renderer calls `utils.ports.getAll.invalidate()` in usePortsData). A dev
* server that flaps 5 ports cascades into 5 `getAll` calls × N workspaces of
* sync SQLite reads on the main thread. Cache once; ports.json rarely changes.
*
* `labels: null` with a resolved worktree means "no labels file" — still
* cached so we don't re-check the filesystem every event. A missing worktree is
* not cached because workspace hydration can race first reads.
*
* Lives in its own module so workspace-delete paths in `workspaces/utils/*`
* can call `invalidatePortLabelCache` without creating a ports ↔ workspaces
* import cycle.
*/
const labelCache = new Map<string, LabelCacheEntry>();

function loadLabelsForWorktree(
worktreePath: string,
): Map<number, string> | null {
const result = loadStaticPorts(worktreePath);
if (!result.exists || result.error || !result.ports) {
return null;
}

const labels = new Map<number, string>();
for (const p of result.ports) {
labels.set(p.port, p.label);
}
return labels;
}

function setLabelCache(
workspaceId: string,
worktreePath: string | null,
labels: Map<number, string> | null,
): Map<number, string> | null {
const portsFileSignature = worktreePath
? safeGetPortsFileSignature(worktreePath)
: null;
labelCache.set(workspaceId, {
labels,
portsFileSignature,
worktreePath,
});
return labels;
}

export function getLabelsForWorkspace(
workspaceId: string,
): Map<number, string> | null {
const cached = labelCache.get(workspaceId);
if (cached) {
if (cached.worktreePath === null) {
labelCache.delete(workspaceId);
} else {
const currentSignature = safeGetPortsFileSignature(cached.worktreePath);
if (currentSignature === cached.portsFileSignature) return cached.labels;
return setLabelCache(
workspaceId,
cached.worktreePath,
safeLoadLabelsForWorktree(cached.worktreePath),
);
}
}

const ws = localDb
.select()
.from(workspaces)
.where(eq(workspaces.id, workspaceId))
.get();
const worktreePath = ws ? getWorkspacePath(ws) : null;
if (!worktreePath) {
return null;
}

return setLabelCache(
workspaceId,
worktreePath,
safeLoadLabelsForWorktree(worktreePath),
);
}

/**
* Invalidate the label cache. Call when a workspace is deleted. Edits to
* `ports.json` are detected by the cached file signature.
*/
export function invalidatePortLabelCache(workspaceId?: string): void {
if (workspaceId === undefined) {
labelCache.clear();
} else {
labelCache.delete(workspaceId);
}
}
11 changes: 8 additions & 3 deletions apps/desktop/src/lib/trpc/routers/ports/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { z } from "zod";
import { publicProcedure, router } from "../..";
import { getWorkspacePath } from "../workspaces/utils/worktree";

export { invalidatePortLabelCache } from "./label-cache";

type PortEvent =
| { type: "add"; port: DetectedPort }
| { type: "remove"; port: DetectedPort };
Expand Down Expand Up @@ -69,9 +71,10 @@ export const createPortsRouter = () => {
detected: true,
pid: port.pid,
processName: port.processName,
paneId: port.paneId,
terminalId: port.terminalId,
detectedAt: port.detectedAt,
address: port.address,
hostUrl: null,
};
});

Expand All @@ -89,9 +92,10 @@ export const createPortsRouter = () => {
detected: false,
pid: null,
processName: null,
paneId: null,
terminalId: null,
detectedAt: null,
address: null,
hostUrl: null,
});
}
}
Expand Down Expand Up @@ -122,7 +126,8 @@ export const createPortsRouter = () => {
kill: publicProcedure
.input(
z.object({
paneId: z.string(),
workspaceId: z.string(),
terminalId: z.string(),
port: z.number().int().positive(),
}),
)
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/trpc/routers/projects/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type { SimpleGitProgressEvent } from "simple-git";
import { z } from "zod";
import { publicProcedure, router } from "../..";
import { resolveDefaultEditor } from "../external";
import { invalidatePortLabelCache } from "../ports/label-cache";
import {
activateProject,
getBranchWorkspace,
Expand Down Expand Up @@ -1890,6 +1891,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
.delete(workspaces)
.where(inArray(workspaces.id, closedWorkspaceIds))
.run();
for (const id of closedWorkspaceIds) invalidatePortLabelCache(id);
}

localDb
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { and, desc, eq, isNotNull, isNull } from "drizzle-orm";

import { localDb } from "main/lib/local-db";
import { invalidatePortLabelCache } from "../../ports/label-cache";
import { computeNextProjectChildTabOrder } from "./project-children-order";

/**
Expand Down Expand Up @@ -273,6 +274,7 @@ export function clearWorkspaceDeletingStatus(workspaceId: string): void {
*/
export function deleteWorkspace(workspaceId: string): void {
localDb.delete(workspaces).where(eq(workspaces.id, workspaceId)).run();
invalidatePortLabelCache(workspaceId);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {
getProcessCommand,
getProcessName,
getProcessTree,
} from "main/lib/terminal/port-scanner";
} from "@superset/port-scanner";
import { getTerminalHostClient } from "main/lib/terminal-host/client";
import { bindingStore } from "../../../lib/trpc/routers/browser-automation/index";

Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src/main/lib/static-ports/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,23 @@ describe("loadStaticPorts", () => {
expect(result.error).toBe("ports[1].port must be an integer");
});

test("returns error when a port entry is duplicated", () => {
writeFileSync(
PORTS_FILE,
JSON.stringify({
ports: [
{ port: 3000, label: "Frontend" },
{ port: 3000, label: "Duplicate" },
],
}),
);

const result = loadStaticPorts(WORKTREE_PATH);
expect(result.exists).toBe(true);
expect(result.ports).toBeNull();
expect(result.error).toBe("ports[1].port duplicates an earlier entry");
});

test("accepts valid boundary port numbers", () => {
const config = {
ports: [
Expand Down
Loading
Loading