diff --git a/server/app/db/version.json b/server/app/db/version.json index 6604efce..476e383f 100644 --- a/server/app/db/version.json +++ b/server/app/db/version.json @@ -1 +1 @@ -{"version": "2026-0902-123259-beta" } \ No newline at end of file +{"version": "2026-0910-102045-beta" } \ No newline at end of file diff --git a/server/app/handlers/projectController.js b/server/app/handlers/projectController.js index f6171a6e..faee0690 100644 --- a/server/app/handlers/projectController.js +++ b/server/app/handlers/projectController.js @@ -323,11 +323,16 @@ async function runDispatcher(clientID, projectRootDir, ack) { 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}`); + emitAll(projectRootDir, "showMessage", `you do not have write permission to ${err.storagePath} on ${err.host}`); } else { getLogger(projectRootDir).error("fatal error occurred while preparing phase:", err); + emitAll(projectRootDir, "showMessage", `failed to start project: ${err.message || err}`); } removeSsh(projectRootDir); removeAllJWTServerPassphrase(projectRootDir); + //runDispatcher is fire-and-forget from onRunProject/onContinueProject, so projectOperator + //has already resolved the socket ack with ack(true); this ack(err) is a dropped second + //ack on the wire. the client is notified via "showMessage" (toast) above instead. ack(err); return false; } @@ -392,6 +397,9 @@ async function runDispatcher(clientID, projectRootDir, ack) { await _internal.unlockIfFinished(projectRootDir); } catch (err) { getLogger(projectRootDir).error("fatal error occurred while parsing workflow:", err); + //the socket ack was already resolved by projectOperator's ack(true) (runDispatcher is + //not awaited), so ack(err) below does nothing on the wire - surface it as a toast here. + emitAll(projectRootDir, "showMessage", `project run aborted: ${err.message || err}`); await updateProjectState(projectRootDir, "failed"); ack(err); } finally { diff --git a/server/app/logSettings.js b/server/app/logSettings.js index e51a4db4..d357eba2 100644 --- a/server/app/logSettings.js +++ b/server/app/logSettings.js @@ -32,6 +32,14 @@ function socketIOAppender(layout, timezoneOffset, argEventName) { if (eventName) { const message = layout(loggingEvent, timezoneOffset); _internal.emitAll(projectRootDir, "WHEEL_LOG", message); + //the structured "WHEEL_LOG" stream above feeds the log screen. the client also + //raises a toast for "logERR" events (onLogErr in Home.vue/Viewer.vue/Workflow.vue), + //but that per-level channel stopped being emitted once the socketIO appender was + //unified onto "WHEEL_LOG". re-emit FATAL only (as plain text) so a run-aborting + //error still pops a toast, without flooding it with every ERROR-level line. + if (loggingEvent.level.levelStr === "FATAL") { + _internal.emitAll(projectRootDir, "logERR", formatToastMessage(loggingEvent)); + } } }; } @@ -73,6 +81,18 @@ function formatLogArg(value) { return value; } +//build a short one-line string from a logging event's data parts 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/file. +function formatToastMessage(loggingEvent) { + const data = Array.isArray(loggingEvent.data) ? loggingEvent.data : [loggingEvent.data]; + return data + .map((value)=>{ + return value instanceof Error ? value.message : formatLogArg(value); + }) + .join(" "); +} + const socketIO = { configure: (config, layouts)=>{ let layout = layouts.basicLayout;