From 17496cc56a0c584d9dc1356bbb0bb5c02a7b4a68 Mon Sep 17 00:00:00 2001 From: willsqow Date: Tue, 6 Oct 2026 12:25:29 +0800 Subject: [PATCH] Report remote session discovery and notification submission failures --- README.md | 7 +++ extension.js | 91 +++++++++++++++++++++++----- package.json | 2 +- scripts/runtime-harness.js | 96 ++++++++++++++++++++++++++++++ scripts/test-remote-diagnostics.js | 75 +++++++++++++++++++++++ 5 files changed, 254 insertions(+), 17 deletions(-) create mode 100644 scripts/runtime-harness.js create mode 100644 scripts/test-remote-diagnostics.js diff --git a/README.md b/README.md index f66795a..c1b38d1 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,13 @@ On Windows, v0.1.12 enables toast window focus automatically. The extension reco ### Remote SSH Workspaces +For a workspace outside a recognizable home directory (for example, +`/data/alice/project`), configure `codexNotifier.remoteSessionsPath` with the +absolute remote sessions directory. An unknown home or invalid remote path no +longer falls back to local sessions. `Show Diagnostics` reports bounded, +throttled metadata about discovery and read errors. Notification submission +status means the OS API accepted the request, not that a banner was displayed. + Codex Notifier remains a single extension running in VS Code's local UI extension host, so sounds and notifications are produced on your computer. In a Remote SSH window it reads `~/.codex/sessions/**/*.jsonl` through VS Code's diff --git a/extension.js b/extension.js index 34b2fc4..257f53f 100644 --- a/extension.js +++ b/extension.js @@ -37,6 +37,25 @@ let codexInputRequestTurnIds = new Set(); let codexSessionInfo = new Map(); let statusItem = null; let statusTimer = null; +const diagnosticEvents = new Map(); +let lastNotificationAttemptAt = 0; +let lastNotificationResult = "none"; + +// Keep bounded metadata only. Never include notification text or transcript bodies. +function recordDiagnostic(code, source, detail) { + const key = `${code}:${source}`; + const previous = diagnosticEvents.get(key); + const now = Date.now(); + const event = { code, source, detail, at: fmtTs(now), count: (previous?.count || 0) + 1 }; + event.loggedAt = previous?.loggedAt || 0; + if (!previous || now - event.loggedAt >= 60000) { + getOutput().appendLine(`[${event.at}] ${code}: ${source} ${detail}`); + event.loggedAt = now; + } + diagnosticEvents.delete(key); + diagnosticEvents.set(key, event); + if (diagnosticEvents.size > 30) diagnosticEvents.delete(diagnosticEvents.keys().next().value); +} function claimNotification(key) { const digest = crypto.createHash("sha256").update(String(key)).digest("hex"); @@ -83,7 +102,9 @@ function getDiagnosticsSummary() { ignoredSessionFiles: [...codexSessionInfo.values()].filter((info) => !info.eligible).length, completedTurnIds: codexCompletedTurnIds.size, inputRequestTurnIds: codexInputRequestTurnIds.size, - lastNotifyAt: fmtTs(lastCodexNotifyAt) + lastNotifyAt: fmtTs(lastCodexNotifyAt), + lastNotificationAttemptAt: fmtTs(lastNotificationAttemptAt), + lastNotificationResult }; } @@ -146,6 +167,7 @@ function showWindowsToast(title, message, launchUri = "") { const xml = `${escXml(title)}${escXml(message)}${actionXml}`; const escapedXml = xml.replace(/'/g, "''"); const script = [ + "$ErrorActionPreference = 'Stop';", "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null;", "[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null;", `$xml = '${escapedXml}';`, @@ -156,15 +178,29 @@ function showWindowsToast(title, message, launchUri = "") { "$notifier.Show($toast);" ].join(" "); - execFile("powershell.exe", ["-NoProfile", "-Command", script], () => resolve()); + execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], + { timeout: 10000 }, (error) => { + resolve(error ? { status: "failed", code: String(error.code || error.name) } : { status: "submitted" }); + }); }); } // Cross-platform system notification helper for when VS Code is not focused. function showSystemNotification(title, message, launchUri = "") { + lastNotificationAttemptAt = Date.now(); + lastNotificationResult = "pending"; + const complete = (result) => { + lastNotificationResult = result.status; + if (result.status === "failed") { + recordDiagnostic("system-notification-failed", process.platform, `code=${result.code}`); + } + }; return new Promise((resolve) => { if (process.platform === "win32") { - void showWindowsToast(title, message, launchUri).finally(resolve); + void showWindowsToast(title, message, launchUri).then((result) => { + complete(result); + resolve(); + }); return; } @@ -172,16 +208,23 @@ function showSystemNotification(title, message, launchUri = "") { execFile( "osascript", ["-e", `display notification ${JSON.stringify(message)} with title ${JSON.stringify(title)}`], - () => resolve() + (error) => { + complete(error ? { status: "failed", code: String(error.code || error.name) } : { status: "submitted" }); + resolve(); + } ); return; } if (process.platform === "linux") { - execFile("notify-send", [title, message], () => resolve()); + execFile("notify-send", [title, message], (error) => { + complete(error ? { status: "failed", code: String(error.code || error.name) } : { status: "submitted" }); + resolve(); + }); return; } + lastNotificationResult = "unsupported-platform"; resolve(); }); } @@ -516,6 +559,7 @@ async function notify(kind, message, _options = {}) { await new Promise((resolve) => setTimeout(resolve, Math.max(150, workspacePriorityDelay))); } if (!claimNotification(_options.dedupeKey)) { + recordDiagnostic("notification-deduplicated", _options.dedupeKey, "Already claimed by another window or turn."); logDebug(`duplicate notification suppressed key=${_options.dedupeKey}`); return; } @@ -646,10 +690,18 @@ function getRemoteSessionRoots() { const roots = []; const seen = new Set(); for (const uri of getRemoteSourceUris()) { + const homePath = inferRemoteHomePath(uri.path); + if (!configuredPath && !homePath) { + recordDiagnostic("remote-home-unknown", uri.toString(), "Set remoteSessionsPath to the absolute remote sessions directory."); + continue; + } const sessionsPath = configuredPath ? configuredPath - : `${inferRemoteHomePath(uri.path) || ""}/.codex/sessions`; - if (!sessionsPath.startsWith("/")) continue; + : `${homePath}/.codex/sessions`; + if (!sessionsPath.startsWith("/")) { + recordDiagnostic("remote-path-invalid", uri.authority || "remote", "remoteSessionsPath must be an absolute POSIX path, not ~ or a Windows path."); + continue; + } const root = uri.with({ path: sessionsPath, query: "", fragment: "" }); const key = root.toString(); if (!seen.has(key)) { @@ -721,7 +773,8 @@ async function remoteDirectoriesAt(root) { .filter(([, type]) => type === vscode.FileType.Directory) .map(([name]) => name) .sort(); - } catch { + } catch (error) { + recordDiagnostic("remote-directory-read-failed", root.toString(), String(error.code || error.name || "unknown")); return []; } } @@ -738,7 +791,8 @@ async function findRemoteCodexSessionFiles() { let entries = []; try { entries = await vscode.workspace.fs.readDirectory(dayDir); - } catch { + } catch (error) { + recordDiagnostic("remote-directory-read-failed", dayDir.toString(), String(error.code || error.name || "unknown")); continue; } for (const [name, type] of entries) { @@ -752,7 +806,8 @@ async function findRemoteCodexSessionFiles() { uri, mtimeMs: stat.mtime }); - } catch { + } catch (error) { + recordDiagnostic("remote-file-stat-failed", uri.toString(), String(error.code || error.name || "unknown")); // File may be rotated between listing and stat. } } @@ -768,7 +823,10 @@ async function findAllCodexSessionFiles() { if (now - codexLogFilesCacheAt < 5000) return codexLogFilesCache; const remoteRoots = getRemoteSessionRoots(); - const candidates = remoteRoots.length > 0 + // A remote workspace with an invalid/missing path must not silently monitor + // unrelated sessions on the local UI machine. + const isRemoteWorkspace = getRemoteSourceUris().length > 0 || Boolean(vscode.env.remoteName); + const candidates = remoteRoots.length > 0 || isRemoteWorkspace ? await findRemoteCodexSessionFiles() : findLocalCodexSessionFiles(); @@ -801,7 +859,8 @@ async function getDetailedDiagnosticsSummary() { .map((source) => ({ source: source.key, reason: codexSessionInfo.get(source.key).reason - })) + })), + recentDiagnostics: [...diagnosticEvents.values()].map(({ loggedAt: _loggedAt, ...event }) => event) }; } catch (error) { return { @@ -1063,7 +1122,7 @@ function startCodexLogWatcher() { const workspaceMatch = isSessionInCurrentWorkspace(sessionInfo); sessionSources.push({ source, stat, sessionInfo, workspaceMatch }); } catch (error) { - logDebug(`session read failed source=${source.key} error=${String(error)}`); + recordDiagnostic("session-read-failed", source.key, String(error.code || error.name || "unknown")); } } @@ -1098,11 +1157,11 @@ function startCodexLogWatcher() { codexLogOffsets.set(source.key, stat.size); await processCodexSessionChunk(chunk, source, sessionInfo, stat.size, historyNotBeforeMs, workspaceMatch); } catch (error) { - logDebug(`session read failed source=${source.key} error=${String(error)}`); + recordDiagnostic("session-read-failed", source.key, String(error.code || error.name || "unknown")); } } } catch (error) { - logDebug(`session discovery failed error=${String(error)}`); + recordDiagnostic("session-discovery-failed", "sessions", String(error.code || error.name || "unknown")); } finally { codexLogPollBusy = false; } @@ -1424,7 +1483,7 @@ function activate(context) { const summary = await getDetailedDiagnosticsSummary(); const out = getOutput(); out.appendLine("===== Diagnostics ====="); - Object.entries(summary).forEach(([k, v]) => out.appendLine(`${k}: ${v}`)); + Object.entries(summary).forEach(([k, v]) => out.appendLine(`${k}: ${JSON.stringify(v, null, 2)}`)); out.show(true); vscode.window.showInformationMessage( `Codex Notifier: sessions=${summary.trackedSessionFiles} completed=${summary.completedTurnIds} lastNotify=${summary.lastNotifyAt}` diff --git a/package.json b/package.json index 686897a..0283606 100644 --- a/package.json +++ b/package.json @@ -171,7 +171,7 @@ "ci": "npm run check && npm run lint && npm test && npm run package:test", "package:test": "node ./scripts/test-vsix.js", "release:check": "npm run check && npm run lint && npm test && npm run package:test", - "test": "node ./scripts/test-session-events.js && node ./scripts/test-toast-focus.js" + "test": "node ./scripts/test-session-events.js && node ./scripts/test-toast-focus.js && node ./scripts/test-remote-diagnostics.js" }, "devDependencies": { "adm-zip": "^0.5.16", diff --git a/scripts/runtime-harness.js b/scripts/runtime-harness.js new file mode 100644 index 0000000..dabb367 --- /dev/null +++ b/scripts/runtime-harness.js @@ -0,0 +1,96 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const { createRequire } = require("module"); +const root = path.resolve(__dirname, ".."); + +class Uri { + constructor(uriPath, authority = "ssh-remote+test", scheme = "vscode-remote") { + this.path = uriPath; + this.fsPath = uriPath; + this.authority = authority; + this.scheme = scheme; + } + with(values) { return Object.assign(new Uri(this.path, this.authority, this.scheme), values); } + toString() { return `${this.scheme}://${this.authority}${this.path}`; } +} + +function createHarness({ platform = "win32", focused = false, settings = {}, locks = new Set(), remotePath } = {}) { + const config = { focusExistingWindowOnToast: false, enableSound: false, completionUseBanner: true, ...settings }; + const lines = []; + const notifications = []; + const localReads = []; + const directories = new Map(); + const files = new Map(); + let notificationError; + const vscode = { + env: { remoteName: remotePath ? "ssh-remote" : undefined, sessionId: "test-window" }, + FileType: { File: 1, Directory: 2 }, + Uri: { joinPath: (uri, ...parts) => uri.with({ path: path.posix.join(uri.path, ...parts) }) }, + workspace: { + workspaceFolders: remotePath ? [{ uri: new Uri(remotePath) }] : [], + textDocuments: [], + getConfiguration: () => ({ get: (key, fallback) => key in config ? config[key] : fallback }), + fs: { + readDirectory: async (uri) => { + const entries = directories.get(uri.path); + if (entries instanceof Error) throw entries; + return entries || []; + }, + readFile: async (uri) => Buffer.from(files.get(uri.path) || ""), + stat: async (uri) => ({ size: Buffer.byteLength(files.get(uri.path) || ""), mtime: Date.now() }) + } + }, + window: { + state: { focused }, visibleTextEditors: [], + createOutputChannel: () => ({ appendLine: (line) => lines.push(line), show: () => {} }), + showInformationMessage: (message) => lines.push(`banner:${message}`) + } + }; + const module = { exports: {} }; + const localRequire = createRequire(path.join(root, "extension.js")); + const runtimeFs = { + openSync: (file, mode) => { + if (mode === "wx" && locks.has(file)) throw Object.assign(new Error("exists"), { code: "EEXIST" }); + locks.add(file); + return 1; + }, + writeFileSync: () => {}, closeSync: () => {}, + statSync: () => ({ mtimeMs: Date.now() }), existsSync: () => false, + readdirSync: (dir) => { localReads.push(dir); return []; } + }; + const source = fs.readFileSync(path.join(root, "extension.js"), "utf8"); + const expose = `\nmodule.exports.testing = { + getRemoteSessionRoots, findAllCodexSessionFiles, getDetailedDiagnosticsSummary, + remoteDirectoriesAt, notify, showSystemNotification, recordDiagnostic, + classifySessionMetadata, processCodexSessionChunk, getDiagnosticsSummary + };`; + const context = { + require: (name) => { + if (name === "vscode") return vscode; + if (name === "fs") return runtimeFs; + if (name === "os") return { tmpdir: () => "/test-temp", homedir: () => "/test-home" }; + if (name === "child_process") return { + execFile: (_command, args, options, callback) => { + const cb = typeof options === "function" ? options : callback; + if (args.join(" ").includes("ForegroundWindow")) cb(null, focused ? "321" : "999"); + else { notifications.push(args); cb(notificationError); } + } + }; + return localRequire(name); + }, + module, exports: module.exports, Buffer, Date, console, + process: { platform, ppid: 321 }, __dirname: root, + setTimeout: (callback) => { callback(); return 1; }, clearTimeout: () => {}, + setInterval: () => 1, clearInterval: () => {} + }; + vm.runInNewContext(source + expose, context, { filename: "extension.js" }); + return { + api: module.exports.testing, config, lines, notifications, localReads, directories, files, + setNotificationError: (error) => { notificationError = error; } + }; +} + +module.exports = { createHarness, Uri }; diff --git a/scripts/test-remote-diagnostics.js b/scripts/test-remote-diagnostics.js new file mode 100644 index 0000000..eb07ee9 --- /dev/null +++ b/scripts/test-remote-diagnostics.js @@ -0,0 +1,75 @@ +"use strict"; + +const assert = require("assert"); +const { createHarness, Uri } = require("./runtime-harness"); + +async function main() { + const unknown = createHarness({ remotePath: "/data/alice/project" }); + assert.strictEqual(unknown.api.getRemoteSessionRoots().length, 0); + assert.strictEqual((await unknown.api.findAllCodexSessionFiles()).length, 0); + assert.strictEqual(unknown.localReads.length, 0, "invalid remote discovery must not fall back to local sessions"); + const summary = await unknown.api.getDetailedDiagnosticsSummary(); + assert.strictEqual(summary.recentDiagnostics[0].code, "remote-home-unknown"); + assert(!JSON.stringify(summary).includes("/.codex/sessions")); + assert.strictEqual(unknown.lines.length, 1, "repeated discovery errors must be throttled"); + + const standard = createHarness({ remotePath: "/home/alice/project" }); + assert.strictEqual(standard.api.getRemoteSessionRoots()[0].path, "/home/alice/.codex/sessions"); + const root = createHarness({ remotePath: "/root/project" }); + assert.strictEqual(root.api.getRemoteSessionRoots()[0].path, "/root/.codex/sessions"); + + const explicit = createHarness({ remotePath: "/data/alice/project", settings: { remoteSessionsPath: "/storage/codex/sessions" } }); + assert.strictEqual(explicit.api.getRemoteSessionRoots()[0].path, "/storage/codex/sessions"); + explicit.directories.set("/storage/codex/sessions", [["2026", 2]]); + explicit.directories.set("/storage/codex/sessions/2026", [["10", 2]]); + explicit.directories.set("/storage/codex/sessions/2026/10", [["06", 2]]); + explicit.directories.set("/storage/codex/sessions/2026/10/06", [["session.jsonl", 1]]); + const sources = await explicit.api.findAllCodexSessionFiles(); + assert.strictEqual(sources.length, 1); + assert.strictEqual(sources[0].kind, "remote"); + + for (const invalidPath of ["~/.codex/sessions", "relative/sessions", "C:\\codex\\sessions"]) { + const invalid = createHarness({ remotePath: "/home/alice/project", settings: { remoteSessionsPath: invalidPath } }); + assert.strictEqual(invalid.api.getRemoteSessionRoots().length, 0); + assert.strictEqual((await invalid.api.getDetailedDiagnosticsSummary()).recentDiagnostics[0].code, "remote-path-invalid"); + } + const missing = createHarness(); + missing.directories.set("/missing", Object.assign(new Error("private body must not be logged"), { code: "FileNotFound" })); + await missing.api.remoteDirectoriesAt(new Uri("/missing")); + assert(missing.lines[0].includes("FileNotFound")); + assert(!missing.lines[0].includes("private body")); + for (let i = 0; i < 40; i++) missing.api.recordDiagnostic("test", String(i), "metadata"); + assert.strictEqual((await missing.api.getDetailedDiagnosticsSummary()).recentDiagnostics.length, 30); + + const notification = createHarness(); + await notification.api.showSystemNotification("test", "private notification text"); + assert.strictEqual(notification.api.getDiagnosticsSummary().lastNotificationResult, "submitted"); + notification.setNotificationError(Object.assign(new Error("private notification text"), { code: 1 })); + await notification.api.showSystemNotification("test", "private notification text"); + assert.strictEqual(notification.api.getDiagnosticsSummary().lastNotificationResult, "failed"); + assert(!notification.lines.join("\n").includes("private notification text")); + + assert.strictEqual(notification.api.classifySessionMetadata({ originator: "codex_vscode", source: "vscode" }).eligible, true); + assert.strictEqual(notification.api.classifySessionMetadata({ originator: "codex_vscode", source: "vscode", thread_source: "subagent" }).eligible, false); + assert.strictEqual(notification.api.classifySessionMetadata({ originator: "codex_cli", source: "cli" }).eligible, false); + const locks = new Set(); + const firstWindow = createHarness({ locks }); + const secondWindow = createHarness({ locks }); + const source = { key: "remote-test", kind: "remote", uri: new Uri("/test/session.jsonl") }; + const session = { sessionId: "session-one", cwd: "/test", prompt: "private prompt" }; + for (let i = 0; i < 3; i++) { + const line = JSON.stringify({ timestamp: new Date().toISOString(), type: "event_msg", + payload: { type: "task_complete", turn_id: `turn-${i}`, completed_at: Date.now() } }) + "\n"; + await Promise.all([ + firstWindow.api.processCodexSessionChunk(line, source, session, 0, undefined, true), + secondWindow.api.processCodexSessionChunk(line, source, session, 0, undefined, true) + ]); + } + assert.strictEqual(firstWindow.notifications.length + secondWindow.notifications.length, 3, + "three consecutive turns across two windows must produce exactly three notifications"); + assert.strictEqual(secondWindow.api.getDiagnosticsSummary().completedTurnIds, 3); + assert(!secondWindow.lines.join("\n").includes("private prompt")); + console.log("Remote path, discovery, diagnostics and submission failure tests passed."); +} + +main().catch((error) => { console.error(error); process.exitCode = 1; });