fix: use live merge-base for task diff scope
The in-progress/in-review Changes tab was inflating file counts by preferring a stale task.baseCommitSha over the live merge-base with the base branch. Once upstream commits are merged into a feature branch, baseCommitSha..HEAD includes every upstream file as well, producing counts far larger than the branch's own changes. resolveDiffBase now prefers merge-base(HEAD, [origin/]baseBranch), falling back to baseCommitSha only when no merge-base is available or when the merge-base equals HEAD (task sitting on the base branch with no divergence, e.g. unit-test scenarios). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4435,16 +4435,27 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
*/
|
||||
async function resolveDiffBase(task: { baseCommitSha?: string; baseBranch?: string }, cwd: string): Promise<string | undefined> {
|
||||
const baseBranch = task.baseBranch ?? "main";
|
||||
let mergeBase: string | undefined;
|
||||
try {
|
||||
try {
|
||||
const base = (await runGitCommand(["merge-base", "HEAD", `origin/${baseBranch}`], cwd, 5000)).trim();
|
||||
if (base) return base;
|
||||
mergeBase = (await runGitCommand(["merge-base", "HEAD", `origin/${baseBranch}`], cwd, 5000)).trim() || undefined;
|
||||
} catch {
|
||||
const base = (await runGitCommand(["merge-base", "HEAD", baseBranch], cwd, 5000)).trim();
|
||||
if (base) return base;
|
||||
mergeBase = (await runGitCommand(["merge-base", "HEAD", baseBranch], cwd, 5000)).trim() || undefined;
|
||||
}
|
||||
} catch {
|
||||
// merge-base unavailable — base branch may no longer exist locally
|
||||
// base branch may no longer exist locally
|
||||
}
|
||||
|
||||
// If the merge-base equals HEAD, we're on the base branch with no feature
|
||||
// divergence — the live merge-base would give an empty diff, so prefer the
|
||||
// task-scoped baseCommitSha instead.
|
||||
if (mergeBase) {
|
||||
try {
|
||||
const head = (await runGitCommand(["rev-parse", "HEAD"], cwd, 5000)).trim();
|
||||
if (head && head !== mergeBase) return mergeBase;
|
||||
} catch {
|
||||
return mergeBase;
|
||||
}
|
||||
}
|
||||
|
||||
if (task.baseCommitSha) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import http from "node:http";
|
||||
import { createHmac } from "node:crypto";
|
||||
import express from "express";
|
||||
import { createServer, setupTerminalWebSocket } from "./server.js";
|
||||
import { toSessionTag } from "./terminal-websocket-diagnostics.js";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { get as performGet, request as performRequest } from "./test-request.js";
|
||||
|
||||
@@ -78,6 +79,35 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
function createTerminalLoggerHarness() {
|
||||
const terminalLogger = {
|
||||
scope: "server:terminal",
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
child: vi.fn(),
|
||||
};
|
||||
|
||||
terminalLogger.child.mockReturnValue(terminalLogger);
|
||||
|
||||
const runtimeLogger = {
|
||||
scope: "server",
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
child: vi.fn(),
|
||||
};
|
||||
|
||||
runtimeLogger.child.mockImplementation((scope: string) => {
|
||||
if (scope === "terminal") {
|
||||
return terminalLogger;
|
||||
}
|
||||
return runtimeLogger;
|
||||
});
|
||||
|
||||
return { runtimeLogger, terminalLogger };
|
||||
}
|
||||
|
||||
async function GET(app: ReturnType<typeof createServer>, path: string): Promise<{ status: number; body: unknown; headers: Record<string, unknown> }> {
|
||||
const res = await performGet(app, path);
|
||||
return res;
|
||||
@@ -475,15 +505,17 @@ describe("Terminal WebSocket heartbeat", () => {
|
||||
let app: ReturnType<typeof express>;
|
||||
let server: http.Server;
|
||||
let store: TaskStore;
|
||||
let runtimeLogger: ReturnType<typeof createTerminalLoggerHarness>["runtimeLogger"];
|
||||
let terminalLogger: ReturnType<typeof createTerminalLoggerHarness>["terminalLogger"];
|
||||
|
||||
beforeEach(() => {
|
||||
app = express();
|
||||
server = http.createServer(app);
|
||||
store = createMockStore();
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const loggerHarness = createTerminalLoggerHarness();
|
||||
runtimeLogger = loggerHarness.runtimeLogger;
|
||||
terminalLogger = loggerHarness.terminalLogger;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -520,8 +552,11 @@ describe("Terminal WebSocket heartbeat", () => {
|
||||
}
|
||||
|
||||
/** Setup terminal WebSocket and trigger a connection */
|
||||
function setupAndConnect(ws: any, req: any): void {
|
||||
const wss = setupTerminalWebSocket(app, server, store);
|
||||
function setupAndConnect(ws: any, req: any, options: Record<string, unknown> = {}): void {
|
||||
setupTerminalWebSocket(app, server, store, {
|
||||
runtimeLogger: runtimeLogger as any,
|
||||
...options,
|
||||
});
|
||||
|
||||
// The function sets up wss on the server's upgrade event.
|
||||
// We need to access the WebSocketServer directly to emit a connection.
|
||||
@@ -579,10 +614,26 @@ describe("Terminal WebSocket heartbeat", () => {
|
||||
// Don't send pong — missed pong #1
|
||||
vi.advanceTimersByTime(30000);
|
||||
expect(ws.terminate).not.toHaveBeenCalled();
|
||||
expect(terminalLogger.info).toHaveBeenCalledWith(
|
||||
"Missed terminal websocket pong",
|
||||
expect.objectContaining({
|
||||
sessionTag: toSessionTag("session-2"),
|
||||
missedPongs: 1,
|
||||
maxMissedPongs: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
// Don't send pong — missed pong #2: should terminate
|
||||
vi.advanceTimersByTime(30000);
|
||||
expect(ws.terminate).toHaveBeenCalled();
|
||||
expect(terminalLogger.warn).toHaveBeenCalledWith(
|
||||
"Terminating terminal websocket after missed pong threshold",
|
||||
expect.objectContaining({
|
||||
sessionTag: toSessionTag("session-2"),
|
||||
missedPongs: 2,
|
||||
maxMissedPongs: 2,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resets missed pong counter on successful pong", () => {
|
||||
@@ -620,14 +671,68 @@ describe("Terminal WebSocket heartbeat", () => {
|
||||
expect(ws.terminate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs warning for stale session reconnect", () => {
|
||||
it("logs structured error and closes 4510 when scoped store resolution fails", async () => {
|
||||
const ws = createMockWs();
|
||||
const req = createMockReq("stale-session");
|
||||
const req = {
|
||||
url: "/api/terminal/ws?sessionId=session-4510&projectId=proj-a",
|
||||
headers: { host: "localhost:3000" },
|
||||
};
|
||||
|
||||
const engineManager = {
|
||||
getEngine: vi.fn(() => {
|
||||
throw new Error("scope lookup failed");
|
||||
}),
|
||||
};
|
||||
|
||||
setupAndConnect(ws, req, { engineManager });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(ws.close).toHaveBeenCalledWith(4510, "Failed to resolve project scope");
|
||||
expect(terminalLogger.error).toHaveBeenCalledWith(
|
||||
"Failed to resolve project scope",
|
||||
expect.objectContaining({
|
||||
projectId: "proj-a",
|
||||
error: "scope lookup failed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("logs redacted cwd mismatch context and closes 4503", () => {
|
||||
const ws = createMockWs();
|
||||
const req = createMockReq("cross-project-session-1");
|
||||
|
||||
mockTerminalService.getSession.mockReturnValue({
|
||||
id: "cross-project-session-1",
|
||||
shell: "/bin/bash",
|
||||
cwd: "/Users/alice/private/other-project",
|
||||
lastActivityAt: new Date(),
|
||||
});
|
||||
|
||||
setupAndConnect(ws, req);
|
||||
|
||||
expect(ws.close).toHaveBeenCalledWith(4503, "Session does not belong to this project");
|
||||
expect(terminalLogger.warn).toHaveBeenCalledWith(
|
||||
"Rejected terminal session outside scoped project root",
|
||||
expect.objectContaining({
|
||||
sessionTag: toSessionTag("cross-project-session-1"),
|
||||
sessionCwdHint: expect.stringContaining("<redacted>/"),
|
||||
scopedRootHint: expect.stringContaining("<redacted>/"),
|
||||
}),
|
||||
);
|
||||
expect(terminalLogger.warn).not.toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ sessionCwd: "/Users/alice/private/other-project" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("logs structured warning for stale session reconnect with bounded context", () => {
|
||||
const ws = createMockWs();
|
||||
const req = createMockReq("stale-session-123456");
|
||||
|
||||
// Session last active 10 minutes ago (past the 5-minute threshold)
|
||||
const tenMinutesAgo = new Date(Date.now() - 600_000);
|
||||
mockTerminalService.getSession.mockReturnValue({
|
||||
id: "stale-session",
|
||||
id: "stale-session-123456",
|
||||
shell: "/bin/bash",
|
||||
cwd: "/fake/root",
|
||||
lastActivityAt: tenMinutesAgo,
|
||||
@@ -635,11 +740,16 @@ describe("Terminal WebSocket heartbeat", () => {
|
||||
|
||||
setupAndConnect(ws, req);
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("stale-session"),
|
||||
expect(terminalLogger.warn).toHaveBeenCalledWith(
|
||||
"Terminal reconnect may target stale PTY session",
|
||||
expect.objectContaining({
|
||||
sessionTag: toSessionTag("stale-session-123456"),
|
||||
idleMs: 600_000,
|
||||
}),
|
||||
);
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("PTY may be stale"),
|
||||
expect(terminalLogger.warn).not.toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ sessionId: "stale-session-123456" }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -657,8 +767,9 @@ describe("Terminal WebSocket heartbeat", () => {
|
||||
|
||||
setupAndConnect(ws, req);
|
||||
|
||||
expect(console.warn).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("PTY may be stale"),
|
||||
expect(terminalLogger.warn).not.toHaveBeenCalledWith(
|
||||
"Terminal reconnect may target stale PTY session",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -667,7 +778,8 @@ describe("Terminal stale-session eviction", () => {
|
||||
let app: ReturnType<typeof express>;
|
||||
let server: http.Server;
|
||||
let store: TaskStore;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let runtimeLogger: ReturnType<typeof createTerminalLoggerHarness>["runtimeLogger"];
|
||||
let terminalLogger: ReturnType<typeof createTerminalLoggerHarness>["terminalLogger"];
|
||||
|
||||
beforeEach(() => {
|
||||
app = express();
|
||||
@@ -675,8 +787,9 @@ describe("Terminal stale-session eviction", () => {
|
||||
store = createMockStore();
|
||||
vi.useFakeTimers();
|
||||
mockTerminalService.evictStaleSessions.mockReset().mockReturnValue(0);
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const loggerHarness = createTerminalLoggerHarness();
|
||||
runtimeLogger = loggerHarness.runtimeLogger;
|
||||
terminalLogger = loggerHarness.terminalLogger;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -686,36 +799,35 @@ describe("Terminal stale-session eviction", () => {
|
||||
});
|
||||
|
||||
it("calls evictStaleSessions on each 60s interval tick", () => {
|
||||
setupTerminalWebSocket(app, server, store);
|
||||
setupTerminalWebSocket(app, server, store, { runtimeLogger: runtimeLogger as any });
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(mockTerminalService.evictStaleSessions).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(mockTerminalService.evictStaleSessions).toHaveBeenCalledTimes(2);
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("Stale session eviction failed"),
|
||||
expect(terminalLogger.error).not.toHaveBeenCalledWith(
|
||||
"Stale session eviction failed",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("logs error and continues when evictStaleSessions throws", () => {
|
||||
it("logs structured error and continues when evictStaleSessions throws", () => {
|
||||
mockTerminalService.evictStaleSessions.mockImplementation(() => {
|
||||
throw new Error("simulated eviction failure");
|
||||
});
|
||||
|
||||
setupTerminalWebSocket(app, server, store);
|
||||
setupTerminalWebSocket(app, server, store, { runtimeLogger: runtimeLogger as any });
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
const failureCall = consoleErrorSpy.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("[terminal] Stale session eviction failed"),
|
||||
);
|
||||
expect(failureCall).toBeDefined();
|
||||
expect(failureCall?.[0]).toEqual(expect.stringContaining("[terminal] Stale session eviction failed"));
|
||||
expect(failureCall?.[1]).toEqual(
|
||||
expect.objectContaining({ error: "simulated eviction failure" }),
|
||||
expect(terminalLogger.error).toHaveBeenCalledWith(
|
||||
"Stale session eviction failed",
|
||||
expect.objectContaining({
|
||||
error: "simulated eviction failure",
|
||||
errorName: "Error",
|
||||
errorMessage: "simulated eviction failure",
|
||||
}),
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
@@ -723,7 +835,7 @@ describe("Terminal stale-session eviction", () => {
|
||||
});
|
||||
|
||||
it("stops eviction interval when server closes", () => {
|
||||
setupTerminalWebSocket(app, server, store);
|
||||
setupTerminalWebSocket(app, server, store, { runtimeLogger: runtimeLogger as any });
|
||||
|
||||
server.emit("close");
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { WebSocketManager, type BadgeSnapshot } from "./websocket.js";
|
||||
import type { BadgePubSub } from "./badge-pubsub.js";
|
||||
import { createBadgePubSub, type BadgePubSubMessage } from "./badge-pubsub.js";
|
||||
import { createRuntimeLogger, type RuntimeLogger } from "./runtime-logger.js";
|
||||
import { createTerminalWebSocketDiagnostics } from "./terminal-websocket-diagnostics.js";
|
||||
import {
|
||||
AiSessionStore,
|
||||
SESSION_CLEANUP_DEFAULT_MAX_AGE_MS,
|
||||
@@ -975,7 +976,7 @@ export function setupTerminalWebSocket(
|
||||
|
||||
// Resolve the daemon token once so every upgrade picks up the same value.
|
||||
const wsDaemonToken = getDaemonToken(options);
|
||||
const terminalLogger = options?.runtimeLogger?.child("terminal") ?? createRuntimeLogger("terminal");
|
||||
const terminalDiagnostics = createTerminalWebSocketDiagnostics(options?.runtimeLogger);
|
||||
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
const pathname = new URL(req.url || "", `http://${req.headers.host}`).pathname;
|
||||
@@ -1028,8 +1029,9 @@ export function setupTerminalWebSocket(
|
||||
terminalService = getTerminalService(scopedRootDir);
|
||||
}
|
||||
} catch (err) {
|
||||
terminalLogger.error("Failed to resolve project scope", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
terminalDiagnostics.scopeResolutionFailed({
|
||||
projectId,
|
||||
error: err,
|
||||
});
|
||||
ws.close(4510, "Failed to resolve project scope");
|
||||
return;
|
||||
@@ -1044,7 +1046,12 @@ export function setupTerminalWebSocket(
|
||||
// Security check: reject sessions that don't belong to this project's root
|
||||
// Session cwd must be within the resolved project root
|
||||
if (!session.cwd.startsWith(scopedRootDir)) {
|
||||
terminalLogger.warn(`Session ${sessionId} cwd ${session.cwd} does not belong to project root ${scopedRootDir}`);
|
||||
terminalDiagnostics.crossProjectCwdRejected({
|
||||
sessionId,
|
||||
projectId,
|
||||
sessionCwd: session.cwd,
|
||||
scopedRootDir,
|
||||
});
|
||||
ws.close(4503, "Session does not belong to this project");
|
||||
return;
|
||||
}
|
||||
@@ -1060,9 +1067,11 @@ export function setupTerminalWebSocket(
|
||||
// Detect potentially stale sessions on reconnect
|
||||
const idleMs = Date.now() - session.lastActivityAt.getTime();
|
||||
if (idleMs > STALE_SESSION_THRESHOLD_MS) {
|
||||
terminalLogger.warn(
|
||||
`Session ${sessionId} reconnect after ${Math.round(idleMs / 1000)}s idle — PTY may be stale`,
|
||||
);
|
||||
terminalDiagnostics.staleReconnect({
|
||||
sessionId,
|
||||
idleMs,
|
||||
staleThresholdMs: STALE_SESSION_THRESHOLD_MS,
|
||||
});
|
||||
}
|
||||
|
||||
// Send scrollback buffer first
|
||||
@@ -1095,7 +1104,11 @@ export function setupTerminalWebSocket(
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: "exit", exitCode }));
|
||||
const idleSec = id ? Math.round((Date.now() - (terminalService.getSession(id)?.lastActivityAt?.getTime() ?? Date.now())) / 1000) : 0;
|
||||
terminalLogger.info(`Session ${id} exited with code ${exitCode} (was ${idleSec}s idle)`);
|
||||
terminalDiagnostics.ptyExit({
|
||||
sessionId: id,
|
||||
exitCode,
|
||||
idleSeconds: idleSec,
|
||||
});
|
||||
} catch {
|
||||
// WebSocket might be closing
|
||||
}
|
||||
@@ -1107,11 +1120,19 @@ export function setupTerminalWebSocket(
|
||||
if (!isAlive) {
|
||||
missedPongs++;
|
||||
if (missedPongs >= MAX_MISSED_PONGS) {
|
||||
terminalLogger.warn(`Connection dead after ${missedPongs} missed pongs, terminating`);
|
||||
terminalDiagnostics.heartbeatTerminating({
|
||||
sessionId,
|
||||
missedPongs,
|
||||
maxMissedPongs: MAX_MISSED_PONGS,
|
||||
});
|
||||
ws.terminate();
|
||||
return;
|
||||
}
|
||||
terminalLogger.info(`Missed pong #${missedPongs}, waiting for response...`);
|
||||
terminalDiagnostics.heartbeatMissed({
|
||||
sessionId,
|
||||
missedPongs,
|
||||
maxMissedPongs: MAX_MISSED_PONGS,
|
||||
});
|
||||
return;
|
||||
}
|
||||
isAlive = false;
|
||||
@@ -1184,9 +1205,7 @@ export function setupTerminalWebSocket(
|
||||
try {
|
||||
defaultTerminalService.evictStaleSessions();
|
||||
} catch (err) {
|
||||
terminalLogger.error("Stale session eviction failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
terminalDiagnostics.staleEvictionFailed({ error: err });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
@@ -1195,7 +1214,7 @@ export function setupTerminalWebSocket(
|
||||
clearInterval(staleEvictionInterval);
|
||||
});
|
||||
|
||||
terminalLogger.info("WebSocket server mounted at /api/terminal/ws");
|
||||
terminalDiagnostics.mounted({ path: "/api/terminal/ws" });
|
||||
}
|
||||
|
||||
export function setupBadgeWebSocket(
|
||||
|
||||
130
packages/dashboard/src/terminal-websocket-diagnostics.ts
Normal file
130
packages/dashboard/src/terminal-websocket-diagnostics.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { basename, normalize, sep } from "node:path";
|
||||
import { createRuntimeLogger, type RuntimeLogger } from "./runtime-logger.js";
|
||||
|
||||
export interface TerminalWebSocketDiagnostics {
|
||||
scopeResolutionFailed(context: { projectId?: string; error: unknown }): void;
|
||||
crossProjectCwdRejected(context: {
|
||||
sessionId: string;
|
||||
projectId?: string;
|
||||
sessionCwd: string;
|
||||
scopedRootDir: string;
|
||||
}): void;
|
||||
staleReconnect(context: { sessionId: string; idleMs: number; staleThresholdMs: number }): void;
|
||||
heartbeatMissed(context: { sessionId: string; missedPongs: number; maxMissedPongs: number }): void;
|
||||
heartbeatTerminating(context: { sessionId: string; missedPongs: number; maxMissedPongs: number }): void;
|
||||
ptyExit(context: { sessionId: string; exitCode: number | null; idleSeconds: number }): void;
|
||||
staleEvictionFailed(context: { error: unknown }): void;
|
||||
mounted(context: { path: string }): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a bounded identifier suitable for diagnostics.
|
||||
*
|
||||
* Contract:
|
||||
* - Never emits the full raw session id
|
||||
* - Preserves enough entropy to correlate events in a single incident
|
||||
* - Output format is stable for test assertions
|
||||
*/
|
||||
export function toSessionTag(sessionId: string): string {
|
||||
const normalized = sessionId.trim();
|
||||
if (normalized.length <= 8) {
|
||||
return `${normalized}#${normalized.length}`;
|
||||
}
|
||||
return `${normalized.slice(0, 4)}…${normalized.slice(-4)}#${normalized.length}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redacts an absolute filesystem path while preserving mismatch debugging clues.
|
||||
*
|
||||
* Contract:
|
||||
* - Removes leading absolute path segments
|
||||
* - Includes only the last two segments + depth metadata
|
||||
* - Stable string formatting for test assertions
|
||||
*/
|
||||
export function toRedactedPathHint(pathValue: string): string {
|
||||
const normalized = normalize(pathValue);
|
||||
const segments = normalized.split(sep).filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
return "<empty>";
|
||||
}
|
||||
|
||||
const tail = segments.slice(-2).join("/");
|
||||
return `<redacted>/${tail} (depth:${segments.length})`;
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): Record<string, unknown> {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
error: error.message,
|
||||
errorName: error.name,
|
||||
errorMessage: error.message,
|
||||
errorStack: error.stack,
|
||||
};
|
||||
}
|
||||
const fallback = String(error);
|
||||
return {
|
||||
error: fallback,
|
||||
errorMessage: fallback,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTerminalWebSocketDiagnostics(runtimeLogger?: RuntimeLogger): TerminalWebSocketDiagnostics {
|
||||
const logger = runtimeLogger?.child("terminal") ?? createRuntimeLogger("terminal");
|
||||
|
||||
return {
|
||||
scopeResolutionFailed({ projectId, error }) {
|
||||
logger.error("Failed to resolve project scope", {
|
||||
projectId,
|
||||
...normalizeError(error),
|
||||
});
|
||||
},
|
||||
crossProjectCwdRejected({ sessionId, projectId, sessionCwd, scopedRootDir }) {
|
||||
logger.warn("Rejected terminal session outside scoped project root", {
|
||||
sessionTag: toSessionTag(sessionId),
|
||||
projectId,
|
||||
sessionCwdHint: toRedactedPathHint(sessionCwd),
|
||||
scopedRootHint: toRedactedPathHint(scopedRootDir),
|
||||
sessionCwdBase: basename(sessionCwd),
|
||||
scopedRootBase: basename(scopedRootDir),
|
||||
});
|
||||
},
|
||||
staleReconnect({ sessionId, idleMs, staleThresholdMs }) {
|
||||
logger.warn("Terminal reconnect may target stale PTY session", {
|
||||
sessionTag: toSessionTag(sessionId),
|
||||
idleMs,
|
||||
staleThresholdMs,
|
||||
});
|
||||
},
|
||||
heartbeatMissed({ sessionId, missedPongs, maxMissedPongs }) {
|
||||
logger.info("Missed terminal websocket pong", {
|
||||
sessionTag: toSessionTag(sessionId),
|
||||
missedPongs,
|
||||
maxMissedPongs,
|
||||
});
|
||||
},
|
||||
heartbeatTerminating({ sessionId, missedPongs, maxMissedPongs }) {
|
||||
logger.warn("Terminating terminal websocket after missed pong threshold", {
|
||||
sessionTag: toSessionTag(sessionId),
|
||||
missedPongs,
|
||||
maxMissedPongs,
|
||||
});
|
||||
},
|
||||
ptyExit({ sessionId, exitCode, idleSeconds }) {
|
||||
logger.info("Terminal PTY exited", {
|
||||
sessionTag: toSessionTag(sessionId),
|
||||
exitCode,
|
||||
idleSeconds,
|
||||
});
|
||||
},
|
||||
staleEvictionFailed({ error }) {
|
||||
logger.error("Stale session eviction failed", {
|
||||
...normalizeError(error),
|
||||
});
|
||||
},
|
||||
mounted({ path }) {
|
||||
logger.info("WebSocket server mounted", {
|
||||
path,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user