diff options
| author | Agando <roduccomm@gmail.com> | 2026-10-26 13:03:11 +0100 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-10-26 13:03:11 +0100 |
| commit | a72e86bcb11d379289e02d868065f4dc86d0f23e (patch) | |
| tree | c5e0202766c935c5b62b29e629b9d4d755772f59 /scripts/newos4-feedback-collect.py | |
| parent | 5b05af91fa3fcc9ae7aff00feedc26d062babbc2 (diff) | |
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 -- <text>" 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).
Diffstat (limited to 'scripts/newos4-feedback-collect.py')
| -rwxr-xr-x | scripts/newos4-feedback-collect.py | 147 |
1 files changed, 147 insertions, 0 deletions
diff --git a/scripts/newos4-feedback-collect.py b/scripts/newos4-feedback-collect.py new file mode 100755 index 0000000..66f1653 --- /dev/null +++ b/scripts/newos4-feedback-collect.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Snapshot agent-side review items for /admin/feedback.html. + +Runs as darkbot (Hermes cron): .hermes/plans/*.md are 600 darkbot, unreadable to php-fpm, +so the snapshot is the only way the page can see them. Writes atomically, 644 www-data. +Read-only with respect to every source file. +""" +import glob, json, os, re, time +from datetime import datetime, timezone + +ROOT = '/var/www/webx11.duckdns.org' +OUT_DIR = os.path.join(ROOT, 'admin/docs/feedback') +OUT = os.path.join(OUT_DIR, 'agent-items.json') +PLANS = os.path.join(ROOT, '.hermes/plans') +TRIAGE = os.path.join(ROOT, 'admin/docs/newos4-triage-latest.json') +STEER = os.path.join(ROOT, 'spaces/newos4/data/maintenance/steering.json') +DECISIONS = os.path.join(ROOT, 'spaces/newos4/data/feedback/decisions.jsonl') + + +def load(path, default): + try: + with open(path, encoding='utf-8') as f: + return json.load(f) + except Exception: + return default + + +def decisions(): + """Latest decision per item id, from the append-only log written by php.""" + out = {} + if not os.path.exists(DECISIONS): + return out + try: + with open(DECISIONS, encoding='utf-8') as f: + for line in f: + line = line.strip() + if not line: + continue + try: + d = json.loads(line) + except Exception: + continue # torn line: ignore, never a crash + out[d.get('item_id', '')] = d + except Exception: + pass + return out + + +def plans_needing_decision(dec): + items = [] + for p in sorted(glob.glob(os.path.join(PLANS, '*.md'))): + try: + with open(p, encoding='utf-8', errors='ignore') as fh: + head = ''.join(fh.readline() for _ in range(40)) + except Exception: + continue + # Exact-line match: the analyser contract puts the marker on the line after the + # title. A substring test also matches plans that merely *document* the marker + # (measured 2026-10-26: this plan's own audit table inflated the count to 4). + if not any(l.strip() == '<!-- autonomy: review -->' for l in head.splitlines()): + continue + name = os.path.basename(p) + title = next((l.lstrip('# ').strip() for l in head.splitlines() if l.startswith('# ')), name) + d = dec.get('plan:' + name, {}) + items.append({ + 'id': 'plan:' + name, 'kind': 'plan', 'title': title, 'path': p, + 'size': os.path.getsize(p), 'mtime': datetime.fromtimestamp( + os.path.getmtime(p), timezone.utc).isoformat(timespec='seconds'), + 'decision': d.get('decision'), 'decided_at': d.get('decided_at') or d.get('ts'), + 'note': d.get('note', ''), + }) + return items + + +def reports_needing_decision(dec): + triage = load(TRIAGE, {}) or {} + new_ids = {r.get('id') for r in (triage.get('new_reports') or [])} + items = [] + for p in sorted(glob.glob(os.path.join(ROOT, 'spaces/newos4/data/reports/*.json'))): + try: + r = json.load(open(p, encoding='utf-8')) + except Exception: + continue + rid = r.get('id') or os.path.basename(p)[:-5] + d = dec.get('report:' + rid, {}) + items.append({ + 'id': 'report:' + rid, 'kind': 'report', 'title': (r.get('title') or rid)[:180], + 'created_at': r.get('created_at'), 'auto': bool((r.get('diagnostics') or {}).get('auto')), + 'new': rid in new_ids, 'decision': d.get('decision'), + 'decided_at': d.get('decided_at') or d.get('ts'), 'note': d.get('note', ''), + }) + items.sort(key=lambda x: (x.get('created_at') or ''), reverse=True) + return items + + +def recent_apply(n): + """Last n decision outcomes, newest first — failures included, so the dashboard can show them.""" + try: + with open(APPLIED, encoding='utf-8') as f: + d = json.load(f) + rows = [v for v in d.values() if isinstance(v, dict)] + rows.sort(key=lambda r: str(r.get('applied_at') or ''), reverse=True) + return rows[:n] + except Exception: + return [] + + +def main(): + dec = decisions() + steer_raw = load(STEER, []) + steer_ok = isinstance(steer_raw, list) + steer = [s for s in (steer_raw if steer_ok else []) if isinstance(s, dict) and not s.get('done')] + triage = load(TRIAGE, {}) or {} + plans = plans_needing_decision(dec) + reports = reports_needing_decision(dec) + awaiting = [x for x in plans + reports if not x.get('decision')] + doc = { + 'generated_at': datetime.now(timezone.utc).isoformat(timespec='seconds'), + 'counts': { + 'plans_review': len(plans), 'reports_total': len(reports), + 'awaiting_decision': len(awaiting), + 'steering_pending': len(steer) if steer_ok else -1, # -1 = steering.json unreadable/wrong shape + 'triage_reports_new': (triage.get('counts') or {}).get('reports_new', 0), + }, + 'plans': plans, 'reports': reports, 'steering': steer[:20], + 'recent_apply': recent_apply(8), + 'triage_checks': (triage.get('checks') or {}), + 'triage_generated_at': triage.get('generated_at'), + } + os.makedirs(OUT_DIR, exist_ok=True) + tmp = OUT + '.tmp' + with open(tmp, 'w', encoding='utf-8') as f: + json.dump(doc, f, ensure_ascii=False, indent=1) + os.replace(tmp, OUT) + os.chmod(OUT, 0o644) + try: + import grp + os.chown(OUT, -1, grp.getgrnam('www-data').gr_gid) + except Exception: + pass + print(f"feedback snapshot: plans={len(plans)} reports={len(reports)} " + f"awaiting={len(awaiting)} steering={len(steer)} -> {OUT}") + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) |