From 5808491a79c151ce9ec0fbbf79844f9365bc131d Mon Sep 17 00:00:00 2001 From: Naoyuki Sogo Date: Thu, 10 Sep 2026 11:02:01 +0900 Subject: [PATCH 1/5] fix: define an actionable-error policy and surface those errors as toasts Errors were logged but never shown to the user: the per-level "logERR" socket event the client's onLogErr toast handlers listen for stopped being emitted when the socketIO appender was unified onto "WHEEL_LOG", and the run path's ack(err) is a dropped second ack (projectOperator already resolved it with ack(true)). Whether an error should interrupt the user was also never defined - .error() covered both "you must fix this" and "internal / auto-retried". Policy (documentMD/design/error_handling.md #4): - notifyUser(projectRootDir, ...): the user must act on it (an operation they requested failed, or a setting must be fixed) -> logged at error level AND emitted on "logERR" so the client shows a snackbar. - logger.error() / logError(): worth recording, no user action needed (auto-retried transfer, internal assertion, deprecated API call, a task failure already shown red on its component) -> log only. notifyUser() lives in logSettings.js. It logs via getLogger().error() (so WHEEL_LOG / the log file are unchanged) and emits "logERR" to the project room, or "default" for messages not tied to a project (project list / remotehost screen / import dialog). Errors are reduced to their message for the toast; the full entry stays in the log. Audited every error-level call in server/app and converted the actionable ones (~30 sites): run/save/stop failures in projectController, import/export/upload/rename/commit failures in file/component/project handlers, "host not found" and connection-test failures, invalid project names, editor-op failures, unknown job scheduler, and the stage-out-stuck "re-run to continue" message. Left as log-only: file-browser read failures, gfarm/remote listing errors, internal asserts, deprecated-API calls, per-task errors. No client change. Refs #131 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01C3jKNM1qubomM8UTRdkEWu --- documentMD/design/error_handling.md | 24 +++++++++++++++ server/app/core/dispatcher.js | 4 +-- server/app/core/executerManager.js | 2 +- server/app/core/projectOperations.js | 10 +++---- server/app/handlers/componentArchive.js | 6 ++-- server/app/handlers/fileManager.js | 16 +++++----- server/app/handlers/fileManager2.js | 4 +-- server/app/handlers/projectArchive.js | 6 ++-- server/app/handlers/projectController.js | 16 +++++----- server/app/handlers/registerHandlers.js | 8 ++--- server/app/handlers/remoteFileBrowser.js | 6 ++-- server/app/handlers/tryToConnect.js | 4 +-- server/app/handlers/workflowEditor.js | 4 +-- server/app/logSettings.js | 38 +++++++++++++++++++++++- server/test/app/core/executerManager.js | 7 +++-- 15 files changed, 108 insertions(+), 47 deletions(-) diff --git a/documentMD/design/error_handling.md b/documentMD/design/error_handling.md index 2b017e5c2..9205883b8 100644 --- a/documentMD/design/error_handling.md +++ b/documentMD/design/error_handling.md @@ -107,6 +107,30 @@ logError(projectRootDir, componentDir, "エラー:", error); logStdout(projectRootDir, componentDir, "タスク出力:", line); ``` +### notifyUser() — ユーザーが対応すべきエラーの通知 + +エラーの扱いは「ユーザーが何か対応しないといけないか」で分ける。 + +| 関数 | 用途 | クライアント表示 | +|------|------|-----------------| +| `notifyUser(projectRootDir, ...)` | ユーザーが要求した操作が失敗した/設定を直さないと先に進めない | ログ **+ トースト**(`logERR` イベント) | +| `logError()` / `logger.error()` | 記録はしたいがユーザーの対応は不要(自動リトライ中の転送、内部アサーション、廃止APIの呼び出し、コンポーネント自体に赤表示される単発のタスク失敗 等) | ログのみ | + +```javascript +import { notifyUser } from "../logSettings.js"; + +//run/save が失敗した、リモートホストが未設定、プロジェクト名が不正 … +notifyUser(projectRootDir, "fatal error occurred while preparing phase:", err); + +//プロジェクトに紐づかないもの(プロジェクト一覧/リモートホスト画面/インポートダイアログ)は "default" +notifyUser("default", "export project failed:", err); +``` + +- `notifyUser()` は内部で `getLogger(projectRootDir).error(...)` を呼ぶため、ログ画面(`WHEEL_LOG`)とログファイルには従来どおり出力される。 +- 追加で `logERR` イベントを対象ルーム(`projectRootDir` または `default`)へ emit する。クライアントの `onLogErr`(Home / Viewer / Workflow / Remotehost 各画面)がこれを snackbar として表示する。 +- トーストにはスタックトレースは載せない(`Error` は `message` のみ)。全文はログ画面/ログファイルで確認する。 +- 引数は `logger.error()` と同じ(第1引数は `projectRootDir` または `"default"`)。`componentDir` は取らない。 + ### ログファイルの保存先 ``` diff --git a/server/app/core/dispatcher.js b/server/app/core/dispatcher.js index e7f68c285..26499db13 100644 --- a/server/app/core/dispatcher.js +++ b/server/app/core/dispatcher.js @@ -32,7 +32,7 @@ import { logInfo, logWarn, logError, - logFatal, + notifyUser, _internal } from "../logSettings.js"; import { cancelDispatchedTasks } from "./taskUtil.js"; @@ -383,7 +383,7 @@ class Dispatcher extends EventEmitter { const stuckNames = Array.from(new Set(stuckPairs.map(({ stuck })=>{ return stuck.name; }))).join(", "); - logFatal(this.projectRootDir, this.cwfDir, + notifyUser(this.projectRootDir, `project failed: ${blockedNames} can never start because required input file(s) from ${stuckNames} were never delivered (stage-out stuck). Resolve the transfer and re-run to continue.`); } } diff --git a/server/app/core/executerManager.js b/server/app/core/executerManager.js index 41cc3b92d..20751551c 100644 --- a/server/app/core/executerManager.js +++ b/server/app/core/executerManager.js @@ -659,7 +659,7 @@ function createExecuter(task, hostinfo) { err.task = task.name; err.useJobScheduler = task.useJobScheduler; err.hostinfo = hostinfo; - loggerWrapper.logError(task.projectRootDir, task.workingDir, err); + loggerWrapper.notifyUser(task.projectRootDir, `[${task.name}] job scheduler "${hostinfo.jobScheduler}" of host "${task.host}" is not defined in jobScheduler.json`); throw err; } if (onRemote) { diff --git a/server/app/core/projectOperations.js b/server/app/core/projectOperations.js index 80c278f13..3f9e312c6 100644 --- a/server/app/core/projectOperations.js +++ b/server/app/core/projectOperations.js @@ -5,7 +5,7 @@ */ import fs from "fs-extra"; import path from "path"; -import { getLogger } from "../logSettings.js"; +import { getLogger, notifyUser } from "../logSettings.js"; import { projectList, defaultCleanupRemoteRoot, projectJsonFilename, componentJsonFilename, suffix } from "../db/db.js"; import { getDateString, writeJsonWrapper, isValidName, removeTrailingPathSep } from "../lib/utility.js"; import { convertPathSep } from "./pathUtils.js"; @@ -110,7 +110,7 @@ export async function addProject(projectDir, description) { const projectName = path.basename(projectRootDir.slice(0, -suffix.length)); if (!_internal.isValidName(projectName)) { - _internal.getLogger().error(projectName, "is not allowed for project name"); + notifyUser("default", projectName, "is not allowed for project name"); throw (new Error("illegal project name")); } projectRootDir = await _internal.createNewProject(projectRootDir, projectName, description, "wheel", "wheel@example.com"); @@ -126,12 +126,12 @@ export async function addProject(projectDir, description) { export async function renameProject(id, argNewName, oldDir) { const newName = argNewName.endsWith(suffix) ? argNewName.slice(0, -suffix.length) : argNewName; if (!_internal.isValidName(newName)) { - _internal.getLogger().error(newName, "is not allowed for project name"); + notifyUser("default", newName, "is not allowed for project name"); throw (new Error("illegal project name")); } const newDir = path.resolve(path.dirname(oldDir), `${newName}${suffix}`); if (await _internal.fs.pathExists(newDir)) { - _internal.getLogger().error(newName, "directory is already exists"); + notifyUser("default", newName, "directory is already exists"); throw (new Error("already exists")); } @@ -190,7 +190,7 @@ export async function readProject(projectRootDir) { await _internal.gitAdd(projectRootDir, "./"); await _internal.gitCommit(projectRootDir, "import project"); } catch (e) { - _internal.getLogger().error("can not access to git repository", e); + notifyUser("default", "can not access to git repository", e); return null; } } else { diff --git a/server/app/handlers/componentArchive.js b/server/app/handlers/componentArchive.js index df9290b35..c808985a1 100644 --- a/server/app/handlers/componentArchive.js +++ b/server/app/handlers/componentArchive.js @@ -4,7 +4,7 @@ * See License in the project root for the license information. */ "use strict"; -import { getLogger } from "../logSettings.js"; +import { notifyUser } from "../logSettings.js"; import { exportComponent } from "../core/exportComponent.js"; import { importComponent } from "../core/importComponent.js"; @@ -19,7 +19,7 @@ async function onExportComponent(projectRootDir, componentID, cb) { const url = await exportComponent(projectRootDir, componentID); cb(url); } catch (e) { - getLogger(projectRootDir).error("export component failed", e); + notifyUser(projectRootDir, "export component failed", e); cb(e); } } @@ -41,7 +41,7 @@ async function onImportComponent(archiveFile, projectRootDir, targetParentID, po } return newComponentID; } catch (e) { - getLogger(projectRootDir).error("import component failed", e); + notifyUser(projectRootDir, "import component failed", e); if (typeof cb === "function") { cb(e); } diff --git a/server/app/handlers/fileManager.js b/server/app/handlers/fileManager.js index e107d828f..f06a63d8f 100644 --- a/server/app/handlers/fileManager.js +++ b/server/app/handlers/fileManager.js @@ -16,7 +16,7 @@ import { getUnusedPath } from "../core/fileUtils.js"; import { deliverFile } from "../core/deliverFile.js"; import { escapeRegExp } from "../lib/utility.js"; import fileBrowser from "../core/fileBrowser.js"; -import { getLogger } from "../logSettings.js"; +import { getLogger, notifyUser } from "../logSettings.js"; import { gitLFSSize, projectJsonFilename, componentJsonFilename, rootDir, remoteHost, logFilename } from "../db/db.js"; import { baseURL } from "../core/global.js"; import { emitAll } from "./commUtils.js"; @@ -120,7 +120,7 @@ export async function onCreateNewFile(projectRootDir, argFilename, cb) { await gitAdd(projectRootDir, filename); } } catch (e) { - getLogger(projectRootDir).error(projectRootDir, "create new file failed", e); + notifyUser(projectRootDir, "create new file failed", e); cb(null); return; } @@ -142,7 +142,7 @@ export async function onCreateNewDir(projectRootDir, argDirname, cb) { await gitAdd(projectRootDir, path.resolve(dirname, ".gitkeep")); } } catch (e) { - getLogger(projectRootDir).error(projectRootDir, "create new directory failed", e); + notifyUser(projectRootDir, "create new directory failed", e); cb(null); return; } @@ -186,7 +186,7 @@ export async function onRenameFile(projectRootDir, parentDir, argOldName, argNew return; } if (await fs.pathExists(newName)) { - getLogger(projectRootDir).error(newName, "is already exists"); + notifyUser(projectRootDir, newName, "is already exists"); cb(false); return; } @@ -216,7 +216,7 @@ export async function onRenameFile(projectRootDir, parentDir, argOldName, argNew err.path = parentDir; err.oldName = oldName; err.newName = newName; - getLogger(projectRootDir).error("rename failed", err); + notifyUser(projectRootDir, "rename failed", err); cb(false); return; } @@ -243,7 +243,7 @@ export async function onCommitFiles(projectRootDir, files, cb) { }); await gitCommit(projectRootDir, undefined, filenames); } catch (err) { - getLogger(projectRootDir).error("commit files failed", err); + notifyUser(projectRootDir, "commit files failed", err); cb(false); return; } @@ -258,7 +258,7 @@ export async function onCommitFiles(projectRootDir, files, cb) { export async function onUploadFileSaved(event, socket) { const projectRootDir = event.file.meta.projectRootDir; if (!event.file.success) { - getLogger(projectRootDir).error("file upload failed", event.file.name); + notifyUser(projectRootDir, "file upload failed", event.file.name); return; } const uploadDir = path.resolve(projectRootDir, event.file.meta.currentDir); @@ -457,7 +457,7 @@ export async function onDownloadFullLog(projectRootDir, cb) { getLogger(projectRootDir).info("Debug log archive is ready for download", url); cb(url); } catch (e) { - getLogger(projectRootDir).error("Failed to create debug log archive", e); + notifyUser(projectRootDir, "Failed to create debug log archive", e); cb(null); } }; diff --git a/server/app/handlers/fileManager2.js b/server/app/handlers/fileManager2.js index f19365d33..4e1338f51 100644 --- a/server/app/handlers/fileManager2.js +++ b/server/app/handlers/fileManager2.js @@ -6,7 +6,7 @@ "use strict"; import path from "node:path"; import fs from "fs-extra"; -import { getLogger } from "../logSettings.js"; +import { getLogger, notifyUser } from "../logSettings.js"; import { emitAll } from "./commUtils.js"; /** @@ -20,7 +20,7 @@ import { emitAll } from "./commUtils.js"; */ async function onUploadFileSaved2(event) { if (!event.file.success) { - getLogger().error("file upload failed", event.file.name); + notifyUser("default", "file upload failed", event.file.name); return; } const fileSizeMB = parseInt(event.file.size / 1024 / 1024, 10); diff --git a/server/app/handlers/projectArchive.js b/server/app/handlers/projectArchive.js index 6e0192294..aff0eac53 100644 --- a/server/app/handlers/projectArchive.js +++ b/server/app/handlers/projectArchive.js @@ -4,7 +4,7 @@ * See License in the project root for the license information. */ "use strict"; -import { getLogger } from "../logSettings.js"; +import { getLogger, notifyUser } from "../logSettings.js"; import { exportProject } from "../core/exportProject.js"; import { importProject, importProjectFromGitRepository } from "../core/importProject.js"; @@ -15,7 +15,7 @@ async function onImportProject(clientID, target, parentDir, isURL, cb) { cb(projectRootDir); } catch (e) { if (e.reason !== "CANCELED") { - getLogger("default").error(`${e.message}`); + notifyUser("default", `${e.message}`); } else { getLogger("default").debug("user canceled importing project:", target); } @@ -28,7 +28,7 @@ async function onExportProject(projectRootDir, name, mail, memo, cb) { const url = await exportProject(projectRootDir, name, mail, memo); cb(url); } catch (e) { - getLogger("default").error("export project failed:", e); + notifyUser("default", "export project failed:", e); cb(false); } } diff --git a/server/app/handlers/projectController.js b/server/app/handlers/projectController.js index f6171a6e7..3a70c0aa4 100644 --- a/server/app/handlers/projectController.js +++ b/server/app/handlers/projectController.js @@ -9,7 +9,7 @@ import axios from "axios"; import { glob } from "glob"; import fs from "fs-extra"; import SBS from "simple-batch-system"; -import { getLogger } from "../logSettings.js"; +import { getLogger, notifyUser } from "../logSettings.js"; import { filesJsonFilename, remoteHost, componentJsonFilename, projectJsonFilename } from "../db/db.js"; import { deliverFile } from "../core/deliverFile.js"; import { gitAdd, gitCommit, gitResetHEAD, gitClean, gitPromise, getUnsavedFiles } from "../core/gitOperator2.js"; @@ -221,7 +221,7 @@ async function runValidationPhase(projectRootDir, ack, commitMessage) { await gitCommit(projectRootDir, commitMessage); return true; } catch (err) { - getLogger(projectRootDir).error("fatal error occurred while validation phase:", err); + notifyUser(projectRootDir, "fatal error occurred while validation phase:", err); ack(err); return false; } finally { @@ -322,9 +322,9 @@ async function runDispatcher(clientID, projectRootDir, ack) { if (err.reason === "CANCELED") { getLogger(projectRootDir).debug(err.message); } else if (err.reason === "invalidRemoteStorage") { - getLogger(projectRootDir).error(`you do not have write permission to ${err.storagePath} on ${err.host}`); + notifyUser(projectRootDir, `you do not have write permission to ${err.storagePath} on ${err.host}`); } else { - getLogger(projectRootDir).error("fatal error occurred while preparing phase:", err); + notifyUser(projectRootDir, "fatal error occurred while preparing phase:", err); } removeSsh(projectRootDir); removeAllJWTServerPassphrase(projectRootDir); @@ -391,7 +391,7 @@ async function runDispatcher(clientID, projectRootDir, ack) { await runProject(projectRootDir); await _internal.unlockIfFinished(projectRootDir); } catch (err) { - getLogger(projectRootDir).error("fatal error occurred while parsing workflow:", err); + notifyUser(projectRootDir, "fatal error occurred while parsing workflow:", err); await updateProjectState(projectRootDir, "failed"); ack(err); } finally { @@ -563,11 +563,11 @@ async function onSaveProject(projectRootDir, ack) { const projectJson = await getProjectJson(projectRootDir); const { readOnly, state: projectState } = projectJson; if (readOnly) { - getLogger(projectRootDir).error("readOnly project can not be saved", projectRootDir); + notifyUser(projectRootDir, "readOnly project can not be saved"); return ack(new Error("project is read-only")); } if (!allowedOperations[projectState].includes("saveProject")) { - getLogger(projectRootDir).error(projectState, "project can not be saved", projectRootDir); + notifyUser(projectRootDir, projectState, "project can not be saved"); return ack(new Error(`${projectState} project is not allowed to save`)); } if (projectJson.exportInfo && projectJson.exportInfo.notChanged) { @@ -619,7 +619,7 @@ async function projectOperator({ clientID, projectRootDir, ack, operation }) { break; } } catch (e) { - getLogger(projectRootDir).error(`${operation} failed`, e); + notifyUser(projectRootDir, `${operation} failed`, e); ack(e); } finally { if (operation !== "runProject") { diff --git a/server/app/handlers/registerHandlers.js b/server/app/handlers/registerHandlers.js index 6da915a7d..9c9278d66 100644 --- a/server/app/handlers/registerHandlers.js +++ b/server/app/handlers/registerHandlers.js @@ -40,7 +40,7 @@ import { import { onAddJobScriptTemplate, onUpdateJobScriptTemplate, onRemoveJobScriptTemplate, onGetJobScriptTemplates } from "./jobScript.js"; import { onGetResultFiles } from "./resultFiles.js"; import { sendTaskStateList, sendComponentTree, sendWorkflow, sendProjectJson } from "./senders.js"; -import { getLogger } from "../logSettings.js"; +import { getLogger, notifyUser } from "../logSettings.js"; import { onCreateNewRemoteFile, onCreateNewRemoteDir, @@ -120,7 +120,7 @@ const registerHandlers = (socket, Siofu)=>{ //Handle component import uploads specially if (event.file.meta.isComponentImport) { if (!event.file.success) { - getLogger(event.file.meta.projectRootDir).error("component import upload failed", event.file.name); + notifyUser(event.file.meta.projectRootDir, "component import upload failed", event.file.name); return; } //Call importComponent with the uploaded file path @@ -138,7 +138,7 @@ const registerHandlers = (socket, Siofu)=>{ const parentDir = await getComponentDir(projectRootDir, targetParentID, true); await sendWorkflow(null, projectRootDir, parentDir); } catch (e) { - getLogger(projectRootDir).error("component import failed", e); + notifyUser(projectRootDir, "component import failed", e); } return; } @@ -149,7 +149,7 @@ const registerHandlers = (socket, Siofu)=>{ }); uploader.on("error", (event)=>{ const projectRootDir = event.file.meta.projectRootDir; - getLogger(projectRootDir).error("file upload failed", event.file, event.error); + notifyUser(projectRootDir, "file upload failed", event.file && event.file.name, event.error); }); //create socket.on("createNewFile", onCreateNewFile); diff --git a/server/app/handlers/remoteFileBrowser.js b/server/app/handlers/remoteFileBrowser.js index 75422fb9f..98f59efe8 100644 --- a/server/app/handlers/remoteFileBrowser.js +++ b/server/app/handlers/remoteFileBrowser.js @@ -7,7 +7,7 @@ import path from "path"; import fs from "fs-extra"; import { readComponentJsonByID } from "../core/componentJsonIO.js"; import { remoteHost } from "../db/db.js"; -import { getLogger } from "../logSettings.js"; +import { getLogger, notifyUser } from "../logSettings.js"; import { createSsh, getSsh, askPassword } from "../core/sshManager.js"; import { createTempd } from "../core/tempd.js"; import { hasRemoteFileBrowser, hasGfarmTarBrowser } from "../../../common/checkComponent.js"; @@ -159,7 +159,7 @@ async function onRemoteDownload(projectRootDir, target, host, cb) { getLogger(projectRootDir).debug("Download url is ready", url); return cb(url); } catch (e) { - getLogger(projectRootDir).error("fetch download file failed", e); + notifyUser(projectRootDir, "fetch download file failed", e); return cb(null); } } @@ -190,7 +190,7 @@ async function gfarmFileUtilWrapper(func, projectRootDir, ...args) { const host = args.pop(); const hostID = remoteHost.getID("name", host); if (!hostID) { - getLogger(projectRootDir).error(`${host} not found in remotehost settings`); + notifyUser(projectRootDir, `${host} not found in remotehost settings`); cb(false); } try { diff --git a/server/app/handlers/tryToConnect.js b/server/app/handlers/tryToConnect.js index 6275df5a8..dd45fe66a 100644 --- a/server/app/handlers/tryToConnect.js +++ b/server/app/handlers/tryToConnect.js @@ -5,7 +5,7 @@ */ "use strict"; import SshClientWrapper from "ssh-client-wrapper"; -import { getLogger } from "../logSettings.js"; +import { getLogger, notifyUser } from "../logSettings.js"; const logger = getLogger(); import { remoteHost, verboseSsh } from "../db/db.js"; import { askPassword } from "../core/sshManager.js"; @@ -32,7 +32,7 @@ async function onTryToConnect(clientID, hostInfo, cb) { logger.info("tryToConnect canceled by user"); return cb("canceled"); } - logger.error("tryToConnect failed with", err); + notifyUser("default", "connection test failed:", err); return cb(err); } ssh.disconnect(); diff --git a/server/app/handlers/workflowEditor.js b/server/app/handlers/workflowEditor.js index b51972801..577756387 100644 --- a/server/app/handlers/workflowEditor.js +++ b/server/app/handlers/workflowEditor.js @@ -4,7 +4,7 @@ * See License in the project root for the license information. */ import SBS from "simple-batch-system"; -import { getLogger } from "../logSettings.js"; +import { getLogger, notifyUser } from "../logSettings.js"; import { addInputFile, addOutputFile, @@ -128,7 +128,7 @@ async function generalHandler(func, funcname, projectRootDir, parentID, needSend await sendComponentTree(projectRootDir, projectRootDir); } } catch (e) { - getLogger(projectRootDir).error(`${funcname} failed`, e); + notifyUser(projectRootDir, `${funcname} failed`, e); cb(e); } }); diff --git a/server/app/logSettings.js b/server/app/logSettings.js index e51a4db49..66d61cd4b 100644 --- a/server/app/logSettings.js +++ b/server/app/logSettings.js @@ -73,6 +73,17 @@ function formatLogArg(value) { return value; } +//build a short one-line string from the log-call arguments for a client-side toast. +//unlike formatLogArg(), an Error is reduced to its message (no stack trace) since the +//toast has no room for it - the full entry is still in the log screen and log file. +function formatToastMessage(messages) { + return messages + .map((value)=>{ + return value instanceof Error ? value.message : formatLogArg(value); + }) + .join(" "); +} + const socketIO = { configure: (config, layouts)=>{ let layout = layouts.basicLayout; @@ -222,6 +233,30 @@ export function logSSHout(projectRootDir, componentDir, ...messages) { export function logSSHerr(projectRootDir, componentDir, ...messages) { logWithComponentDir("ssherr", projectRootDir, componentDir, ...messages); } + +/** + * report a condition the user has to act on: something they requested failed, or a + * setting must be fixed before they can proceed. + * + * logging policy: + * - notifyUser(): the user must do something about it -> logged AND shown as a toast + * - logger.error() / logError(): an error worth recording but the user need not act + * (auto-retried transfer, internal assertion, deprecated API call, a task failure + * that already shows on the component itself) -> log only, no toast + * + * the toast is delivered on the "logERR" socket event, which the client's onLogErr + * handlers (Home/Viewer/Workflow/remotehost screens) already turn into a snackbar. + * @param {string} projectRootDir - project's root path, or "default" for messages not + * tied to a project (project list / remotehost screen / import dialog) + * @param {...any} messages - same arguments you would pass to logger.error() + * @returns {void} + */ +export function notifyUser(projectRootDir, ...messages) { + getLogger(projectRootDir).error(...messages); + const room = typeof projectRootDir === "string" && projectRootDir.length > 0 ? projectRootDir : "default"; + _internal.emitAll(room, "logERR", formatToastMessage(messages)); +} + export const loggerWrapper = { logTrace, logDebug, @@ -232,5 +267,6 @@ export const loggerWrapper = { logStdout, logStderr, logSSHout, - logSSHerr + logSSHerr, + notifyUser }; diff --git a/server/test/app/core/executerManager.js b/server/test/app/core/executerManager.js index 9c59b1abe..804f0997b 100644 --- a/server/test/app/core/executerManager.js +++ b/server/test/app/core/executerManager.js @@ -563,12 +563,13 @@ describe("UT for executerManager class", function () { }); describe("createExecuter", function () { let loggerDebugStub; - let loggerErrorStub; + let notifyUserStub; //eslint-disable-next-line no-unused-vars let jobSchedulerStub; beforeEach(()=>{ loggerDebugStub = sinon.stub(loggerWrapper, "logDebug"); - loggerErrorStub = sinon.stub(loggerWrapper, "logError"); + sinon.stub(loggerWrapper, "logError"); + notifyUserStub = sinon.stub(loggerWrapper, "notifyUser"); jobSchedulerStub = sinon.stub(_internal, "jobScheduler").value({ validScheduler: { submit: "mockSubmitCommand", queueOpt: "--queue=", reJobID: "mockJobIDPattern" } }); }); afterEach(()=>{ @@ -622,7 +623,7 @@ describe("UT for executerManager class", function () { const hostinfo = { host: "remoteHost", jobScheduler: "invalidScheduler" }; //eslint-disable-next-line @stylistic/max-statements-per-line expect(()=>{ return createExecuter(task, hostinfo); }).to.throw("illegal job Scheduler specifies"); - expect(loggerErrorStub).to.have.been.calledWith(task.projectRootDir, task.workingDir, sinon.match.instanceOf(Error)); + expect(notifyUserStub).to.have.been.calledWith(task.projectRootDir, sinon.match(/job scheduler .* is not defined in jobScheduler\.json/)); }); }); describe("register", function () { From 93e68a70734b1e538cf69a0edc48fae00fcb0d7d Mon Sep 17 00:00:00 2001 From: "version-number-updater[bot]" Date: Thu, 10 Sep 2026 11:09:28 +0900 Subject: [PATCH 2/5] [skip ci] update version number --- server/app/db/version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/app/db/version.json b/server/app/db/version.json index 128da255f..6a7e7b4ed 100644 --- a/server/app/db/version.json +++ b/server/app/db/version.json @@ -1 +1 @@ -{"version": "2026-0903-113554-beta" } \ No newline at end of file +{"version": "2026-0910-110927-beta" } \ No newline at end of file From e13dfd6b6195428338090529f38dd4715e5f5b58 Mon Sep 17 00:00:00 2001 From: Naoyuki Sogo Date: Thu, 10 Sep 2026 13:14:28 +0900 Subject: [PATCH 3/5] chore(logSettings): add JSDoc to notifyUser and formatToastMessage AGENTS.md requires JSDoc on new functions. No behavior change. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01C3jKNM1qubomM8UTRdkEWu --- server/app/logSettings.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/server/app/logSettings.js b/server/app/logSettings.js index 66d61cd4b..990247f38 100644 --- a/server/app/logSettings.js +++ b/server/app/logSettings.js @@ -73,9 +73,13 @@ function formatLogArg(value) { return value; } -//build a short one-line string from the log-call arguments for a client-side toast. -//unlike formatLogArg(), an Error is reduced to its message (no stack trace) since the -//toast has no room for it - the full entry is still in the log screen and log file. +/** + * build a short one-line string from the log-call arguments for a client-side toast. + * unlike formatLogArg(), an Error is reduced to its message (no stack trace) since the + * toast has no room for it - the full entry is still in the log screen and log file. + * @param {Array<(string|Error|object)>} messages - the arguments passed to notifyUser() + * @returns {string} - space-joined single-line message + */ function formatToastMessage(messages) { return messages .map((value)=>{ @@ -248,7 +252,7 @@ export function logSSHerr(projectRootDir, componentDir, ...messages) { * handlers (Home/Viewer/Workflow/remotehost screens) already turn into a snackbar. * @param {string} projectRootDir - project's root path, or "default" for messages not * tied to a project (project list / remotehost screen / import dialog) - * @param {...any} messages - same arguments you would pass to logger.error() + * @param {...(string|Error|object)} messages - same arguments you would pass to logger.error() * @returns {void} */ export function notifyUser(projectRootDir, ...messages) { From 18bce1bfb09aec1ea050b1ed90bcca51fdaf5611 Mon Sep 17 00:00:00 2001 From: Naoyuki Sogo Date: Thu, 10 Sep 2026 13:17:28 +0900 Subject: [PATCH 4/5] test(logging): add unit tests for notifyUser Covers: logERR toast emitted alongside the log, Error reduced to its message (no stack) in the toast, and "default" room fallback for messages not tied to a project. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01C3jKNM1qubomM8UTRdkEWu --- server/test/app/Logging.js | 46 +++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/server/test/app/Logging.js b/server/test/app/Logging.js index f92f02733..7803ebf02 100644 --- a/server/test/app/Logging.js +++ b/server/test/app/Logging.js @@ -26,7 +26,7 @@ import { logFilename } from "../../app/db/db.js"; const projectRootDir = path.resolve("hoge"); //testee -import { getLogger, configure, logSettings, _internal, logInfo, logDebug, logError } from "../../app/logSettings.js"; +import { getLogger, configure, logSettings, _internal, logInfo, logDebug, logError, notifyUser } from "../../app/logSettings.js"; describe("Unit test for log4js's helper functions", ()=>{ let logger; @@ -216,4 +216,48 @@ describe("Unit test for log4js's helper functions", ()=>{ expect(errorLog.data.join(" ")).to.match(/Error: something went wrong/); }); }); + describe("#notifyUser", ()=>{ + const findLogErrCall = ()=>{ + return emitAll.getCalls().find((c)=>{ + return c.args[1] === "logERR"; + }); + }; + beforeEach(async ()=>{ + await fs.remove(projectRootDir); + await fs.mkdir(projectRootDir); + emitAll.resetHistory(); + }); + afterEach(async ()=>{ + if (!process.env.WHEEL_KEEP_FILES_AFTER_LAST_TEST) { + await fs.remove(path.resolve(__dirname, logFilename)); + await fs.remove(projectRootDir); + } + configure(logSettings); + }); + it("should emit a plain-text 'logERR' toast to the project's room in addition to the log", ()=>{ + notifyUser(projectRootDir, "something the user must fix"); + const logErr = findLogErrCall(); + expect(logErr, "a logERR event should have been emitted").to.not.be.undefined; + expect(logErr.args[0]).to.equal(projectRootDir); + expect(logErr.args[2]).to.equal("something the user must fix"); + //the same message is still logged (and streamed to the log screen) via WHEEL_LOG + expect(emitAll.getCalls().some((c)=>{ + return c.args[1] === "WHEEL_LOG"; + })).to.be.true; + }); + it("should reduce an Error argument to its message (no stack trace) in the toast", ()=>{ + notifyUser(projectRootDir, "operation failed:", new Error("boom")); + const logErr = findLogErrCall(); + expect(logErr.args[2]).to.equal("operation failed: boom"); + expect(logErr.args[2]).to.not.match(/\n\s+at /); + }); + it("should route messages not tied to a project to the 'default' room", ()=>{ + notifyUser("default", "invalid project name"); + expect(findLogErrCall().args[0]).to.equal("default"); + }); + it("should fall back to the 'default' room when projectRootDir is not a string", ()=>{ + notifyUser(null, "connection test failed"); + expect(findLogErrCall().args[0]).to.equal("default"); + }); + }); }); From bf1fe9fd5679e9d73e251648bcf2346219abdaf7 Mon Sep 17 00:00:00 2001 From: "version-number-updater[bot]" Date: Thu, 10 Sep 2026 13:24:40 +0900 Subject: [PATCH 5/5] [skip ci] update version number --- server/app/db/version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/app/db/version.json b/server/app/db/version.json index 6a7e7b4ed..72d482293 100644 --- a/server/app/db/version.json +++ b/server/app/db/version.json @@ -1 +1 @@ -{"version": "2026-0910-110927-beta" } \ No newline at end of file +{"version": "2026-0910-132440-beta" } \ No newline at end of file