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
36 changes: 36 additions & 0 deletions apps/desktop/src/lib/trpc/routers/resource-metrics.schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,40 @@ describe("resourceMetricsSnapshotSchema", () => {
validSnapshot.host.totalMemory,
);
});

test("preserves optional terminal titles", () => {
const validSnapshot = createFallbackResourceMetricsSnapshot();
const snapshotWithTerminal = {
...validSnapshot,
workspaces: [
{
workspaceId: "workspace-1",
projectId: "project-1",
projectName: "Project",
workspaceName: "Workspace",
cpu: 1,
memory: 2,
sessions: [
{
sessionId: "terminal-1",
paneId: "terminal-1",
pid: 123,
title: "Claude Code",
cpu: 1,
memory: 2,
},
],
},
],
totalCpu: validSnapshot.app.cpu + 1,
totalMemory: validSnapshot.app.memory + 2,
};

const validated = validateResourceMetricsSnapshot(snapshotWithTerminal);

expect(validated.isValid).toBe(true);
expect(validated.snapshot.workspaces[0]?.sessions[0]?.title).toBe(
"Claude Code",
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const sessionMetricsSchema = usageValuesSchema.extend({
sessionId: zod.string().min(1),
paneId: zod.string().min(1),
pid: zod.number().int().min(0),
title: zod.string().nullable().optional(),
});

const workspaceMetricsSchema = usageValuesSchema.extend({
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/lib/trpc/routers/resource-metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const getSnapshotInputSchema = z
.object({
mode: z.enum(["interactive", "idle"]).optional(),
force: z.boolean().optional(),
surface: z.enum(["v1", "v2"]).optional(),
organizationId: z.string().optional(),
})
.optional();

Expand All @@ -22,6 +24,8 @@ export const createResourceMetricsRouter = () => {
const snapshot = await collectResourceMetrics({
mode: input?.mode,
force: input?.force,
surface: input?.surface,
organizationId: input?.organizationId,
});
const validation = validateResourceMetricsSnapshot(snapshot);
if (!validation.isValid) {
Expand Down
92 changes: 40 additions & 52 deletions apps/desktop/src/main/lib/resource-metrics/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import os from "node:os";
import { projects, workspaces } from "@superset/local-db";
import { eq } from "drizzle-orm";
import { app } from "electron";
import { localDb } from "main/lib/local-db";
import { getWorkspaceRuntimeRegistry } from "main/lib/workspace-runtime/registry";
import pidusage from "pidusage";
import {
captureProcessSnapshot,
Expand All @@ -12,6 +8,12 @@ import {
getSubtreeResources,
type ProcessSnapshot,
} from "./process-tree";
import { normalizeOptionalTitle } from "./session-normalization";
import {
collectWorkspaceSessionMap,
getWorkspaceMetadata,
type ResourceMetricsSurface,
} from "./session-sources";

interface ProcessMetrics {
cpu: number;
Expand All @@ -22,6 +24,7 @@ interface SessionMetrics {
sessionId: string;
paneId: string;
pid: number;
title: string | null;
cpu: number;
memory: number;
}
Expand Down Expand Up @@ -61,19 +64,20 @@ export interface ResourceMetricsSnapshot {
}

type SnapshotMode = "interactive" | "idle";

interface CollectResourceMetricsOptions {
mode?: SnapshotMode;
force?: boolean;
surface?: ResourceMetricsSurface;
organizationId?: string;
}

const SNAPSHOT_MAX_AGE_MS: Record<SnapshotMode, number> = {
interactive: 2500,
idle: 15000,
};

let cachedSnapshot: ResourceMetricsSnapshot | null = null;
let inflightCollection: Promise<ResourceMetricsSnapshot> | null = null;
const cachedSnapshots = new Map<string, ResourceMetricsSnapshot>();
const inflightCollections = new Map<string, Promise<ResourceMetricsSnapshot>>();

function normalizeFiniteNumber(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
Expand Down Expand Up @@ -135,6 +139,7 @@ function normalizeSnapshot(
sessionId: session.sessionId,
paneId: session.paneId,
pid: Math.max(0, Math.floor(normalizeFiniteNumber(session.pid))),
title: normalizeOptionalTitle(session.title),
cpu: normalizeFiniteNumber(session.cpu),
memory: normalizeFiniteNumber(session.memory),
}));
Expand Down Expand Up @@ -188,8 +193,11 @@ export async function collectResourceMetrics(
options: CollectResourceMetricsOptions = {},
): Promise<ResourceMetricsSnapshot> {
const mode = options.mode ?? "interactive";
const surface = options.surface ?? "v1";
const maxAgeMs = getSnapshotMaxAge(mode);
const cacheKey = `${surface}:${options.organizationId ?? "all"}`;

const cachedSnapshot = cachedSnapshots.get(cacheKey) ?? null;
if (!options.force && cachedSnapshot) {
const ageMs = Date.now() - cachedSnapshot.collectedAt;
if (ageMs <= maxAgeMs) {
Expand All @@ -198,11 +206,15 @@ export async function collectResourceMetrics(
}

// Avoid duplicate expensive process-tree scans for concurrent callers.
const inflightCollection = inflightCollections.get(cacheKey);
if (inflightCollection) {
return inflightCollection;
}

inflightCollection = collectResourceMetricsNow()
const collection = collectResourceMetricsNow({
surface,
organizationId: options.organizationId,
})
.catch((error) => {
console.warn(
"[resource-metrics] Failed to collect resource metrics; returning a safe fallback snapshot",
Expand All @@ -212,14 +224,15 @@ export async function collectResourceMetrics(
})
.then((snapshot) => {
const normalized = normalizeSnapshot(snapshot);
cachedSnapshot = normalized;
cachedSnapshots.set(cacheKey, normalized);
return normalized;
})
.finally(() => {
inflightCollection = null;
inflightCollections.delete(cacheKey);
});
inflightCollections.set(cacheKey, collection);

return inflightCollection;
return collection;
}

async function enrichSnapshotCpu(
Expand All @@ -241,32 +254,17 @@ async function enrichSnapshotCpu(
}
}

async function collectResourceMetricsNow(): Promise<ResourceMetricsSnapshot> {
const registry = getWorkspaceRuntimeRegistry();
const { sessions } = await registry
.getDefault()
.terminal.management.listSessions();

const workspaceSessionMap = new Map<
string,
Array<{ sessionId: string; paneId: string; pid: number }>
>();

for (const session of sessions) {
if (!session.isAlive || session.pid == null) continue;

let entries = workspaceSessionMap.get(session.workspaceId);
if (!entries) {
entries = [];
workspaceSessionMap.set(session.workspaceId, entries);
}
entries.push({
sessionId: session.sessionId,
paneId: session.paneId,
pid: session.pid,
});
}

async function collectResourceMetricsNow({
surface,
organizationId,
}: {
surface: ResourceMetricsSurface;
organizationId?: string;
}): Promise<ResourceMetricsSnapshot> {
const workspaceSessionMap = await collectWorkspaceSessionMap({
surface,
organizationId,
});
const allEntries = [...workspaceSessionMap.values()].flat();

// Single atomic snapshot: tree structure + resource data from one `ps`
Expand Down Expand Up @@ -340,21 +338,10 @@ async function collectResourceMetricsNow(): Promise<ResourceMetricsSnapshot> {

for (const [workspaceId, entries] of workspaceSessionMap) {
if (!workspaceMetaCache.has(workspaceId)) {
const ws = localDb
.select({
workspaceName: workspaces.name,
projectId: workspaces.projectId,
projectName: projects.name,
})
.from(workspaces)
.leftJoin(projects, eq(projects.id, workspaces.projectId))
.where(eq(workspaces.id, workspaceId))
.get();
workspaceMetaCache.set(workspaceId, {
workspaceName: ws?.workspaceName ?? "Unknown",
projectId: ws?.projectId ?? "unknown",
projectName: ws?.projectName ?? "Unknown Project",
});
workspaceMetaCache.set(
workspaceId,
getWorkspaceMetadata(surface, workspaceId),
);
}

const sessionMetrics: SessionMetrics[] = [];
Expand All @@ -371,6 +358,7 @@ async function collectResourceMetricsNow(): Promise<ResourceMetricsSnapshot> {
sessionId: entry.sessionId,
paneId: entry.paneId,
pid: entry.pid,
title: entry.title,
cpu: agg.cpu,
memory: agg.memory,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
export interface WorkspaceSessionEntry {
sessionId: string;
paneId: string;
pid: number;
title: string | null;
}

export type WorkspaceSessionMap = Map<string, WorkspaceSessionEntry[]>;

interface V2ResourceSessionPayload {
terminalId: unknown;
workspaceId: unknown;
pid: unknown;
title: unknown;
}

function toPositiveInteger(value: unknown): number | null {
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
return null;
}
return value;
}

export function normalizeOptionalTitle(value: unknown): string | null {
if (typeof value !== "string") return null;
const title = value.trim();
return title.length > 0 ? title : null;
}

export function parseV2ResourceSessions(payload: unknown): WorkspaceSessionMap {
const workspaceSessionMap: WorkspaceSessionMap = new Map();
const rawSessions =
payload &&
typeof payload === "object" &&
Array.isArray((payload as { sessions?: unknown }).sessions)
? (payload as { sessions: unknown[] }).sessions
: [];

for (const rawSession of rawSessions) {
if (!rawSession || typeof rawSession !== "object") continue;
const session = rawSession as V2ResourceSessionPayload;
if (typeof session.terminalId !== "string" || !session.terminalId) {
continue;
}
if (typeof session.workspaceId !== "string" || !session.workspaceId) {
continue;
}
const pid = toPositiveInteger(session.pid);
if (pid === null) continue;

let entries = workspaceSessionMap.get(session.workspaceId);
if (!entries) {
entries = [];
workspaceSessionMap.set(session.workspaceId, entries);
}
entries.push({
sessionId: session.terminalId,
paneId: session.terminalId,
pid,
title: normalizeOptionalTitle(session.title),
});
}

return workspaceSessionMap;
}
71 changes: 71 additions & 0 deletions apps/desktop/src/main/lib/resource-metrics/session-sources.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, test } from "bun:test";
import { parseV2ResourceSessions } from "./session-normalization";

describe("parseV2ResourceSessions", () => {
test("groups valid v2 sessions and normalizes titles", () => {
const sessions = parseV2ResourceSessions({
sessions: [
{
terminalId: "terminal-1",
workspaceId: "workspace-1",
pid: 123,
title: " Claude Code ",
},
{
terminalId: "terminal-2",
workspaceId: "workspace-1",
pid: 124,
title: " ",
},
],
});

expect(sessions.get("workspace-1")).toEqual([
{
sessionId: "terminal-1",
paneId: "terminal-1",
pid: 123,
title: "Claude Code",
},
{
sessionId: "terminal-2",
paneId: "terminal-2",
pid: 124,
title: null,
},
]);
});

test("rejects invalid v2 session identifiers and fractional PIDs", () => {
const sessions = parseV2ResourceSessions({
sessions: [
{
terminalId: "fractional",
workspaceId: "workspace-1",
pid: 123.5,
title: "Fractional",
},
{
terminalId: "zero",
workspaceId: "workspace-1",
pid: 0,
title: "Zero",
},
{
terminalId: "",
workspaceId: "workspace-1",
pid: 125,
title: "Missing terminal",
},
{
terminalId: "missing-workspace",
workspaceId: "",
pid: 126,
title: "Missing workspace",
},
],
});

expect(sessions.size).toBe(0);
});
});
Loading
Loading