From d95b4c597ca7bb60179cec7510fafad886c41cf7 Mon Sep 17 00:00:00 2001 From: arkon Date: Tue, 9 Jun 2026 03:36:39 +0200 Subject: [PATCH] feat(self-update): live progress during install/build so it doesn't look hung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The updater wrote the status once per phase, so the minute-plus npm install and build steps left the UI frozen on a single label. Add: - a heartbeat in scripts/self-update.sh (run_step wrapper) that refreshes update-status.json every ~3s during the install/build steps with the latest output line; full output is still mirrored to the update log. - a frontend (settings-ui.js) that, during non-terminal phases, shows the live status message plus a ticking total-elapsed counter instead of only the static phase label. Takes effect when updating FROM a build that contains it — the detached runner script (staged from scripts/self-update.sh) and the polling frontend are both the from-version's copies. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/self-update.sh | 38 ++++++++++++++++++++++++++++++----- src/web/public/settings-ui.js | 12 ++++++++++- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/scripts/self-update.sh b/scripts/self-update.sh index 8dbc79f8..c1abe977 100755 --- a/scripts/self-update.sh +++ b/scripts/self-update.sh @@ -93,6 +93,35 @@ write_status() { ' || echo "[self-update] WARN: status write failed ($phase)" } +# Run a slow step with a heartbeat so the status file (and the UI polling it) keeps +# moving instead of looking frozen during npm install / build. Every few seconds it +# refreshes the status with the latest output line, and mirrors full output to the +# log. Returns the wrapped command's exit code. +run_step() { + local phase="$1" base="$2"; shift 2 + local step_log; step_log="$(mktemp "${TMPDIR:-/tmp}/codeman-update.XXXXXX" 2>/dev/null || echo "/tmp/codeman-update.$$")" + write_status "$phase" "$base…" + echo "[self-update] $phase: $* (output below)" + "$@" >"$step_log" 2>&1 & + local pid=$! start=$SECONDS last_line="" + while kill -0 "$pid" 2>/dev/null; do + sleep 3 + local line + line="$(tr -d '\r' <"$step_log" 2>/dev/null | grep -aE '[^[:space:]]' | tail -n 1 | cut -c1-100)" + [[ -n "$line" && "$line" != "$last_line" ]] && last_line="$line" + if [[ -n "$last_line" ]]; then + write_status "$phase" "$base… · $last_line" + else + write_status "$phase" "$base… (working)" + fi + done + wait "$pid"; local rc=$? + echo "[self-update] $phase finished in $((SECONDS - start))s (rc=$rc)" + cat "$step_log" >>"$LOG" 2>/dev/null || true + rm -f "$step_log" 2>/dev/null || true + return $rc +} + fail() { local msg="$1" err="${2:-}" echo "[self-update] FAILED: $msg ($err)" @@ -138,13 +167,12 @@ git fetch --tags --force origin "refs/tags/$TAG:refs/tags/$TAG" 2>/dev/null \ write_status "checkout" "Checking out $TAG…" git -c advice.detachedHead=false checkout --force "$TAG" || rollback_and_fail "Could not check out $TAG" -# 4) Install dependencies. -write_status "installing" "Installing dependencies…" -npm install --no-fund --no-audit || rollback_and_fail "Dependency install failed" +# 4) Install dependencies (heartbeat keeps the UI live during this slow step). +run_step "installing" "Installing dependencies" npm install --no-fund --no-audit \ + || rollback_and_fail "Dependency install failed" # 5) Build (gate the restart on success — never restart into a torn dist/). -write_status "building" "Building…" -npm run build || rollback_and_fail "Build failed" +run_step "building" "Building" npm run build || rollback_and_fail "Build failed" # 6) Restart the service so the new code loads. Write the terminal pre-restart # marker FIRST so the freshly-booted server can reconcile it deterministically. diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 8d714538..06ff4560 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -606,7 +606,17 @@ Object.assign(CodemanApp.prototype, { return; } if (!terminal.has(data.phase)) { - this._setUpdateProgress(`↻ ${escapeHtml(this._updatePhaseText(data.phase))}`); + // Prefer the live status message — the updater's heartbeat enriches it with + // the latest npm/build output line so a slow step doesn't look frozen — and + // fall back to the static phase label. Append total elapsed so the counter + // keeps ticking between heartbeats: a clear "still working" signal. + const label = (data.message && data.message.trim()) ? data.message.trim() : this._updatePhaseText(data.phase); + let elapsed = ''; + if (data.startedAt) { + const secs = Math.max(0, Math.round((Date.now() - data.startedAt) / 1000)); + elapsed = ` · ${secs}s`; + } + this._setUpdateProgress(` ${escapeHtml(label)}${elapsed}`); return; } this._stopUpdatePolling();