openvibers/openvibe.live Commits
Chat deploy notices: only unannounced commits, folded into one rolling message with real timestamps, replaced in place by id; legacy restart rows fold at render; silent on a restart with no new code
Changes
@@@ -3157,37 +3157,8 @@ function handleChatMessage(msg) {_chatConnState('updating');break;case 'update': {-- // Platform update notification with commit logs + expandable changelog-- const summary = esc(msg.summary || 'New update deployed!');-- const commits = Array.isArray(msg.commits) ? msg.commits : [];-- const linkUrl = msg.url ? esc(msg.url) : null;---- let html = `🚀 ${summary}`;---- if (commits.length > 0) {-- const changelogId = 'update-changelog-' + Date.now();-- html += ` <a href="#" onclick="event.preventDefault();document.getElementById('${changelogId}').classList.toggle('open')" style="color:var(--accent);text-decoration:underline;cursor:pointer">View changelog ▾</a>`;-- if (linkUrl) {-- html += ` · <a href="${linkUrl}" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:underline">Full patch notes →</a>`;-- }-- html += `<div id="${changelogId}" class="chat-changelog" style="display:none;margin-top:6px;padding:6px 8px;background:rgba(0,0,0,0.25);border-radius:6px;font-size:0.82rem;max-height:200px;overflow-y:auto;">`;-- for (const c of commits) {-- const short = esc(c.short || '');-- const subject = esc(c.subject || '');-- const commitUrl = c.hash ? `https://github.com/OpenVibers/OpenVibe.Live/commit/${esc(c.hash)}` : '#';-- html += `<div style="padding:2px 0;display:flex;gap:6px;align-items:baseline;"><a href="${commitUrl}" target="_blank" rel="noopener" style="color:var(--accent);font-family:monospace;font-size:0.78rem;text-decoration:none;flex-shrink:0">${short}</a> <span style="opacity:0.85">${subject}</span></div>`;-- }-- html += '</div>';-- // Auto-expand with a microtask so the DOM element exists-- setTimeout(() => {-- const el = document.getElementById(changelogId);-- if (el) el.classList.add('open'), el.style.display = '';-- }, 0);-- } else if (linkUrl) {-- html += ` <a href="${linkUrl}" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:underline">View full patch notes →</a>`;-- }-- html += ' <button onclick="location.href=location.pathname+\'?_=\'+Date.now()" style="margin-left:8px;padding:2px 10px;border:1px solid var(--accent);background:var(--accent);color:#fff;border-radius:var(--radius-sm);cursor:pointer;font-size:0.8rem;font-family:var(--font)">Refresh Page</button>';-- addRichSystemMessage(html, 'update');++ // Deploy notice: one rolling card per run of deploys, replaced in place by id (never duplicated).++ renderDeployCard({ id: msg.id, commits: msg.commits, deploys: msg.deploys, first_at: msg.first_at || msg.timestamp, updated_at: msg.updated_at || msg.timestamp, fresh: msg.fresh }, { live: true });break;}case 'coin_earned':@@@ -3790,6 +3761,87 @@ function addRichSystemMessage(html, style = 'info') {if (_chatUserScrolledUp) _onNewChatMessageWhileScrolledUp();}++/* ── Deploy notices ─────────────────────────────────────────────++ * One card for a run of deploys: "🚀 5 updates shipped", the time range, the commit subjects (each++ * listed once), a link to the full notes. Rendered from data (metadata.kind === 'deploy'), so times++ * come from ISO timestamps and are always correct. Same card in global, channel, popout and widget++ * chat because they all render through this file. Legacy one-line rows are folded on the way in. */++function _deployCss() {++ if (document.getElementById('chat-deploy-css')) return;++ const st = document.createElement('style'); st.id = 'chat-deploy-css';++ st.textContent = `.chat-deploy{margin:6px 0;padding:9px 11px;border-radius:10px;border:1px solid color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent);background:color-mix(in srgb,var(--accent,#3b82f6) 7%,transparent);color:var(--text-secondary,#a8b3c4);font-style:normal;font-size:.84rem;line-height:1.45}++.chat-deploy-h{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;color:var(--text-primary,#e6edf7)}.chat-deploy-h b{font-weight:700}.chat-deploy-t{font-size:.76rem;color:var(--text-muted,#7d8aa0)}++.chat-deploy-a{margin-left:auto;display:flex;gap:10px;align-items:center}.chat-deploy a,.chat-deploy-more{color:var(--accent-light,var(--accent,#60a5fa));text-decoration:none;font-size:.78rem;cursor:pointer;background:none;border:0;padding:0;font-family:inherit}++.chat-deploy-reload{border:0;border-radius:6px;background:var(--accent,#3b82f6);color:var(--on-accent,#fff);font:600 .74rem/1 inherit;padding:4px 9px;cursor:pointer}++.chat-deploy ul{list-style:none;margin:6px 0 0;padding:0;display:grid;gap:2px}.chat-deploy li{display:flex;gap:7px;align-items:baseline;min-width:0}.chat-deploy li.is-hidden{display:none}.chat-deploy.is-open li.is-hidden{display:flex}++.chat-deploy li span{min-width:0;overflow-wrap:anywhere}.chat-deploy li a{font-family:ui-monospace,Menlo,monospace;font-size:.72rem;flex:none}.chat-deploy li.is-fresh span{color:var(--text-primary,#e6edf7)}`;++ document.head.appendChild(st);++}++function _deployTime(d) {++ const opts = chatSettings.timestampFormat === '24h' ? { hour: '2-digit', minute: '2-digit', hour12: false } : { hour: '2-digit', minute: '2-digit' };++ const today = new Date().toDateString() === d.toDateString();++ return (today ? '' : d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ') + d.toLocaleTimeString([], opts);++}++function renderDeployCard(data, opts = {}) {++ const { messages: container } = getChatEl();++ if (!container) return;++ _deployCss();++ const seen = new Set();++ const commits = (Array.isArray(data.commits) ? data.commits : []).filter(c => c && c.subject && !seen.has(c.hash || c.subject) && seen.add(c.hash || c.subject));++ if (!commits.length) return;++ const fresh = new Set(data.fresh || []);++ const first = _parseMsgTime(data.first_at) || new Date(), last = _parseMsgTime(data.updated_at) || first;++ const SHOW = 3;++ const id = data.id != null ? String(data.id) : '';++ let el = id ? container.querySelector('.chat-deploy[data-deploy-id="' + id.replace(/[^\w-]/g, '') + '"]') : null;++ const wasOpen = el && el.classList.contains('is-open');++ if (!el) { el = document.createElement('div'); el.className = 'chat-msg system chat-deploy'; if (id) el.dataset.deployId = id.replace(/[^\w-]/g, ''); }++ const range = first.getTime() === last.getTime() || Math.abs(last - first) < 60000 ? _deployTime(last) : _deployTime(first) + ' – ' + _deployTime(last);++ const deploys = Number(data.deploys) || 1;++ el.innerHTML = `<div class="chat-deploy-h"><b>🚀 ${commits.length} update${commits.length === 1 ? '' : 's'} shipped</b><span class="chat-deploy-t" title="${esc(last.toLocaleString())}">${esc(range)}${deploys > 1 ? ' · ' + deploys + ' deploys' : ''}</span>++ <span class="chat-deploy-a">${commits.length > SHOW ? `<button type="button" class="chat-deploy-more">${wasOpen ? 'Show less' : 'Show all ' + commits.length}</button>` : ''}<a href="/updates" target="_blank" rel="noopener">Patch notes</a>${opts.live ? '<button type="button" class="chat-deploy-reload" title="Load the new version">Reload</button>' : ''}</span></div>++ <ul>${commits.map((c, i) => `<li class="${i >= SHOW ? 'is-hidden' : ''}${fresh.has(c.hash) ? ' is-fresh' : ''}">${c.hash ? `<a href="https://github.com/OpenVibers/OpenVibe.Live/commit/${esc(c.hash)}" target="_blank" rel="noopener">${esc(c.short || String(c.hash).slice(0, 7))}</a>` : ''}<span>${esc(c.subject)}</span></li>`).join('')}</ul>`;++ if (wasOpen) el.classList.add('is-open');++ const more = el.querySelector('.chat-deploy-more');++ if (more) more.addEventListener('click', () => { const open = el.classList.toggle('is-open'); more.textContent = open ? 'Show less' : 'Show all ' + commits.length; });++ const reload = el.querySelector('.chat-deploy-reload');++ if (reload) reload.addEventListener('click', () => { location.href = location.pathname + '?_=' + Date.now(); });++ // A live update moves the card to the end (it IS the newest message); history keeps its position.++ if (!el.isConnected || opts.live) container.appendChild(el);++ scrollChat();++ if (opts.live && _chatUserScrolledUp) _onNewChatMessageWhileScrolledUp();++}++/** Old rows were prose: "🚀 Server restarted (abc1234) — a · b · c (2m ago)". Pull the subjects back out. */++function _legacyDeploy(m) {++ const t = String(m.message || '');++ if (!/^🚀\s*Server restarted/.test(t)) return null;++ const body = t.replace(/^🚀\s*Server restarted\s*(\([^)]*\)\s*)?—\s*/, '').replace(/\s*\((just now|\d+[mhd] ago)\)\s*$/, '');++ const short = (/\(([0-9a-f]{6,12})\)/.exec(t) || [])[1] || '';++ return body.split(' · ').map(x => x.trim()).filter(Boolean).map((subject, i) => ({ subject, short: i === 0 ? short : '', hash: '' }));++}++/** A deploy notice for history rows: structured rows as they are, consecutive legacy rows folded into one. */++function _deployFromRow(m) {++ let meta = null; try { meta = typeof m.metadata === 'string' ? JSON.parse(m.metadata) : m.metadata; } catch { meta = null; }++ if (meta && meta.kind === 'deploy') return { id: m.id, commits: meta.commits, deploys: meta.deploys, first_at: meta.first_at, updated_at: meta.updated_at };++ const legacy = _legacyDeploy(m);++ return legacy ? { id: null, legacy: true, commits: legacy, deploys: 1, first_at: m.timestamp || m.created_at, updated_at: m.timestamp || m.created_at } : null;++}++function _foldDeployRows(msgs) {++ const out = [];++ for (const m of msgs) {++ const d = m.message_type === 'system' ? _deployFromRow(m) : null;++ const prev = out[out.length - 1];++ if (d && prev && prev.__deploy) {++ // Newest first inside the card; each subject once.++ prev.__deploy.commits = d.commits.concat(prev.__deploy.commits);++ prev.__deploy.deploys += d.deploys || 1;++ prev.__deploy.updated_at = d.updated_at;++ if (d.id != null) prev.__deploy.id = d.id;++ } else out.push(d ? { __deploy: d } : m);++ }++ return out;++}++function addDonationMessage(msg) {const { messages: container } = getChatEl();if (!container) return;@@@ -4496,9 +4548,11 @@ async function loadGlobalChatHistory() {function _renderGlobalHistoryMessages(msgs) {chatLiveSlots = [];chatChannel = null;-- msgs.forEach(m => {-- if (m.message_type === 'system') {-- // Render system messages (update announcements, etc.) with system styling++ _foldDeployRows(msgs).forEach(m => {++ if (m.__deploy) {++ renderDeployCard(m.__deploy);++ } else if (m.message_type === 'system') {++ // Other system messages keep the plain system stylingaddRichSystemMessage(esc(m.message), 'update');} else if (m.message_type === 'donation') {// Donations carry their payload in metadata — render the rich card herennew file mode 100644@@@ -0,0 +1,99 @@++'use strict';++// ═══════════════════════════════════════════════════════════════++// Deploy notices in chat — one tidy, rolling message instead of a line per restart.++//++// What went wrong before: every boot inserted a chat row holding the latest three commit subjects++// (which overlap from one restart to the next) with a relative time baked into the text ("2m ago"++// forever). Fourteen deploys made fourteen rows repeating each other.++//++// Now:++// • Only commits that were never announced are announced (site setting `deploy_last_announced`).++// A restart with no new code says nothing.++// • Consecutive deploys fold into ONE stored message: while the newest global chat row is a deploy++// notice (nobody has spoken since) and it is under 12 hours old, that row is updated in place.++// • The row stores data, not prose: metadata { kind: 'deploy', commits[], deploys, first_at,++// updated_at }. Clients render times from ISO timestamps, so they are always right.++// • The live broadcast carries the row id; clients replace the card with that id instead of++// appending, so reconnects and repeats can never duplicate it.++// ═══════════════════════════════════════════════════════════════++const { execFile } = require('child_process');++const path = require('path');++++const REPO_DIR = path.join(__dirname, '..', '..');++const SETTING = 'deploy_last_announced';++const FOLD_WINDOW_MS = 12 * 60 * 60 * 1000;++const MAX_COMMITS = 40;++const ATTEMPTS_MS = [5000, 20000, 45000]; // clients reconnect with backoff after a restart++++const git = (args, timeout = 5000) => new Promise((resolve) => {++ execFile('git', args, { cwd: REPO_DIR, encoding: 'utf8', timeout, maxBuffer: 1024 * 1024 }, (err, out) => resolve(err ? '' : String(out || '')));++});++++const parseLog = (raw) => raw.trim().split('\n').filter(Boolean).map((line) => {++ const [hash, short, date, ...rest] = line.split('\x1f');++ return { hash, short, date, subject: rest.join(' ').trim().slice(0, 200) };++}).filter(c => /^[0-9a-f]{40}$/.test(c.hash || ''));++++/** Commits on HEAD that have not been announced yet (newest first). */++async function newCommits(db) {++ const head = (await git(['rev-parse', 'HEAD'], 3000)).trim();++ if (!/^[0-9a-f]{40}$/.test(head)) return { head: '', commits: [] };++ const last = String(db.getSetting(SETTING) || '').trim();++ if (last === head) return { head, commits: [] };++ const fmt = '--pretty=format:%H%x1f%h%x1f%aI%x1f%s';++ let raw = '';++ if (/^[0-9a-f]{40}$/.test(last)) raw = await git(['--no-pager', 'log', fmt, '-n', String(MAX_COMMITS), `${last}..${head}`]);++ // First run, or history was rewritten: announce the current commit only, never a backlog.++ if (!raw.trim()) raw = await git(['--no-pager', 'log', fmt, '-n', '1', head]);++ return { head, commits: parseLog(raw) };++}++++const plainText = (meta) => `🚀 ${meta.commits.length} update${meta.commits.length === 1 ? '' : 's'} shipped: ${meta.commits.slice(0, 3).map(c => c.subject).join(' · ')}${meta.commits.length > 3 ? ` · and ${meta.commits.length - 3} more` : ''}`;++++/** Insert a notice, or fold into the newest row when that row is itself a recent deploy notice. */++function persist(db, commits) {++ const nowIso = new Date().toISOString();++ const newest = db.get('SELECT id, message_type, metadata, created_at FROM chat_messages WHERE is_global = 1 AND is_deleted = 0 ORDER BY id DESC LIMIT 1');++ let prev = null;++ if (newest && newest.message_type === 'system' && newest.metadata) {++ try { const m = JSON.parse(newest.metadata); if (m && m.kind === 'deploy' && Date.now() - Date.parse(m.first_at) < FOLD_WINDOW_MS) prev = m; } catch { /* not ours */ }++ }++ if (prev) {++ const seen = new Set(commits.map(c => c.hash));++ const merged = commits.concat((prev.commits || []).filter(c => !seen.has(c.hash))).slice(0, MAX_COMMITS);++ const meta = { kind: 'deploy', commits: merged, deploys: (prev.deploys || 1) + 1, first_at: prev.first_at, updated_at: nowIso };++ db.run('UPDATE chat_messages SET message = ?, metadata = ? WHERE id = ?', [plainText(meta), JSON.stringify(meta), newest.id]);++ return { id: newest.id, meta };++ }++ const meta = { kind: 'deploy', commits: commits.slice(0, MAX_COMMITS), deploys: 1, first_at: nowIso, updated_at: nowIso };++ const res = db.saveChatMessage({ stream_id: null, user_id: null, anon_id: null, username: 'OpenVibe.Live', message: plainText(meta), message_type: 'system', is_global: true, metadata: meta });++ return { id: res && (res.lastInsertRowid || res.lastID || res.id) || null, meta };++}++++/**++ * Announce this boot's new commits. Safe to call once after the chat server is up.++ * @returns {Promise<{ announced: number }>}++ */++async function announce({ db, chatServer, log = console }) {++ const { head, commits } = await newCommits(db);++ if (!head || !commits.length) return { announced: 0 };++++ let saved;++ try { saved = persist(db, commits); db.setSetting(SETTING, head); }++ catch (err) { log.warn('[Deploy notice] not saved:', err.message); return { announced: 0 }; }++++ const payload = JSON.stringify({ type: 'update', kind: 'deploy', id: saved.id, fresh: commits.map(c => c.hash), url: '/updates', timestamp: saved.meta.updated_at, ...saved.meta });++ const sent = new WeakSet();++ const push = () => {++ let n = 0;++ for (const [ws] of chatServer.clients) {++ if (sent.has(ws) || ws.readyState !== 1 || ws.bufferedAmount > 256 * 1024) continue;++ try { ws.send(payload); sent.add(ws); n++; } catch { /* socket went away */ }++ }++ return n;++ };++ ATTEMPTS_MS.forEach((ms, i) => { const t = setTimeout(() => { const n = push(); if (n) log.log(`[Deploy notice] ${commits.length} commit(s) → ${n} client(s) (pass ${i + 1})`); }, ms); if (t.unref) t.unref(); });++ return { announced: commits.length };++}++++module.exports = { announce, newCommits, persist, plainText, parseLog, SETTING };@@@ -1351,106 +1351,9 @@ async function start() {const _pcT = setInterval(_pcClean, 24 * 60 * 60 * 1000); if (_pcT.unref) _pcT.unref();} catch { /* */ }-- // Broadcast recent git changes to all chat clients after startup.-- // Uses a retry loop — clients reconnect with backoff after a restart,-- // so a single 5s broadcast misses most of them. We broadcast at 5s,-- // 15s, and 30s intervals, tracking which clients already received it.-- // The message is also persisted to chat_messages so it shows in history.-- const _changelogSentTo = new Set();-- let _changelogBroadcasts = 0;-- const _changelogMaxBroadcasts = 3;-- const _changelogDelays = [5000, 15000, 30000];---- /** Promise wrapper around execFile — git, argv-style, never through a shell. */-- function _git(args, timeout) {-- return new Promise((resolve) => {-- execFile('git', args, { cwd: REPO_DIR, encoding: 'utf8', timeout, maxBuffer: 1024 * 1024 },-- (err, stdout) => resolve(err ? '' : String(stdout || '')));-- });-- }---- // These two git calls used to be execSync. At boot that blocks the event loop while the-- // server is meant to be accepting its first requests, and the whole reason this runs on a-- // 5/15/30s delay is to stay out of the way of startup. execFile keeps it off the loop.-- async function broadcastChangelog() {-- try {-- const versionTag = (await _git(['describe', '--always', '--tags'], 3000)).trim();---- const raw = await _git(['--no-pager', 'log', '--pretty=format:%H||%h||%s||%an||%aI', '-10'], 5000);-- if (!raw.trim()) return;-- const commits = raw.trim().split('\n').filter(Boolean).map(line => {-- const [hash, short, subject, author, date] = line.split('||');-- return { hash, short, subject, author, date };-- });-- if (commits.length === 0) return;---- // Build summary: use version tag if available, otherwise top 3 subjects-- const top3 = commits.slice(0, 3).map(c => c.subject).join(' · ');-- const versionPrefix = versionTag ? `(${versionTag}) ` : '';-- // Include relative time since the most recent commit-- const latestDate = commits[0].date ? new Date(commits[0].date) : null;-- let timeSuffix = '';-- if (latestDate) {-- const ago = Date.now() - latestDate.getTime();-- if (ago < 60_000) timeSuffix = ' (just now)';-- else if (ago < 3_600_000) timeSuffix = ` (${Math.round(ago / 60_000)}m ago)`;-- else if (ago < 86_400_000) timeSuffix = ` (${Math.round(ago / 3_600_000)}h ago)`;-- else timeSuffix = ` (${Math.round(ago / 86_400_000)}d ago)`;-- }---- const payload = {-- type: 'update',-- summary: `Server restarted ${versionPrefix}— ${top3}${timeSuffix}`,-- commits,-- url: '/updates',-- timestamp: new Date().toISOString(),-- };-- const msg = JSON.stringify(payload);---- // Persist to chat_messages on the FIRST broadcast only-- if (_changelogBroadcasts === 0) {-- try {-- const summaryText = `🚀 Server restarted ${versionPrefix}— ${top3}${timeSuffix}`;-- db.saveChatMessage({-- stream_id: null,-- user_id: null,-- anon_id: null,-- username: 'OpenVibe.Live',-- message: summaryText,-- message_type: 'system',-- is_global: true,-- });-- } catch (dbErr) {-- console.warn('[Server] Failed to persist changelog to chat:', dbErr.message);-- }-- }---- // Send only to clients that haven't received it yet-- let newRecipients = 0;-- for (const [ws, client] of chatServer.clients) {-- const clientKey = client.user?.id ? `u:${client.user.id}` : `a:${client.anonId || ws._socket?.remoteAddress}`;-- if (_changelogSentTo.has(clientKey)) continue;-- if (ws.readyState === 1 /* WebSocket.OPEN */ && ws.bufferedAmount <= 256 * 1024) {-- ws.send(msg);-- _changelogSentTo.add(clientKey);-- newRecipients++;-- }-- }---- _changelogBroadcasts++;-- console.log(`[Server] Changelog broadcast #${_changelogBroadcasts}: ${newRecipients} new recipients (${_changelogSentTo.size} total, ${chatServer.getTotalConnections()} connected)`);---- // Schedule next broadcast if we haven't hit the max-- if (_changelogBroadcasts < _changelogMaxBroadcasts) {-- const nextDelay = (_changelogDelays[_changelogBroadcasts] || 30000) - (_changelogDelays[_changelogBroadcasts - 1] || 0);-- setTimeout(broadcastChangelog, nextDelay);-- }-- } catch (err) {-- console.warn('[Server] Failed to broadcast startup changelog:', err.message);-- }-- }---- setTimeout(broadcastChangelog, _changelogDelays[0]);++ // Deploy notice in chat: only commits not announced before, folded into one rolling message++ // (server/chat/deploy-notice.js). A restart with no new code says nothing.++ require('./chat/deploy-notice').announce({ db, chatServer }).catch((err) => console.warn('[Deploy notice] failed:', err.message));});// 8. Start stale stream heartbeat cleanup (every 60 seconds)nnew file mode 100644@@@ -0,0 +1,34 @@++'use strict';++// Deploy notices fold into one rolling row, never repeat a commit, and stay silent without new code.++const assert = require('assert');++const dn = require('../server/chat/deploy-notice');++++const rows = []; const settings = {};++const db = {++ getSetting: (k) => settings[k], setSetting: (k, v) => { settings[k] = v; },++ get: () => rows.filter(r => r.is_global && !r.is_deleted).slice(-1)[0] || undefined,++ run: (_sql, [message, metadata, id]) => { const r = rows.find(x => x.id === id); r.message = message; r.metadata = metadata; },++ saveChatMessage: (m) => { const r = { id: rows.length + 1, is_global: 1, is_deleted: 0, message_type: m.message_type, message: m.message, metadata: m.metadata ? JSON.stringify(m.metadata) : null }; rows.push(r); return { lastInsertRowid: r.id }; },++};++const c = (n) => ({ hash: String(n).repeat(40).slice(0, 40).replace(/[^0-9a-f]/g, 'a'), short: 'c' + n, subject: 'change ' + n, date: new Date().toISOString() });++++const first = dn.persist(db, [c(1)]);++assert.equal(rows.length, 1); assert.equal(first.meta.deploys, 1);++const second = dn.persist(db, [c(2), c(1)]); // overlap with what is already there++assert.equal(rows.length, 1, 'a second deploy updates the same row');++assert.equal(second.id, first.id);++assert.deepEqual(second.meta.commits.map(x => x.short), ['c2', 'c1'], 'each commit once, newest first');++assert.equal(second.meta.deploys, 2);++assert.ok(/^🚀 2 updates shipped/.test(rows[0].message));++++rows.push({ id: 2, is_global: 1, is_deleted: 0, message_type: 'chat', message: 'hello', metadata: null }); // someone spoke++const third = dn.persist(db, [c(3)]);++assert.equal(rows.length, 3, 'after real chat, the next deploy starts a new notice');++assert.equal(third.meta.deploys, 1);++++const old = JSON.parse(rows[2].metadata); old.first_at = new Date(Date.now() - 13 * 3600 * 1000).toISOString(); rows[2].metadata = JSON.stringify(old);++dn.persist(db, [c(4)]);++assert.equal(rows.length, 4, 'a notice older than the fold window is not reopened');++++assert.deepEqual(dn.parseLog('zz\x1fab\x1f2026\x1fnope'), [], 'malformed git lines are dropped');++console.log('deploy notice: all checks passed');
@@ -3157,37 +3157,8 @@ function handleChatMessage(msg) {
@@ -3157,37 +3157,8 @@ function handleChatMessage(msg) {
_chatConnState('updating');
_chatConnState('updating');
break;
break;
case 'update': {
case 'update': {
- // Platform update notification with commit logs + expandable changelog
- const summary = esc(msg.summary || 'New update deployed!');
- const commits = Array.isArray(msg.commits) ? msg.commits : [];
- const linkUrl = msg.url ? esc(msg.url) : null;
-
- let html = `🚀 ${summary}`;
-
- if (commits.length > 0) {
- const changelogId = 'update-changelog-' + Date.now();
- html += ` <a href="#" onclick="event.preventDefault();document.getElementById('${changelogId}').classList.toggle('open')" style="color:var(--accent);text-decoration:underline;cursor:pointer">View changelog ▾</a>`;
- if (linkUrl) {
- html += ` · <a href="${linkUrl}" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:underline">Full patch notes →</a>`;
- }
- html += `<div id="${changelogId}" class="chat-changelog" style="display:none;margin-top:6px;padding:6px 8px;background:rgba(0,0,0,0.25);border-radius:6px;font-size:0.82rem;max-height:200px;overflow-y:auto;">`;
- for (const c of commits) {
- const short = esc(c.short || '');
- const subject = esc(c.subject || '');
- const commitUrl = c.hash ? `https://github.com/OpenVibers/OpenVibe.Live/commit/${esc(c.hash)}` : '#';
- html += `<div style="padding:2px 0;display:flex;gap:6px;align-items:baseline;"><a href="${commitUrl}" target="_blank" rel="noopener" style="color:var(--accent);font-family:monospace;font-size:0.78rem;text-decoration:none;flex-shrink:0">${short}</a> <span style="opacity:0.85">${subject}</span></div>`;
- }
- html += '</div>';
- // Auto-expand with a microtask so the DOM element exists
- setTimeout(() => {
- const el = document.getElementById(changelogId);
- if (el) el.classList.add('open'), el.style.display = '';
- }, 0);
- } else if (linkUrl) {
- html += ` <a href="${linkUrl}" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:underline">View full patch notes →</a>`;
- }
- html += ' <button onclick="location.href=location.pathname+\'?_=\'+Date.now()" style="margin-left:8px;padding:2px 10px;border:1px solid var(--accent);background:var(--accent);color:#fff;border-radius:var(--radius-sm);cursor:pointer;font-size:0.8rem;font-family:var(--font)">Refresh Page</button>';
- addRichSystemMessage(html, 'update');
+ // Deploy notice: one rolling card per run of deploys, replaced in place by id (never duplicated).
+ renderDeployCard({ id: msg.id, commits: msg.commits, deploys: msg.deploys, first_at: msg.first_at || msg.timestamp, updated_at: msg.updated_at || msg.timestamp, fresh: msg.fresh }, { live: true });
break;
break;
}
}
case 'coin_earned':
case 'coin_earned':
@@ -3790,6 +3761,87 @@ function addRichSystemMessage(html, style = 'info') {
@@ -3790,6 +3761,87 @@ function addRichSystemMessage(html, style = 'info') {
if (_chatUserScrolledUp) _onNewChatMessageWhileScrolledUp();
if (_chatUserScrolledUp) _onNewChatMessageWhileScrolledUp();
}
}
+/* ── Deploy notices ─────────────────────────────────────────────
+ * One card for a run of deploys: "🚀 5 updates shipped", the time range, the commit subjects (each
+ * listed once), a link to the full notes. Rendered from data (metadata.kind === 'deploy'), so times
+ * come from ISO timestamps and are always correct. Same card in global, channel, popout and widget
+ * chat because they all render through this file. Legacy one-line rows are folded on the way in. */
+function _deployCss() {
+ if (document.getElementById('chat-deploy-css')) return;
+ const st = document.createElement('style'); st.id = 'chat-deploy-css';
+ st.textContent = `.chat-deploy{margin:6px 0;padding:9px 11px;border-radius:10px;border:1px solid color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent);background:color-mix(in srgb,var(--accent,#3b82f6) 7%,transparent);color:var(--text-secondary,#a8b3c4);font-style:normal;font-size:.84rem;line-height:1.45}
+.chat-deploy-h{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;color:var(--text-primary,#e6edf7)}.chat-deploy-h b{font-weight:700}.chat-deploy-t{font-size:.76rem;color:var(--text-muted,#7d8aa0)}
+.chat-deploy-a{margin-left:auto;display:flex;gap:10px;align-items:center}.chat-deploy a,.chat-deploy-more{color:var(--accent-light,var(--accent,#60a5fa));text-decoration:none;font-size:.78rem;cursor:pointer;background:none;border:0;padding:0;font-family:inherit}
+.chat-deploy-reload{border:0;border-radius:6px;background:var(--accent,#3b82f6);color:var(--on-accent,#fff);font:600 .74rem/1 inherit;padding:4px 9px;cursor:pointer}
+.chat-deploy ul{list-style:none;margin:6px 0 0;padding:0;display:grid;gap:2px}.chat-deploy li{display:flex;gap:7px;align-items:baseline;min-width:0}.chat-deploy li.is-hidden{display:none}.chat-deploy.is-open li.is-hidden{display:flex}
+.chat-deploy li span{min-width:0;overflow-wrap:anywhere}.chat-deploy li a{font-family:ui-monospace,Menlo,monospace;font-size:.72rem;flex:none}.chat-deploy li.is-fresh span{color:var(--text-primary,#e6edf7)}`;
+ document.head.appendChild(st);
+}
+function _deployTime(d) {
+ const opts = chatSettings.timestampFormat === '24h' ? { hour: '2-digit', minute: '2-digit', hour12: false } : { hour: '2-digit', minute: '2-digit' };
+ const today = new Date().toDateString() === d.toDateString();
+ return (today ? '' : d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ') + d.toLocaleTimeString([], opts);
+}
+function renderDeployCard(data, opts = {}) {
+ const { messages: container } = getChatEl();
+ if (!container) return;
+ _deployCss();
+ const seen = new Set();
+ const commits = (Array.isArray(data.commits) ? data.commits : []).filter(c => c && c.subject && !seen.has(c.hash || c.subject) && seen.add(c.hash || c.subject));
+ if (!commits.length) return;
+ const fresh = new Set(data.fresh || []);
+ const first = _parseMsgTime(data.first_at) || new Date(), last = _parseMsgTime(data.updated_at) || first;
+ const SHOW = 3;
+ const id = data.id != null ? String(data.id) : '';
+ let el = id ? container.querySelector('.chat-deploy[data-deploy-id="' + id.replace(/[^\w-]/g, '') + '"]') : null;
+ const wasOpen = el && el.classList.contains('is-open');
+ if (!el) { el = document.createElement('div'); el.className = 'chat-msg system chat-deploy'; if (id) el.dataset.deployId = id.replace(/[^\w-]/g, ''); }
+ const range = first.getTime() === last.getTime() || Math.abs(last - first) < 60000 ? _deployTime(last) : _deployTime(first) + ' – ' + _deployTime(last);
+ const deploys = Number(data.deploys) || 1;
+ el.innerHTML = `<div class="chat-deploy-h"><b>🚀 ${commits.length} update${commits.length === 1 ? '' : 's'} shipped</b><span class="chat-deploy-t" title="${esc(last.toLocaleString())}">${esc(range)}${deploys > 1 ? ' · ' + deploys + ' deploys' : ''}</span>
+ <span class="chat-deploy-a">${commits.length > SHOW ? `<button type="button" class="chat-deploy-more">${wasOpen ? 'Show less' : 'Show all ' + commits.length}</button>` : ''}<a href="/updates" target="_blank" rel="noopener">Patch notes</a>${opts.live ? '<button type="button" class="chat-deploy-reload" title="Load the new version">Reload</button>' : ''}</span></div>
+ <ul>${commits.map((c, i) => `<li class="${i >= SHOW ? 'is-hidden' : ''}${fresh.has(c.hash) ? ' is-fresh' : ''}">${c.hash ? `<a href="https://github.com/OpenVibers/OpenVibe.Live/commit/${esc(c.hash)}" target="_blank" rel="noopener">${esc(c.short || String(c.hash).slice(0, 7))}</a>` : ''}<span>${esc(c.subject)}</span></li>`).join('')}</ul>`;
+ if (wasOpen) el.classList.add('is-open');
+ const more = el.querySelector('.chat-deploy-more');
+ if (more) more.addEventListener('click', () => { const open = el.classList.toggle('is-open'); more.textContent = open ? 'Show less' : 'Show all ' + commits.length; });
+ const reload = el.querySelector('.chat-deploy-reload');
+ if (reload) reload.addEventListener('click', () => { location.href = location.pathname + '?_=' + Date.now(); });
+ // A live update moves the card to the end (it IS the newest message); history keeps its position.
+ if (!el.isConnected || opts.live) container.appendChild(el);
+ scrollChat();
+ if (opts.live && _chatUserScrolledUp) _onNewChatMessageWhileScrolledUp();
+}
+/** Old rows were prose: "🚀 Server restarted (abc1234) — a · b · c (2m ago)". Pull the subjects back out. */
+function _legacyDeploy(m) {
+ const t = String(m.message || '');
+ if (!/^🚀\s*Server restarted/.test(t)) return null;
+ const body = t.replace(/^🚀\s*Server restarted\s*(\([^)]*\)\s*)?—\s*/, '').replace(/\s*\((just now|\d+[mhd] ago)\)\s*$/, '');
+ const short = (/\(([0-9a-f]{6,12})\)/.exec(t) || [])[1] || '';
+ return body.split(' · ').map(x => x.trim()).filter(Boolean).map((subject, i) => ({ subject, short: i === 0 ? short : '', hash: '' }));
+}
+/** A deploy notice for history rows: structured rows as they are, consecutive legacy rows folded into one. */
+function _deployFromRow(m) {
+ let meta = null; try { meta = typeof m.metadata === 'string' ? JSON.parse(m.metadata) : m.metadata; } catch { meta = null; }
+ if (meta && meta.kind === 'deploy') return { id: m.id, commits: meta.commits, deploys: meta.deploys, first_at: meta.first_at, updated_at: meta.updated_at };
+ const legacy = _legacyDeploy(m);
+ return legacy ? { id: null, legacy: true, commits: legacy, deploys: 1, first_at: m.timestamp || m.created_at, updated_at: m.timestamp || m.created_at } : null;
+}
+function _foldDeployRows(msgs) {
+ const out = [];
+ for (const m of msgs) {
+ const d = m.message_type === 'system' ? _deployFromRow(m) : null;
+ const prev = out[out.length - 1];
+ if (d && prev && prev.__deploy) {
+ // Newest first inside the card; each subject once.
+ prev.__deploy.commits = d.commits.concat(prev.__deploy.commits);
+ prev.__deploy.deploys += d.deploys || 1;
+ prev.__deploy.updated_at = d.updated_at;
+ if (d.id != null) prev.__deploy.id = d.id;
+ } else out.push(d ? { __deploy: d } : m);
+ }
+ return out;
+}
+
function addDonationMessage(msg) {
function addDonationMessage(msg) {
const { messages: container } = getChatEl();
const { messages: container } = getChatEl();
if (!container) return;
if (!container) return;
@@ -4496,9 +4548,11 @@ async function loadGlobalChatHistory() {
@@ -4496,9 +4548,11 @@ async function loadGlobalChatHistory() {
function _renderGlobalHistoryMessages(msgs) {
function _renderGlobalHistoryMessages(msgs) {
chatLiveSlots = [];
chatLiveSlots = [];
chatChannel = null;
chatChannel = null;
- msgs.forEach(m => {
- if (m.message_type === 'system') {
- // Render system messages (update announcements, etc.) with system styling
+ _foldDeployRows(msgs).forEach(m => {
+ if (m.__deploy) {
+ renderDeployCard(m.__deploy);
+ } else if (m.message_type === 'system') {
+ // Other system messages keep the plain system styling
addRichSystemMessage(esc(m.message), 'update');
addRichSystemMessage(esc(m.message), 'update');
} else if (m.message_type === 'donation') {
} else if (m.message_type === 'donation') {
// Donations carry their payload in metadata — render the rich card here
// Donations carry their payload in metadata — render the rich card here
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════
+// Deploy notices in chat — one tidy, rolling message instead of a line per restart.
+//
+// What went wrong before: every boot inserted a chat row holding the latest three commit subjects
+// (which overlap from one restart to the next) with a relative time baked into the text ("2m ago"
+// forever). Fourteen deploys made fourteen rows repeating each other.
+//
+// Now:
+// • Only commits that were never announced are announced (site setting `deploy_last_announced`).
+// A restart with no new code says nothing.
+// • Consecutive deploys fold into ONE stored message: while the newest global chat row is a deploy
+// notice (nobody has spoken since) and it is under 12 hours old, that row is updated in place.
+// • The row stores data, not prose: metadata { kind: 'deploy', commits[], deploys, first_at,
+// updated_at }. Clients render times from ISO timestamps, so they are always right.
+// • The live broadcast carries the row id; clients replace the card with that id instead of
+// appending, so reconnects and repeats can never duplicate it.
+// ═══════════════════════════════════════════════════════════════
+const { execFile } = require('child_process');
+const path = require('path');
+
+const REPO_DIR = path.join(__dirname, '..', '..');
+const SETTING = 'deploy_last_announced';
+const FOLD_WINDOW_MS = 12 * 60 * 60 * 1000;
+const MAX_COMMITS = 40;
+const ATTEMPTS_MS = [5000, 20000, 45000]; // clients reconnect with backoff after a restart
+
+const git = (args, timeout = 5000) => new Promise((resolve) => {
+ execFile('git', args, { cwd: REPO_DIR, encoding: 'utf8', timeout, maxBuffer: 1024 * 1024 }, (err, out) => resolve(err ? '' : String(out || '')));
+});
+
+const parseLog = (raw) => raw.trim().split('\n').filter(Boolean).map((line) => {
+ const [hash, short, date, ...rest] = line.split('\x1f');
+ return { hash, short, date, subject: rest.join(' ').trim().slice(0, 200) };
+}).filter(c => /^[0-9a-f]{40}$/.test(c.hash || ''));
+
+/** Commits on HEAD that have not been announced yet (newest first). */
+async function newCommits(db) {
+ const head = (await git(['rev-parse', 'HEAD'], 3000)).trim();
+ if (!/^[0-9a-f]{40}$/.test(head)) return { head: '', commits: [] };
+ const last = String(db.getSetting(SETTING) || '').trim();
+ if (last === head) return { head, commits: [] };
+ const fmt = '--pretty=format:%H%x1f%h%x1f%aI%x1f%s';
+ let raw = '';
+ if (/^[0-9a-f]{40}$/.test(last)) raw = await git(['--no-pager', 'log', fmt, '-n', String(MAX_COMMITS), `${last}..${head}`]);
+ // First run, or history was rewritten: announce the current commit only, never a backlog.
+ if (!raw.trim()) raw = await git(['--no-pager', 'log', fmt, '-n', '1', head]);
+ return { head, commits: parseLog(raw) };
+}
+
+const plainText = (meta) => `🚀 ${meta.commits.length} update${meta.commits.length === 1 ? '' : 's'} shipped: ${meta.commits.slice(0, 3).map(c => c.subject).join(' · ')}${meta.commits.length > 3 ? ` · and ${meta.commits.length - 3} more` : ''}`;
+
+/** Insert a notice, or fold into the newest row when that row is itself a recent deploy notice. */
+function persist(db, commits) {
+ const nowIso = new Date().toISOString();
+ const newest = db.get('SELECT id, message_type, metadata, created_at FROM chat_messages WHERE is_global = 1 AND is_deleted = 0 ORDER BY id DESC LIMIT 1');
+ let prev = null;
+ if (newest && newest.message_type === 'system' && newest.metadata) {
+ try { const m = JSON.parse(newest.metadata); if (m && m.kind === 'deploy' && Date.now() - Date.parse(m.first_at) < FOLD_WINDOW_MS) prev = m; } catch { /* not ours */ }
+ }
+ if (prev) {
+ const seen = new Set(commits.map(c => c.hash));
+ const merged = commits.concat((prev.commits || []).filter(c => !seen.has(c.hash))).slice(0, MAX_COMMITS);
+ const meta = { kind: 'deploy', commits: merged, deploys: (prev.deploys || 1) + 1, first_at: prev.first_at, updated_at: nowIso };
+ db.run('UPDATE chat_messages SET message = ?, metadata = ? WHERE id = ?', [plainText(meta), JSON.stringify(meta), newest.id]);
+ return { id: newest.id, meta };
+ }
+ const meta = { kind: 'deploy', commits: commits.slice(0, MAX_COMMITS), deploys: 1, first_at: nowIso, updated_at: nowIso };
+ const res = db.saveChatMessage({ stream_id: null, user_id: null, anon_id: null, username: 'OpenVibe.Live', message: plainText(meta), message_type: 'system', is_global: true, metadata: meta });
+ return { id: res && (res.lastInsertRowid || res.lastID || res.id) || null, meta };
+}
+
+/**
+ * Announce this boot's new commits. Safe to call once after the chat server is up.
+ * @returns {Promise<{ announced: number }>}
+ */
+async function announce({ db, chatServer, log = console }) {
+ const { head, commits } = await newCommits(db);
+ if (!head || !commits.length) return { announced: 0 };
+
+ let saved;
+ try { saved = persist(db, commits); db.setSetting(SETTING, head); }
+ catch (err) { log.warn('[Deploy notice] not saved:', err.message); return { announced: 0 }; }
+
+ const payload = JSON.stringify({ type: 'update', kind: 'deploy', id: saved.id, fresh: commits.map(c => c.hash), url: '/updates', timestamp: saved.meta.updated_at, ...saved.meta });
+ const sent = new WeakSet();
+ const push = () => {
+ let n = 0;
+ for (const [ws] of chatServer.clients) {
+ if (sent.has(ws) || ws.readyState !== 1 || ws.bufferedAmount > 256 * 1024) continue;
+ try { ws.send(payload); sent.add(ws); n++; } catch { /* socket went away */ }
+ }
+ return n;
+ };
+ ATTEMPTS_MS.forEach((ms, i) => { const t = setTimeout(() => { const n = push(); if (n) log.log(`[Deploy notice] ${commits.length} commit(s) → ${n} client(s) (pass ${i + 1})`); }, ms); if (t.unref) t.unref(); });
+ return { announced: commits.length };
+}
+
+module.exports = { announce, newCommits, persist, plainText, parseLog, SETTING };
@@ -1351,106 +1351,9 @@ async function start() {
@@ -1351,106 +1351,9 @@ async function start() {
const _pcT = setInterval(_pcClean, 24 * 60 * 60 * 1000); if (_pcT.unref) _pcT.unref();
const _pcT = setInterval(_pcClean, 24 * 60 * 60 * 1000); if (_pcT.unref) _pcT.unref();
} catch { /* */ }
} catch { /* */ }
- // Broadcast recent git changes to all chat clients after startup.
- // Uses a retry loop — clients reconnect with backoff after a restart,
- // so a single 5s broadcast misses most of them. We broadcast at 5s,
- // 15s, and 30s intervals, tracking which clients already received it.
- // The message is also persisted to chat_messages so it shows in history.
- const _changelogSentTo = new Set();
- let _changelogBroadcasts = 0;
- const _changelogMaxBroadcasts = 3;
- const _changelogDelays = [5000, 15000, 30000];
-
- /** Promise wrapper around execFile — git, argv-style, never through a shell. */
- function _git(args, timeout) {
- return new Promise((resolve) => {
- execFile('git', args, { cwd: REPO_DIR, encoding: 'utf8', timeout, maxBuffer: 1024 * 1024 },
- (err, stdout) => resolve(err ? '' : String(stdout || '')));
- });
- }
-
- // These two git calls used to be execSync. At boot that blocks the event loop while the
- // server is meant to be accepting its first requests, and the whole reason this runs on a
- // 5/15/30s delay is to stay out of the way of startup. execFile keeps it off the loop.
- async function broadcastChangelog() {
- try {
- const versionTag = (await _git(['describe', '--always', '--tags'], 3000)).trim();
-
- const raw = await _git(['--no-pager', 'log', '--pretty=format:%H||%h||%s||%an||%aI', '-10'], 5000);
- if (!raw.trim()) return;
- const commits = raw.trim().split('\n').filter(Boolean).map(line => {
- const [hash, short, subject, author, date] = line.split('||');
- return { hash, short, subject, author, date };
- });
- if (commits.length === 0) return;
-
- // Build summary: use version tag if available, otherwise top 3 subjects
- const top3 = commits.slice(0, 3).map(c => c.subject).join(' · ');
- const versionPrefix = versionTag ? `(${versionTag}) ` : '';
- // Include relative time since the most recent commit
- const latestDate = commits[0].date ? new Date(commits[0].date) : null;
- let timeSuffix = '';
- if (latestDate) {
- const ago = Date.now() - latestDate.getTime();
- if (ago < 60_000) timeSuffix = ' (just now)';
- else if (ago < 3_600_000) timeSuffix = ` (${Math.round(ago / 60_000)}m ago)`;
- else if (ago < 86_400_000) timeSuffix = ` (${Math.round(ago / 3_600_000)}h ago)`;
- else timeSuffix = ` (${Math.round(ago / 86_400_000)}d ago)`;
- }
-
- const payload = {
- type: 'update',
- summary: `Server restarted ${versionPrefix}— ${top3}${timeSuffix}`,
- commits,
- url: '/updates',
- timestamp: new Date().toISOString(),
- };
- const msg = JSON.stringify(payload);
-
- // Persist to chat_messages on the FIRST broadcast only
- if (_changelogBroadcasts === 0) {
- try {
- const summaryText = `🚀 Server restarted ${versionPrefix}— ${top3}${timeSuffix}`;
- db.saveChatMessage({
- stream_id: null,
- user_id: null,
- anon_id: null,
- username: 'OpenVibe.Live',
- message: summaryText,
- message_type: 'system',
- is_global: true,
- });
- } catch (dbErr) {
- console.warn('[Server] Failed to persist changelog to chat:', dbErr.message);
- }
- }
-
- // Send only to clients that haven't received it yet
- let newRecipients = 0;
- for (const [ws, client] of chatServer.clients) {
- const clientKey = client.user?.id ? `u:${client.user.id}` : `a:${client.anonId || ws._socket?.remoteAddress}`;
- if (_changelogSentTo.has(clientKey)) continue;
- if (ws.readyState === 1 /* WebSocket.OPEN */ && ws.bufferedAmount <= 256 * 1024) {
- ws.send(msg);
- _changelogSentTo.add(clientKey);
- newRecipients++;
- }
- }
-
- _changelogBroadcasts++;
- console.log(`[Server] Changelog broadcast #${_changelogBroadcasts}: ${newRecipients} new recipients (${_changelogSentTo.size} total, ${chatServer.getTotalConnections()} connected)`);
-
- // Schedule next broadcast if we haven't hit the max
- if (_changelogBroadcasts < _changelogMaxBroadcasts) {
- const nextDelay = (_changelogDelays[_changelogBroadcasts] || 30000) - (_changelogDelays[_changelogBroadcasts - 1] || 0);
- setTimeout(broadcastChangelog, nextDelay);
- }
- } catch (err) {
- console.warn('[Server] Failed to broadcast startup changelog:', err.message);
- }
- }
-
- setTimeout(broadcastChangelog, _changelogDelays[0]);
+ // Deploy notice in chat: only commits not announced before, folded into one rolling message
+ // (server/chat/deploy-notice.js). A restart with no new code says nothing.
+ require('./chat/deploy-notice').announce({ db, chatServer }).catch((err) => console.warn('[Deploy notice] failed:', err.message));
});
});
// 8. Start stale stream heartbeat cleanup (every 60 seconds)
// 8. Start stale stream heartbeat cleanup (every 60 seconds)
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+'use strict';
+// Deploy notices fold into one rolling row, never repeat a commit, and stay silent without new code.
+const assert = require('assert');
+const dn = require('../server/chat/deploy-notice');
+
+const rows = []; const settings = {};
+const db = {
+ getSetting: (k) => settings[k], setSetting: (k, v) => { settings[k] = v; },
+ get: () => rows.filter(r => r.is_global && !r.is_deleted).slice(-1)[0] || undefined,
+ run: (_sql, [message, metadata, id]) => { const r = rows.find(x => x.id === id); r.message = message; r.metadata = metadata; },
+ saveChatMessage: (m) => { const r = { id: rows.length + 1, is_global: 1, is_deleted: 0, message_type: m.message_type, message: m.message, metadata: m.metadata ? JSON.stringify(m.metadata) : null }; rows.push(r); return { lastInsertRowid: r.id }; },
+};
+const c = (n) => ({ hash: String(n).repeat(40).slice(0, 40).replace(/[^0-9a-f]/g, 'a'), short: 'c' + n, subject: 'change ' + n, date: new Date().toISOString() });
+
+const first = dn.persist(db, [c(1)]);
+assert.equal(rows.length, 1); assert.equal(first.meta.deploys, 1);
+const second = dn.persist(db, [c(2), c(1)]); // overlap with what is already there
+assert.equal(rows.length, 1, 'a second deploy updates the same row');
+assert.equal(second.id, first.id);
+assert.deepEqual(second.meta.commits.map(x => x.short), ['c2', 'c1'], 'each commit once, newest first');
+assert.equal(second.meta.deploys, 2);
+assert.ok(/^🚀 2 updates shipped/.test(rows[0].message));
+
+rows.push({ id: 2, is_global: 1, is_deleted: 0, message_type: 'chat', message: 'hello', metadata: null }); // someone spoke
+const third = dn.persist(db, [c(3)]);
+assert.equal(rows.length, 3, 'after real chat, the next deploy starts a new notice');
+assert.equal(third.meta.deploys, 1);
+
+const old = JSON.parse(rows[2].metadata); old.first_at = new Date(Date.now() - 13 * 3600 * 1000).toISOString(); rows[2].metadata = JSON.stringify(old);
+dn.persist(db, [c(4)]);
+assert.equal(rows.length, 4, 'a notice older than the fold window is not reopened');
+
+assert.deepEqual(dn.parseLog('zz\x1fab\x1f2026\x1fnope'), [], 'malformed git lines are dropped');
+console.log('deploy notice: all checks passed');