From 66d30f637b763c6f4c5061e4fde306cc66ef7f59 Mon Sep 17 00:00:00 2001 From: Agando Date: Mon, 26 Oct 2026 13:03:11 +0100 Subject: feat(feedback): review dashboard, public guide, terminal commands (v4.6.94) Dashboard (/admin/feedback.html, admin-gated) - User-feedback tab over the live :8908 API: list, filter, search, publish reply, archive/reopen. Reply is what the reporter sees in /stear -> Your reports & replies. - Agent-items tab: 3 plans parked at autonomy:review + 19 defect reports, one click each. - Snapshot age line + stale warning (>30 min), and the last apply outcome with failures. - Existing /admin/ tab relabelled "Client error log" (it renders feedback_log.json, not the feedback store) and now links to the review dashboard. Decision path - admin/api/feedback-decide.php: exact-match nginx location + auth_request, Origin check, item_id allowlist, append-only decisions.jsonl with fwrite+fsync verification. - scripts/newos4-feedback-apply.py (darkbot): last decision per item wins, superseded rows recorded, reject reverts safe->review, per-item try/except, ledger saved per mutation, realpath containment, exact standalone-line marker flip, file mode preserved, queue rotation at 1 MB. Steering directives now carry the report title. - scripts/newos4-feedback-collect.py: snapshot for the dashboard, 40-line plan head, steering shape assert (-1 = unknown), recent_apply outcomes. - newos4-feedback-sync.sh: flock -n so overlapping runs cannot double-apply. Guide + terminal - feedback-guide.html: which channel (terminal feedback / /stear / automatic reports), expectations, exact payload per channel, how to write a report that gets fixed. Linked from /stear, index.html footer and help.html. - newOS4: feedback list|read|reply|archive|reopen|stats|dashboard|help (+ feedback-admin alias), admin-token derived per call, argument-validated dispatch so a sentence like "feedback list is broken on mobile" is still a message, "feedback -- " forces it. - help.html: 8 rows, coverage 362/362. Review: glm-5.3 REWORK -> 15 findings fixed, 5 refuted with evidence, 2 doc-only. Guide corrected (invented "60 MB per batch" replaced by the real 512 KB/5-per-10-min limits). --- modules/core/module-debug-supervisor.js | 159 +++++++++++++++++++++++++++++++- 1 file changed, 158 insertions(+), 1 deletion(-) (limited to 'modules/core/module-debug-supervisor.js') diff --git a/modules/core/module-debug-supervisor.js b/modules/core/module-debug-supervisor.js index 25e0566..ed97aff 100644 --- a/modules/core/module-debug-supervisor.js +++ b/modules/core/module-debug-supervisor.js @@ -63,6 +63,9 @@ class DebugSupervisorModule { this.commands['feedback-inbox'] = () => this._cmdFeedbackInbox(); this.commands['hermes-poll'] = () => this._cmdFeedbackInbox(); this.commands['hermes-reset'] = () => this._cmdChatReset(); + // Admin management of stored /stear reports (2026-10-26). `feedback ` still + // sends a chat message: a subcommand needs plausible arguments (see _cmdFeedback). + this.commands['feedback-admin'] = (args) => this._fbDispatch(args); } seed() { @@ -960,9 +963,25 @@ class DebugSupervisorModule { } _cmdFeedback(args) { - const msg = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim(); + const raw = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim(); + const argv = raw.split(/\s+/); + const sub = (argv[0] || '').toLowerCase(); + // A subcommand only when its ARGUMENTS are plausible. Measured failure without this + // check: "feedback list is broken on mobile" became a filtered list of reports. + const STATUS = ['', 'new', 'read', 'archived']; + const isNum = (v) => /^\d+$/.test(v || ''); + const forced = raw.startsWith('--'); + const isSub = !forced && ( + (sub === 'list' && STATUS.indexOf((argv[1] || '').toLowerCase()) >= 0) || + ((sub === 'stats' || sub === 'dashboard' || sub === 'help') && argv.length === 1) || + (['read', 'archive', 'reopen'].indexOf(sub) >= 0 && isNum(argv[1])) || + (sub === 'reply' && isNum(argv[1]) && argv.length >= 3) + ); + if (isSub) { this._fbDispatch(args); return; } + const msg = forced ? raw.replace(/^--\s*/, '') : raw; if (!msg) { this.terminal.print('usage: hermes (alias: feedback )', 'error'); + this.terminal.print(' feedback list|read|reply|archive|reopen|stats|dashboard|help', ''); return; } this.terminal.print('πŸ“¨ Sending…', ''); @@ -998,6 +1017,144 @@ class DebugSupervisorModule { }); } + // ── feedback management (admin) ────────────────────────────────────────── + _fbHelp() { + const rows = [ + 'feedback send a message to the operator agent (alias: hermes)', + 'feedback list [status] list stored /stear reports (new|read|archived, default all)', + 'feedback read show one report in full', + 'feedback reply publish a reply (status becomes read, user sees it)', + 'feedback archive archive a report', + 'feedback reopen move a report back to new', + 'feedback stats counts per status', + 'feedback dashboard open /admin/feedback.html in a new tab', + 'feedback-inbox read agent replies from the chat bridge', + ]; + this.terminal.print('feedback β€” commands', ''); + rows.forEach(r => this.terminal.print(' ' + r, '')); + this.terminal.print(' admin subcommands need a valid admin session (log in at /admin-login.html)', ''); + } + + async _fbToken() { + if (!this._fbKey) { + const r = await fetch('/admin/api/admin-token.php', { credentials: 'same-origin' }); + if (!r.ok) throw new Error(r.status === 302 || r.redirected ? 'admin session required' : 'token HTTP ' + r.status); + const d = await r.json(); + if (!d || !d.token) throw new Error('no admin token in response'); + this._fbKey = d.token; + } + const w = Math.floor(Date.now() / 1000 / 300) * 300; + const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(this._fbKey + ':' + w)); + return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); + } + + async _fbCall(path, opts) { + const tok = await this._fbToken(); + const o = Object.assign({ credentials: 'same-origin' }, opts || {}); + o.headers = Object.assign({ 'X-Admin-Token': tok, 'Content-Type': 'application/json' }, + (opts && opts.headers) || {}); + const r = await fetch('/api/feedback' + path, o); + if (r.status === 403) { this._fbKey = null; throw new Error('admin token rejected (rejected window, retry)'); } + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); + } + + _fbPad(s, n) { s = String(s == null ? '' : s); return s.length >= n ? s.slice(0, n - 1) + '…' : s + ' '.repeat(n - s.length); } + + _fbFail(e) { this.terminal.print('❌ feedback: ' + (e && e.message ? e.message : e), 'error'); } + + _fbList(status) { + const q = status ? '?status=' + encodeURIComponent(status) + '&limit=50' : '?limit=50'; + this.terminal.print('πŸ“‹ loading…', ''); + this._fbCall('/list' + q).then(d => { + const items = d.items || []; + if (!items.length) { this.terminal.print('no reports' + (status ? ' with status ' + status : ''), ''); return; } + this.terminal.print(this._fbPad('#', 6) + this._fbPad('status', 10) + this._fbPad('cat', 10) + + this._fbPad('user', 12) + this._fbPad('date', 12) + 'message', ''); + items.forEach(it => this.terminal.print( + this._fbPad(it.id, 6) + this._fbPad(it.status, 10) + this._fbPad(it.category, 10) + + this._fbPad(it.username || 'anon', 12) + this._fbPad(String(it.created_at || '').slice(0, 10), 12) + + String(it.message || '').replace(/\s+/g, ' ').slice(0, 60), '')); + const total = (typeof d.total === 'number' ? d.total : items.length); + const more = total > items.length ? ' (showing ' + items.length + ' of ' + total + + ' β€” feedback list ' + (status || '') + ' with a higher limit via the dashboard)' : ''; + this.terminal.print(total + ' item(s)' + more + ' β€” feedback read , reply ', ''); + }).catch(e => this._fbFail(e)); + } + + _fbRead(id) { + if (!id) { this.terminal.print('usage: feedback read ', 'error'); return; } + const want = String(id); + // The server understands ?id= since v4.6.93's API change; an older process ignores it and + // returns the newest row instead, so verify the id before trusting the answer. + this._fbCall('/list?limit=1&id=' + encodeURIComponent(want)).then(d => { + const hit = (d.items || []).find(x => String(x.id) === want); + if (hit) return hit; + return this._fbCall('/list?limit=200').then(d2 => + (d2.items || []).find(x => String(x.id) === want) || null); + }).then(it => { + if (!it) { this.terminal.print('no report #' + want, 'error'); return; } + this.terminal.print('#' + it.id + ' ' + it.status + ' ' + it.category + ' ' + (it.username || 'anonymous'), ''); + this.terminal.print('page: ' + (it.page || 'β€”') + ' at: ' + (it.created_at || ''), ''); + this.terminal.print('', ''); + this.terminal.print(String(it.message || ''), ''); + if (it.admin_reply) { this.terminal.print('', ''); this.terminal.print('reply (' + (it.replied_at || '') + '): ' + it.admin_reply, ''); } + this.terminal.print('', ''); + this.terminal.print('feedback reply ' + it.id + ' | feedback archive ' + it.id, ''); + }).catch(e => this._fbFail(e)); + } + + _fbReply(id, text) { + if (!id || !text) { this.terminal.print('usage: feedback reply ', 'error'); return; } + this._fbCall('/' + encodeURIComponent(id), { method: 'PATCH', + body: JSON.stringify({ status: 'read', admin_reply: text }) }) + .then(() => this.terminal.print('βœ… reply published on #' + id + ' (the user sees it in /stear β†’ My reports)', '')) + .catch(e => this._fbFail(e)); + } + + _fbStatus(id, status) { + if (!id) { this.terminal.print('usage: feedback ' + (status === 'new' ? 'reopen' : 'archive') + ' ', 'error'); return; } + this._fbCall('/' + encodeURIComponent(id), { method: 'PATCH', body: JSON.stringify({ status: status }) }) + .then(() => this.terminal.print('βœ… #' + id + ' β†’ ' + status, '')) + .catch(e => this._fbFail(e)); + } + + _fbStats() { + const one = (st) => this._fbCall('/list?limit=1' + (st ? '&status=' + st : '')) + .then(d => { + if (typeof d.total === 'number') return d.total; // COUNT(*) from the server + return this._fbCall('/list?limit=200' + (st ? '&status=' + st : '')) + .then(d2 => (d2.items || []).filter(x => !st || x.status === st).length); + }); + Promise.all([one(''), one('new'), one('read'), one('archived')]).then(([all, nw, rd, ar]) => { + this.terminal.print('feedback: ' + all + ' total β€” new ' + nw + ', read ' + rd + + ', archived ' + ar, ''); + this.terminal.print('dashboard: /admin/feedback.html', ''); + }).catch(e => this._fbFail(e)); + } + + _fbDashboard() { + this.terminal.print('πŸͺŸ opening /admin/feedback.html …', ''); + window.open('/admin/feedback.html', '_blank'); + } + + _fbDispatch(args) { + const argv = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim().split(/\s+/); + const sub = (argv[0] || '').toLowerCase(); + const arg = argv[1]; + const rest = argv.slice(2).join(' '); + switch (sub) { + case 'list': return this._fbList(arg || ''); + case 'read': return this._fbRead(arg); + case 'reply': return this._fbReply(arg, rest); + case 'archive': return this._fbStatus(arg, 'archived'); + case 'reopen': return this._fbStatus(arg, 'new'); + case 'stats': return this._fbStats(); + case 'dashboard': return this._fbDashboard(); + case 'help': default: return this._fbHelp(); + } + } + _cmdSync(args) { const url = (args && args[0]) ? args[0] : null; if (url) { -- cgit v1.2.3