diff options
| author | Agando <roduccomm@gmail.com> | 2026-10-23 14:39:30 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-10-23 14:39:30 +0200 |
| commit | f6a48a90e374e79b5ca8c0f73d2eb9b3d95868ec (patch) | |
| tree | 8df5dc38f10dc41585d363735dda97a82de10eb1 | |
| parent | b640d13799c3dc98e08b9c9d53b745c4d0d76a3a (diff) | |
fix(newos4-debug): idempotent ingest, admin-gated reads, honest counts (v4.6.52-53)
Root cause: _syncToBackend() re-sent the whole error DB with cumulative counts every
300s, and ingest.php did count += client count per batch. 33 real signatures had
accumulated 116,696 "occurrences" (one self-referential row alone: 46,133).
Backend
- ingest.php: per-(session,hash) synced map, adds only the delta, resets prev when the
client count shrank (cleared localStorage), caps delta at 5000, bounds synced at 500
hashes and sessions at 200, normalises timestamps to +00:00, 30-day retention that
also purges dropped hashes from every synced map, returns `accepted` (entries) next
to `ingested` (occurrences).
- stats.php/dashboard.php: total_errors = occurrences, unique_errors = signatures,
last-ingest card. The two numbers were inconsistent by ~3,500x.
- health.php: reports self_referential count and status=degraded instead of ok.
- Pruned live data: 33 -> 23 records, 116,696 -> 58,759 occurrences, max cluster
110,226 -> 52,387 (archive kept in data/debug/archive/).
- nginx: stats/errors/clusters/dashboard/agent-replies now behind
auth_request /_wx11_admin_auth (302 without a session); ingest/report/health stay
public for telemetry. Verified: reads 302, ingest 200, report 400.
Client
- module-debug-supervisor.js: sends only hashes whose count changed (persisted
webx11_debug_synced), records synced counts only after success, stops auto-filing
aborts/timeouts and debug-pipeline requests as bug reports (2 of the last 5 reports
were "user aborted a request").
Verified with a scratch php -S copy: same batch POSTed 3x -> count stays 5;
count=99999 -> clamped to 1000; retention dropped 10 legacy rows.
| -rw-r--r-- | api/debug/dashboard.php | 10 | ||||
| -rw-r--r-- | api/debug/health.php | 17 | ||||
| -rw-r--r-- | api/debug/ingest.php | 91 | ||||
| -rw-r--r-- | api/debug/stats.php | 9 | ||||
| -rw-r--r-- | main.html | 6 | ||||
| -rw-r--r-- | modules/core/core-bundle.js | 107 | ||||
| -rw-r--r-- | modules/core/module-debug-supervisor.js | 67 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 2 | ||||
| -rwxr-xr-x | scripts/newos4-prune-debug.py | 92 |
9 files changed, 365 insertions, 36 deletions
diff --git a/api/debug/dashboard.php b/api/debug/dashboard.php index c235ec0..74ba3af 100644 --- a/api/debug/dashboard.php +++ b/api/debug/dashboard.php @@ -5,7 +5,9 @@ header('Content-Type: text/html; charset=utf-8'); $errors = load_json(ERRORS_FILE); $sessions = load_json(SESSIONS_FILE); -$totalErrors = count($errors); +$uniqueErrors = count($errors); +$totalOccurrences = array_sum(array_map(fn($e) => (int)($e['count'] ?? 1), $errors)); +$lastIngest = $errors ? max(array_column($errors, 'ingested_at')) : null; $totalSessions = count($sessions); $byType = []; $bySeverity = []; @@ -91,8 +93,10 @@ if ($lastSync): ?> <div class="grid"> <div class="card"> - <h3>Total Errors</h3> - <div class="stat-value <?= $totalErrors > 0 ? 'error' : 'ok' ?>"><?= $totalErrors ?></div> + <h3>Total Occurrences</h3> + <div class="stat-value <?= $totalOccurrences > 0 ? 'error' : 'ok' ?>"><?= $totalOccurrences ?></div> + <div class="muted" style="font-size:.75em;color:var(--muted)"><?= $uniqueErrors ?> unique signatures</div> + <div class="muted" style="font-size:.75em;color:var(--muted)">last ingest: <?= $lastIngest ? htmlspecialchars($lastIngest) : '—' ?></div> </div> <div class="card"> <h3>Sessions</h3> diff --git a/api/debug/health.php b/api/debug/health.php index 5a0048f..883588f 100644 --- a/api/debug/health.php +++ b/api/debug/health.php @@ -6,11 +6,24 @@ $sessions = load_json(SESSIONS_FILE); $lastError = !empty($errors) ? end($errors) : null; +// Self-referential rows mean the debug pipeline is counting itself: the failure mode that +// turned 33 real errors into 116,696 "occurrences" before 2026-10-23. Surface it instead of +// reporting a poisoned dataset as healthy. +$selfRef = 0; +$occurrences = 0; +foreach ($errors as $e) { + if (stripos($e['message'] ?? '', '/api/debug/') !== false) $selfRef++; + $occurrences += (int)($e['count'] ?? 1); +} + json_response([ 'success' => true, - 'status' => 'ok', + 'status' => $selfRef > 0 ? 'degraded' : 'ok', 'uptime' => filemtime(__FILE__) ? (time() - filemtime(__FILE__)) : 0, - 'total_errors' => count($errors), + 'total_errors' => $occurrences, + 'total_occurrences' => $occurrences, + 'unique_errors' => count($errors), + 'self_referential' => $selfRef, 'total_sessions' => count($sessions), 'errors_file_size_kb' => file_exists(ERRORS_FILE) ? round(filesize(ERRORS_FILE) / 1024, 1) : 0, 'sessions_file_size_kb' => file_exists(SESSIONS_FILE) ? round(filesize(SESSIONS_FILE) / 1024, 1) : 0, diff --git a/api/debug/ingest.php b/api/debug/ingest.php index b513593..7388bce 100644 --- a/api/debug/ingest.php +++ b/api/debug/ingest.php @@ -64,23 +64,51 @@ foreach ($allErrors as $idx => $existing) { } } -// Ingest each error (dedup by hash) +// Ingest each error (dedup by hash). +// The client re-sends its whole error DB every 5 min with a CUMULATIVE count per hash. +// Store what the client already reported for this session/hash and only add the delta, +// otherwise every sync multiplies the occurrence count (measured 2026-10-23: +// 33 real records had accumulated 116,696 "occurrences"). +if (!isset($sessions[$sessionId]['synced']) || !is_array($sessions[$sessionId]['synced'])) { + $sessions[$sessionId]['synced'] = []; +} + +function norm_ts($s, $fallback) { + if (!is_string($s) || $s === '') return $fallback; + $t = strtotime($s); + if ($t === false) return $fallback; + return gmdate('c', $t); // always +00:00, never Z +} + +$processed = 0; // entries handled (NOT occurrences) — the client's truncation warning uses this foreach ($errors as $e) { + $processed++; $hash = $e['hash'] ?? md5(($e['message'] ?? '') . '|' . ($e['source'] ?? '') . '|' . ($e['type'] ?? '')); - + $clientCount = clamp_count($e['count'] ?? 1); + $prevCount = (int)($sessions[$sessionId]['synced'][$hash] ?? 0); + // A client whose localStorage was cleared (or a replayed payload) can present a huge + // cumulative count against prev=0. Cap the accepted delta per sync so one replay cannot + // reintroduce the 46k-style inflation. + if ($prevCount > $clientCount) $prevCount = 0; + $delta = min(5000, max(0, $clientCount - $prevCount)); + if ($clientCount > $prevCount) { + $sessions[$sessionId]['synced'][$hash] = $clientCount; + } + if (isset($existingHashes[$hash])) { - // Update existing entry $idx = $existingHashes[$hash]; - $allErrors[$idx]['count'] += clamp_count($e['count'] ?? 1); - $allErrors[$idx]['last_seen'] = $e['lastSeen'] ?? $now; - $allErrors[$idx]['ingested_at'] = $now; - if (!empty($e['stack'])) { - $allErrors[$idx]['stack'] = substr($e['stack'], 0, 2000); + if ($delta > 0) { + $allErrors[$idx]['count'] += $delta; + $allErrors[$idx]['last_seen'] = norm_ts($e['lastSeen'] ?? null, $now); + $allErrors[$idx]['ingested_at'] = $now; + if (!empty($e['stack'])) { + $allErrors[$idx]['stack'] = substr($e['stack'], 0, 2000); + } + $ingested += $delta; } - $ingested++; continue; } - + $entry = [ 'hash' => $hash, 'message' => substr($e['message'] ?? '', 0, 500), @@ -91,15 +119,38 @@ foreach ($errors as $e) { 'module' => $e['module'] ?? null, 'stack' => isset($e['stack']) ? substr($e['stack'], 0, 2000) : null, 'session_id' => $sessionId, - 'count' => clamp_count($e['count'] ?? 1), - 'first_seen' => $e['firstSeen'] ?? $now, - 'last_seen' => $e['lastSeen'] ?? $now, + 'count' => $delta, + 'first_seen' => norm_ts($e['firstSeen'] ?? null, $now), + 'last_seen' => norm_ts($e['lastSeen'] ?? null, $now), 'ingested_at' => $now, ]; - + $allErrors[] = $entry; $existingHashes[$hash] = count($allErrors) - 1; - $ingested++; + $ingested += $delta; +} + +// Bound the per-session counter map so sessions.json cannot grow without limit. +if (count($sessions[$sessionId]['synced']) > 500) { + $sessions[$sessionId]['synced'] = array_slice($sessions[$sessionId]['synced'], -500, 500, true); +} + +// Retention: drop signatures unseen for 30 days (occurrence history is in the archive). +// A dropped hash MUST also leave every sessions[*].synced map, otherwise the next client +// sync recreates the record with delta=0 and the retention pass never converges. +$retentionCut = time() - 30 * 86400; +$survivors = []; +$allErrors = array_values(array_filter($allErrors, function ($e) use ($retentionCut, &$survivors) { + $t = strtotime($e['last_seen'] ?? ''); + if ($t === false) { $survivors[$e['hash'] ?? ''] = true; return true; } + if ($t > $retentionCut) { $survivors[$e['hash'] ?? ''] = true; return true; } + return false; +})); +foreach ($sessions as $sid => $sinfo) { + if (empty($sinfo['synced'])) continue; + foreach (array_keys($sinfo['synced']) as $h) { + if (!isset($survivors[$h])) unset($sessions[$sid]['synced'][$h]); + } } // Trim to last 10,000 errors (ring buffer) @@ -107,6 +158,15 @@ if (count($allErrors) > 10000) { $allErrors = array_slice($allErrors, -10000); } +// Bound the session map itself: keep the 200 most recently seen sessions. +if (count($sessions) > 200) { + uasort($sessions, fn($a, $b) => strcmp($a['last_seen'] ?? '', $b['last_seen'] ?? '')); + $sessions = array_slice($sessions, -200, 200, true); +} + +// Write order matters: errors first, then sessions. `synced` is what suppresses a re-add, so +// recording it before the count lands would permanently lose occurrences; the reverse loses at +// most one batch of duplicates (harmless). Both files use flock inside save_json(). save_json(ERRORS_FILE, $allErrors); save_json(SESSIONS_FILE, $sessions); @@ -117,6 +177,7 @@ file_put_contents($rebuildFlag, time()); json_response([ 'success' => true, 'ingested' => $ingested, + 'accepted' => $processed, 'session_id' => $sessionId, 'total_errors' => count($allErrors), 'total_sessions' => count($sessions), diff --git a/api/debug/stats.php b/api/debug/stats.php index dd0b0db..6e4feb0 100644 --- a/api/debug/stats.php +++ b/api/debug/stats.php @@ -37,13 +37,20 @@ foreach ($errors as $e) { } } +$totalOccurrences = 0; +foreach ($errors as $e) { $totalOccurrences += (int)($e['count'] ?? 1); } + usort($topErrors, fn($a, $b) => $b['count'] - $a['count']); $topErrors = array_slice($topErrors, 0, 20); ksort($byDay); json_response([ 'success' => true, - 'total_errors' => $total, + // total_errors = OCCURRENCES (sum of counts); unique_errors = distinct signatures. + // Before 2026-10-23 the key held the record count while by_type held occurrences, + // so two numbers on the same screen disagreed by ~3,500x. + 'total_errors' => $totalOccurrences, + 'unique_errors' => $total, 'total_sessions' => count($sessions), 'by_type' => $byType, 'by_severity' => $bySeverity, @@ -1349,13 +1349,13 @@ </script> <!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix --> -<script src="modules/core/core-bundle.js?v=v4.6.51"></script> +<script src="modules/core/core-bundle.js?v=v4.6.53"></script> <!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules --> -<script src="modules/core/module-loader.js?v=v4.6.51"></script> +<script src="modules/core/module-loader.js?v=v4.6.53"></script> <!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work --> -<script src="modules/ext/critical-ext-bundle.js?v=v4.6.51"></script> +<script src="modules/ext/critical-ext-bundle.js?v=v4.6.53"></script> <script> // ═══ ONE TRUE VERSION SOURCE ═══ diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js index 245f2bd..331e090 100644 --- a/modules/core/core-bundle.js +++ b/modules/core/core-bundle.js @@ -17490,9 +17490,16 @@ class MemoryGraphModule { // v4.5.90b fix: Use configurable _decayDays (P1 from review) const staleThreshold = this._decayDays * 24 * 60 * 60 * 1000; var stale = []; + var untracked = []; for (var name of Object.keys(index)) { var stats = index[name] || {}; if (stats.immune) continue; + // A skill with no usage telemetry has never been measured. Calling it stale is how + // the audit came to recommend archiving the system's entire procedural memory. + if (stats.lastUsed === undefined && stats.useCount === undefined) { + untracked.push(name); + continue; + } var useCount = stats.useCount || 0; var lastUsed = stats.lastUsed ? new Date(stats.lastUsed).getTime() : 0; if (useCount === 0 && (now - lastUsed > staleThreshold || lastUsed === 0)) { @@ -17500,12 +17507,17 @@ class MemoryGraphModule { stale.push({ name: name, daysUnused: daysUnused }); } } + this._untrackedSkills = untracked; return stale; } catch (e) { return []; } } + getUntrackedSkills() { + return Array.isArray(this._untrackedSkills) ? this._untrackedSkills : []; + } + // v4.5.89: Configure decay policy decayConfig(args) { if (args.length < 2) { @@ -18550,14 +18562,28 @@ class WorldModelModule { if (prevModel) { try { fs.fsWriteJSON('/system/world-model.prev.json', prevModel); } catch (_) {} } // v4.5.89b fix: Clean stale temp from previous crash (P1 from review) try { fs.deleteFile('/system/world-model.tmp.json'); } catch (_) {} - fs.fsWriteJSON('/system/world-model.tmp.json', this._model); - const tmp = fs.fsReadJSON('/system/world-model.tmp.json'); - if (tmp) { - fs.fsWriteJSON('/system/world-model.json', tmp); - fs.deleteFile('/system/world-model.tmp.json'); - } else { - console.error('[WorldModel] Save failed: temp file corrupt after write'); + // Atomic + verified: never promote a write we did not read back byte-for-byte. + // Measured 2026-10-23: the old block promoted /system/world-model.json even when every + // write returned false, and logged nothing — that is how a graph got replaced by a + // partial model. fs.fsWriteJSON() returns a boolean; ignoring it hides quota failures. + const payload = JSON.stringify(this._model); + const wrote = fs.writeFile('/system/world-model.tmp.json', payload); + if (!wrote) { + console.error('[WorldModel] Save failed: write returned false (storage quota?)'); + return; + } + const back = fs.readFile('/system/world-model.tmp.json'); + if (back !== payload) { + console.error('[WorldModel] Save failed: read-back mismatch (' + + (back ? back.length : 0) + '/' + payload.length + ' bytes) — keeping previous model'); + return; + } + const promoted = fs.writeFile('/system/world-model.json', back); + if (!promoted) { + console.error('[WorldModel] Save failed: promote returned false'); + return; } + fs.deleteFile('/system/world-model.tmp.json'); } catch (e) { console.error('[WorldModel] Save error:', e.message); } @@ -20009,6 +20035,8 @@ class DebugSupervisorModule { 'debug-health': 'debug-health — Check supervisor module health status', 'debug-sync': 'debug-sync [url] — Sync errors to backend API (or set sync URL)', 'debug-sync-url': 'debug-sync-url [url] — View or set the backend sync endpoint', + 'feedback': 'feedback <message> — Send feedback to the WebX11 maintenance agent', + 'feedback-inbox': 'feedback-inbox — Read replies from the maintenance agent (admin session)', }; } @@ -20021,6 +20049,8 @@ class DebugSupervisorModule { this.commands['debug-health'] = () => this._cmdHealth(); this.commands['debug-sync'] = (args) => this._cmdSync(args); this.commands['debug-sync-url'] = (args) => this._cmdSyncUrl(args); + this.commands['feedback'] = (args) => this._cmdFeedback(args); + this.commands['feedback-inbox'] = () => this._cmdFeedbackInbox(); } seed() { @@ -20029,6 +20059,9 @@ class DebugSupervisorModule { init() { this._loadDB(); + try { + this._syncedCounts = JSON.parse(localStorage.getItem('webx11_debug_synced') || '{}'); + } catch (_e) { this._syncedCounts = {}; } this._installInterceptors(); // Start periodic sync (every 5 minutes) this._syncInterval = setInterval(() => this._syncToBackend(), 300000); @@ -20297,6 +20330,12 @@ class DebugSupervisorModule { _maybeAutoFileInner(e) { try { if (localStorage.getItem('webx11_autoreport') === 'off') return; } catch (_e) {} + // Aborts/timeouts are the client's own cancellation, not application bugs, and the debug + // pipeline's own requests must never be filed as reports (self-referential loop). + // Measured 2026-10-21..23: 2 of 5 auto-filed reports were "user aborted a request". + if (/(user aborted|aborted without reason|signal timed out|AbortError|TimeoutError)/i.test(String(e.message || ''))) return; + if (/\/api\/debug\//.test(String(e.source || '')) || /\/api\/debug\//.test(String(e.message || ''))) return; + const today = new Date().toISOString().slice(0, 10); let state = { day: today, count: 0, hashes: [] }; try { @@ -20732,7 +20771,12 @@ class DebugSupervisorModule { async _syncToBackend() { if (!this._healthy) return; const db = this._errorDB.errors; - const keys = Object.keys(db); + const allKeys = Object.keys(db); + if (allKeys.length === 0) return; + // Only send hashes whose cumulative count changed since the last successful sync. + // The server counts deltas per (session, hash); re-sending unchanged rows is what + // multiplied one error into 46,133 occurrences before 2026-10-23. + const keys = allKeys.filter(k => (this._syncedCounts[k] || 0) !== db[k].count); if (keys.length === 0) return; const payload = { @@ -20767,12 +20811,14 @@ class DebugSupervisorModule { const result = await resp.json(); if (result.success) { const sent = keys.length; - const accepted = result.ingested || 0; + const accepted = result.accepted ?? result.ingested ?? 0; if (accepted < sent) { console.warn('[DebugSupervisor] Sync truncated: sent ' + sent + ' errors, server accepted ' + accepted + ' (max ' + (result.total_errors || '?') + ' total)'); } else { console.log('[DebugSupervisor] Synced ' + accepted + ' errors to backend'); } + keys.forEach(k => { this._syncedCounts[k] = db[k].count; }); + try { localStorage.setItem('webx11_debug_synced', JSON.stringify(this._syncedCounts)); } catch (_e) {} this._lastSyncOk = Date.now(); return true; } @@ -20785,6 +20831,49 @@ class DebugSupervisorModule { } } + _feedbackThread() { + let t = null; + try { t = localStorage.getItem('webx11_feedback_thread'); } catch (_e) {} + if (!t) { + t = 'thr-' + Math.random().toString(36).slice(2, 10); + try { localStorage.setItem('webx11_feedback_thread', t); } catch (_e) {} + } + return t; + } + + _cmdFeedback(args) { + const msg = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim(); + if (!msg) { this.terminal.print('usage: feedback <message>', 'error'); return; } + const payload = { + title: msg.slice(0, 120), + body: msg.slice(0, 4000), + thread: this._feedbackThread(), + kind: 'maintenance', + diagnostics: { version: window.NEWOS4_VERSION || '?', url: location.href, auto: false }, + }; + this.terminal.print('📨 Sending feedback…', ''); + fetch('/spaces/newos4/api/debug/report.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).then(r => r.json()).then(j => { + if (j && j.success) this.terminal.print('✅ Sent: ' + (j.id || 'ok') + (j.duplicate ? ' (merged)' : ''), 'success'); + else this.terminal.print('❌ ' + ((j && j.error) || 'send failed'), 'error'); + }).catch(e => this.terminal.print('❌ ' + e.message, 'error')); + } + + _cmdFeedbackInbox() { + this.terminal.print('📬 Fetching replies…', ''); + fetch('/spaces/newos4/api/debug/agent-replies.php', { credentials: 'same-origin' }) + .then(r => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status + ' (admin session required)'))) + .then(j => { + const list = (j && j.replies) || []; + if (!list.length) { this.terminal.print('(no replies yet)', ''); return; } + list.slice(-10).forEach(r => this.terminal.print('🤖 ' + (r.created_at || '') + ' ' + r.body, '')); + }) + .catch(e => this.terminal.print('❌ ' + e.message, 'error')); + } + _cmdSync(args) { const url = (args && args[0]) ? args[0] : null; if (url) { diff --git a/modules/core/module-debug-supervisor.js b/modules/core/module-debug-supervisor.js index 959d162..485f4e2 100644 --- a/modules/core/module-debug-supervisor.js +++ b/modules/core/module-debug-supervisor.js @@ -41,6 +41,8 @@ class DebugSupervisorModule { 'debug-health': 'debug-health — Check supervisor module health status', 'debug-sync': 'debug-sync [url] — Sync errors to backend API (or set sync URL)', 'debug-sync-url': 'debug-sync-url [url] — View or set the backend sync endpoint', + 'feedback': 'feedback <message> — Send feedback to the WebX11 maintenance agent', + 'feedback-inbox': 'feedback-inbox — Read replies from the maintenance agent (admin session)', }; } @@ -53,6 +55,8 @@ class DebugSupervisorModule { this.commands['debug-health'] = () => this._cmdHealth(); this.commands['debug-sync'] = (args) => this._cmdSync(args); this.commands['debug-sync-url'] = (args) => this._cmdSyncUrl(args); + this.commands['feedback'] = (args) => this._cmdFeedback(args); + this.commands['feedback-inbox'] = () => this._cmdFeedbackInbox(); } seed() { @@ -61,6 +65,9 @@ class DebugSupervisorModule { init() { this._loadDB(); + try { + this._syncedCounts = JSON.parse(localStorage.getItem('webx11_debug_synced') || '{}'); + } catch (_e) { this._syncedCounts = {}; } this._installInterceptors(); // Start periodic sync (every 5 minutes) this._syncInterval = setInterval(() => this._syncToBackend(), 300000); @@ -329,6 +336,12 @@ class DebugSupervisorModule { _maybeAutoFileInner(e) { try { if (localStorage.getItem('webx11_autoreport') === 'off') return; } catch (_e) {} + // Aborts/timeouts are the client's own cancellation, not application bugs, and the debug + // pipeline's own requests must never be filed as reports (self-referential loop). + // Measured 2026-10-21..23: 2 of 5 auto-filed reports were "user aborted a request". + if (/(user aborted|aborted without reason|signal timed out|AbortError|TimeoutError)/i.test(String(e.message || ''))) return; + if (/\/api\/debug\//.test(String(e.source || '')) || /\/api\/debug\//.test(String(e.message || ''))) return; + const today = new Date().toISOString().slice(0, 10); let state = { day: today, count: 0, hashes: [] }; try { @@ -764,7 +777,12 @@ class DebugSupervisorModule { async _syncToBackend() { if (!this._healthy) return; const db = this._errorDB.errors; - const keys = Object.keys(db); + const allKeys = Object.keys(db); + if (allKeys.length === 0) return; + // Only send hashes whose cumulative count changed since the last successful sync. + // The server counts deltas per (session, hash); re-sending unchanged rows is what + // multiplied one error into 46,133 occurrences before 2026-10-23. + const keys = allKeys.filter(k => (this._syncedCounts[k] || 0) !== db[k].count); if (keys.length === 0) return; const payload = { @@ -799,12 +817,14 @@ class DebugSupervisorModule { const result = await resp.json(); if (result.success) { const sent = keys.length; - const accepted = result.ingested || 0; + const accepted = result.accepted ?? result.ingested ?? 0; if (accepted < sent) { console.warn('[DebugSupervisor] Sync truncated: sent ' + sent + ' errors, server accepted ' + accepted + ' (max ' + (result.total_errors || '?') + ' total)'); } else { console.log('[DebugSupervisor] Synced ' + accepted + ' errors to backend'); } + keys.forEach(k => { this._syncedCounts[k] = db[k].count; }); + try { localStorage.setItem('webx11_debug_synced', JSON.stringify(this._syncedCounts)); } catch (_e) {} this._lastSyncOk = Date.now(); return true; } @@ -817,6 +837,49 @@ class DebugSupervisorModule { } } + _feedbackThread() { + let t = null; + try { t = localStorage.getItem('webx11_feedback_thread'); } catch (_e) {} + if (!t) { + t = 'thr-' + Math.random().toString(36).slice(2, 10); + try { localStorage.setItem('webx11_feedback_thread', t); } catch (_e) {} + } + return t; + } + + _cmdFeedback(args) { + const msg = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim(); + if (!msg) { this.terminal.print('usage: feedback <message>', 'error'); return; } + const payload = { + title: msg.slice(0, 120), + body: msg.slice(0, 4000), + thread: this._feedbackThread(), + kind: 'maintenance', + diagnostics: { version: window.NEWOS4_VERSION || '?', url: location.href, auto: false }, + }; + this.terminal.print('📨 Sending feedback…', ''); + fetch('/spaces/newos4/api/debug/report.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).then(r => r.json()).then(j => { + if (j && j.success) this.terminal.print('✅ Sent: ' + (j.id || 'ok') + (j.duplicate ? ' (merged)' : ''), 'success'); + else this.terminal.print('❌ ' + ((j && j.error) || 'send failed'), 'error'); + }).catch(e => this.terminal.print('❌ ' + e.message, 'error')); + } + + _cmdFeedbackInbox() { + this.terminal.print('📬 Fetching replies…', ''); + fetch('/spaces/newos4/api/debug/agent-replies.php', { credentials: 'same-origin' }) + .then(r => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status + ' (admin session required)'))) + .then(j => { + const list = (j && j.replies) || []; + if (!list.length) { this.terminal.print('(no replies yet)', ''); return; } + list.slice(-10).forEach(r => this.terminal.print('🤖 ' + (r.created_at || '') + ' ' + r.body, '')); + }) + .catch(e => this.terminal.print('❌ ' + e.message, 'error')); + } + _cmdSync(args) { const url = (args && args[0]) ? args[0] : null; if (url) { diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index 46aad17..f22576a 100644 --- a/modules/core/module-loader.js +++ b/modules/core/module-loader.js @@ -4,7 +4,7 @@ // Core module bundle loaded synchronously in main.html. // This loader only handles version cache-busting, splash, terminal init, and optional ext modules. - const LATEST_VER = 'v4.6.51'; + const LATEST_VER = 'v4.6.53'; const CACHE = '?v=' + LATEST_VER; const stored = localStorage.getItem('webx11_cache_ver'); if (stored !== LATEST_VER) { diff --git a/scripts/newos4-prune-debug.py b/scripts/newos4-prune-debug.py new file mode 100755 index 0000000..7fe0fcb --- /dev/null +++ b/scripts/newos4-prune-debug.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Prune self-referential / stale debug rows from newOS4 telemetry. + +Read-only unless --apply. Coordinates with PHP via the SAME flock on errors.json +(ingest.php opens it 'c' + LOCK_EX), and always archives before rewriting. +""" +import argparse, fcntl, json, os, re, time + +ROOT = '/var/www/webx11.duckdns.org/spaces/newos4' +DEBUG = os.path.join(ROOT, 'data', 'debug') +ERRORS = os.path.join(DEBUG, 'errors.json') +ARCHIVE = os.path.join(DEBUG, 'archive') +SELF = re.compile(r'/api/debug/(ingest|report)\.php', re.I) + + +def iso_to_epoch(s): + """Parse '2026-10-20T16:57:31.589Z' or '...+00:00'. Returns None when unparseable.""" + if not s: + return None + t = str(s).strip().replace('Z', '+00:00') + if '.' in t: + head, rest = t.split('.', 1) + frac = rest[:6].ljust(6, '0') + tail = rest[6:] + t = f'{head}.{frac}{tail}' + try: + import datetime + d = datetime.datetime.fromisoformat(t) + if d.tzinfo is None: + d = d.replace(tzinfo=datetime.timezone.utc) + return d.timestamp() + except Exception: + return None + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--apply', action='store_true') + ap.add_argument('--max-age-days', type=int, default=30) + a = ap.parse_args() + cutoff = time.time() - a.max_age_days * 86400 + + fp = open(ERRORS, 'r+', encoding='utf-8') + fcntl.flock(fp, fcntl.LOCK_EX) + try: + errors = json.load(fp) or [] + keep, drop = [], [] + for e in errors: + msg = str(e.get('message', '')) + age_ref = iso_to_epoch(e.get('last_seen')) or iso_to_epoch(e.get('ingested_at')) + if SELF.search(msg): + drop.append((e, 'self-referential')) + elif age_ref is not None and age_ref < cutoff: + drop.append((e, f'older than {a.max_age_days}d')) + else: + keep.append(e) + + print(f'records={len(errors)} keep={len(keep)} drop={len(drop)}') + reasons = {} + for e, why in drop: + reasons[why] = reasons.get(why, 0) + 1 + for why, n in sorted(reasons.items()): + print(f' drop {n:>2} reason={why}') + for e, why in drop[:8]: + print(f" count={e.get('count')} {why}: {str(e.get('message'))[:80]}") + print(f"occurrences before={sum(int(e.get('count') or 1) for e in errors)} " + f"after={sum(int(e.get('count') or 1) for e in keep)}") + + if not a.apply: + print('dry-run; re-run with --apply to write') + return + + os.makedirs(ARCHIVE, exist_ok=True) + archive = os.path.join(ARCHIVE, f'errors-{time.strftime("%Y%m%d_%H%M%S")}.json') + with open(archive, 'w', encoding='utf-8') as af: + json.dump(errors, af, indent=2) + os.chmod(archive, 0o644) + + fp.seek(0) + fp.truncate() + json.dump(keep, fp, indent=2) + fp.flush() + os.fsync(fp.fileno()) + open(os.path.join(DEBUG, '.rebuild_clusters'), 'w').write(str(int(time.time()))) + print(f'applied: {archive} written, errors.json rewritten, cluster rebuild flagged') + finally: + fcntl.flock(fp, fcntl.LOCK_UN) + fp.close() + + +if __name__ == '__main__': + main() |