diff options
| author | Agando <roduccomm@gmail.com> | 2026-10-23 14:39:38 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-10-23 14:39:38 +0200 |
| commit | 25833fb43a0ed3e9c7522dfb6a78f29dfd947720 (patch) | |
| tree | 01e1b13d8619290143a67990bd12657323867dcc /scripts/newos4-triage.py | |
| parent | 97f55bf200c74a1b8ca0068ff70cbfeda77916cf (diff) | |
feat(newos4): maintenance agent channel — triage engine, two-way replies, cron
- scripts/newos4-triage.py: deterministic collector (records vs occurrences, real vs
noise vs self-referential, new reports since last run, checker WARN/FAIL). Untrusted
strings are sanitised and prefixed DATA| before they can reach an agent prompt;
state + output written under flock with mode 644.
- scripts/newos4-reply.py: appends one reply to data/maintenance/replies.json under
flock. Hermes writes here instead of POSTing with an admin cookie, so an expired
session can never look like a delivered reply.
- api/debug/agent-replies.php: GET/POST (admin-gated by nginx) half of the thread.
- api/debug/report.php: accepts thread + kind=maintenance so in-app feedback is
distinguishable from operator bug reports.
- terminal: `feedback <message>` and `feedback-inbox` commands; help.html rows added
(help coverage 357/357, modules-index regenerated).
Live proof: `feedback "…"` filed RPT-20261023-123825-781fba86 with thread
thr-glmfwae2 and kind=maintenance; agent-replies GET returned the posted reply, POST
returned 201; triage first run reported 9 new reports, second run 0.
Cron: newos4-maintenance (7e3b326daa4c) daily 07:15, newos4-skills-weekly
(cb6bc2a16216) Mondays 06:30.
Diffstat (limited to 'scripts/newos4-triage.py')
| -rwxr-xr-x | scripts/newos4-triage.py | 166 |
1 files changed, 166 insertions, 0 deletions
diff --git a/scripts/newos4-triage.py b/scripts/newos4-triage.py new file mode 100755 index 0000000..20016f7 --- /dev/null +++ b/scripts/newos4-triage.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""newOS4 triage engine — turns telemetry + reports into one actionable digest. + +Read-only by default. --apply performs safe hygiene only (archive old reports). +Never edits app code, nginx, services or user content. +""" +import argparse, json, os, re, subprocess, sys, time +from datetime import datetime, timezone + +ROOT = '/var/www/webx11.duckdns.org/spaces/newos4' +DEBUG = os.path.join(ROOT, 'data', 'debug') +REPORTS = os.path.join(ROOT, 'data', 'reports') +ARCHIVE = os.path.join(REPORTS, 'archive') +OUTDIR = '/var/www/webx11.duckdns.org/admin/docs' +STATE_DIR = '/var/lib/webx11/newos4-maintenance' +STATE = os.path.join(STATE_DIR, 'state.json') + +NOISE = re.compile(r'(user aborted|aborted without reason|signal timed out|AbortError|TimeoutError)', re.I) +SELF = re.compile(r'/api/debug/(ingest|report)\.php', re.I) + + +def load(path, default): + try: + with open(path, encoding='utf-8') as f: + return json.load(f) + except Exception: + return default + + +def save(path, obj): + """Write atomically, under flock, and leave the file readable by nginx.""" + import fcntl + os.makedirs(os.path.dirname(path), exist_ok=True) + lock = open(path + '.lock', 'a+') + fcntl.flock(lock, fcntl.LOCK_EX) + try: + tmp = path + '.tmp' + with open(tmp, 'w', encoding='utf-8') as f: + json.dump(obj, f, indent=2) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + os.chmod(path, 0o644) # a 600 file 403s behind the admin gate + finally: + fcntl.flock(lock, fcntl.LOCK_UN) + lock.close() + + +def ts(s): + if not s: + return None + try: + d = datetime.fromisoformat(str(s).replace('Z', '+00:00')) + return d if d.tzinfo else d.replace(tzinfo=timezone.utc) + except Exception: + return None + + +def age_hours(s): + d = ts(s) + return None if d is None else round((datetime.now(timezone.utc) - d).total_seconds() / 3600, 1) + + +def check_runner(): + r = subprocess.run([sys.executable, 'scripts/newos4-maintain.py'], cwd=ROOT, + capture_output=True, text=True, timeout=300) + lines = [l for l in (r.stdout or '').splitlines() if l.startswith(('FAIL', 'WARN'))] + return {'exit': r.returncode, 'findings': lines} + + +def safe(s, limit=200): + """Make untrusted telemetry safe to paste into an agent prompt: one line, no control + chars, bounded. Everything the agent sees from here is DATA, never an instruction.""" + s = re.sub(r'[\x00-\x1f\x7f]+', ' ', str(s or '')) + s = re.sub(r'\s+', ' ', s).strip() + return s[:limit] + + +def classify(err): + msg = str(err.get('message', '')) + if SELF.search(msg): + return 'self-referential' + if NOISE.search(msg): + return 'noise' + return 'real' + + +def build(apply_fix=False): + errors = load(os.path.join(DEBUG, 'errors.json'), []) + reports = [] + if os.path.isdir(REPORTS): + for name in sorted(os.listdir(REPORTS)): + if name.startswith('RPT-') and name.endswith('.json'): + r = load(os.path.join(REPORTS, name), None) + if isinstance(r, dict): + r['_file'] = os.path.join(REPORTS, name) + reports.append(r) + + state = load(STATE, {'seen_reports': [], 'last_run': None}) + seen = set(state.get('seen_reports', [])) + new_reports = [r for r in reports if r.get('id') not in seen] + + buckets = {'real': [], 'noise': [], 'self-referential': []} + for e in errors: + buckets[classify(e)].append(e) + + digest = { + 'generated_at': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), + 'counts': { + 'records': len(errors), + 'occurrences': sum(int(e.get('count') or 1) for e in errors), + 'real_records': len(buckets['real']), + 'noise_records': len(buckets['noise']), + 'self_referential_records': len(buckets['self-referential']), + 'reports_total': len(reports), + 'reports_new': len(new_reports), + }, + 'top_real': sorted( + ({'message': safe(e.get('message'), 200), 'type': e.get('type'), + 'count': int(e.get('count') or 1), 'age_hours': age_hours(e.get('last_seen')), + 'session': safe(e.get('session_id'), 40)} for e in buckets['real']), + key=lambda x: -x['count'])[:10], + 'new_reports': [{'id': r.get('id'), 'title': safe(r.get('title'), 160), + 'created_at': r.get('created_at'), + 'age_hours': age_hours(r.get('created_at')), + 'body': safe(r.get('body'), 400)} for r in new_reports[:10]], + 'self_referential': [str(e.get('message'))[:120] for e in buckets['self-referential']], + 'checks': check_runner(), + } + + if apply_fix: + os.makedirs(ARCHIVE, exist_ok=True) + moved = 0 + cutoff = time.time() - 90 * 86400 + for r in reports: + t = ts(r.get('created_at')) + if t and t.timestamp() < cutoff: + os.replace(r['_file'], os.path.join(ARCHIVE, os.path.basename(r['_file']))) + moved += 1 + digest['counts']['reports_archived'] = moved + + save(STATE, {'seen_reports': sorted(seen | {r.get('id') for r in new_reports if r.get('id')}), + 'last_run': digest['generated_at']}) + save(os.path.join(OUTDIR, 'newos4-triage-latest.json'), digest) + + c = digest['counts'] + print(f"newOS4 triage {digest['generated_at']}: records={c['records']} " + f"occurrences={c['occurrences']} real={c['real_records']} noise={c['noise_records']} " + f"self={c['self_referential_records']} reports={c['reports_total']} new={c['reports_new']}") + for f in digest['checks']['findings']: + print('check:', f) + for e in digest['top_real'][:5]: + print(f" DATA| top: {e['count']}x {e['type']} ({e['age_hours']}h) {safe(e['message'], 110)}") + for r in digest['new_reports'][:5]: + print(f" DATA| report: {r['id']} {safe(r['title'], 110)}") + return digest + + +if __name__ == '__main__': + ap = argparse.ArgumentParser() + ap.add_argument('--apply', action='store_true') + ap.add_argument('--json', action='store_true') + a = ap.parse_args() + d = build(apply_fix=a.apply) + if a.json: + print(json.dumps(d, indent=2)) |