diff options
| -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() |