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-apply.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-apply.py')
| -rwxr-xr-x | scripts/newos4-feedback-apply.py | 203 |
1 files changed, 203 insertions, 0 deletions
diff --git a/scripts/newos4-feedback-apply.py b/scripts/newos4-feedback-apply.py new file mode 100755 index 0000000..38188fb --- /dev/null +++ b/scripts/newos4-feedback-apply.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Apply queued operator decisions from data/feedback/decisions.jsonl. + +Runs as darkbot (cron). Mutates exactly two things, both assert-guarded: + approve + plan -> flip the exact '<!-- autonomy: review -->' line to '<!-- autonomy: safe -->' + reject + plan -> flip it back (an approval that was reconsidered before the night run) + approve + report -> append a high-priority directive via scripts/newos4-steer.py add +Every decision is recorded in data/feedback/applied.json (darkbot-owned, 0600) so nothing is +applied twice and php never needs write access to the sources. + +Ordering rules (2026-10-26 review): + * the LAST decision per item wins — approve followed by reject must not ship the plan; + * a decision that is superseded in the same run is recorded, not executed; + * one failing item never aborts the run, and applied.json is written after every mutation. +""" +import json, os, re, subprocess, sys +from datetime import datetime, timezone + +ROOT = '/var/www/webx11.duckdns.org' +FB = os.path.join(ROOT, 'spaces/newos4/data/feedback') +DEC = os.path.join(FB, 'decisions.jsonl') +APPLIED = os.path.join(FB, 'applied.json') +STEER = os.path.join(ROOT, 'spaces/newos4/scripts/newos4-steer.py') +PLANS = os.path.join(ROOT, '.hermes/plans') +REPORTS = os.path.join(ROOT, 'spaces/newos4/data/reports') +ROTATE_BYTES = 1024 * 1024 # move decisions.jsonl aside once it passes 1 MB + + +def load_applied(): + try: + with open(APPLIED, encoding='utf-8') as f: + d = json.load(f) + return d if isinstance(d, dict) else {} + except Exception: + return {} + + +def save_applied(d): + os.makedirs(FB, exist_ok=True) + tmp = APPLIED + '.tmp' + with open(tmp, 'w', encoding='utf-8') as f: + json.dump(d, f, indent=1, ensure_ascii=False) + os.chmod(tmp, 0o600) # www-data must not be able to wipe the ledger + os.replace(tmp, APPLIED) + + +def read_decisions(): + rows = [] + if not os.path.exists(DEC): + return rows + with open(DEC, encoding='utf-8', errors='ignore') as f: + for i, line in enumerate(f): + line = line.strip() + if not line: + continue + try: + d = json.loads(line) + except Exception: + continue # torn write: skip, never crash the run + d['_row'] = i + rows.append(d) + return rows + + +def _marker_count(src): + return sum(1 for l in src.splitlines() if l.strip() == '<!-- autonomy: review -->') + + +def _flip_marker(path, src, old, new, note): + """Flip one standalone marker LINE (not a substring) and keep the file mode.""" + lines = src.splitlines(keepends=True) + hits = [i for i, l in enumerate(lines) if l.strip() == old] + if len(hits) != 1: + return None, 'error: expected 1 standalone %s line, found %d' % (old, len(hits)) + i = hits[0] + indent = lines[i][:len(lines[i]) - len(lines[i].lstrip())] + eol = '\n' if lines[i].endswith('\n') else '' + lines[i] = '%s%s%s' % (indent, new, eol) + stamp = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%MZ') + body = ''.join(lines) + if not body.endswith('\n'): + body += '\n' + body += '\n<!-- %s by operator via /admin/feedback.html %s%s -->\n' % ( + 'approved' if new.endswith('safe -->') else 'reverted', + stamp, (' — ' + note.replace('-->', '')) if note else '') + return body, None + + +def apply_plan(name, decision, note): + plans_dir = os.path.realpath(PLANS) + path = os.path.realpath(os.path.join(plans_dir, name)) + if os.path.dirname(path) != plans_dir: + return 'error: refused, outside plans dir' + if not re.fullmatch(r'[A-Za-z0-9._-]+\.md', os.path.basename(path)): + return 'error: refused, bad plan name' + try: + src = open(path, encoding='utf-8').read() + mode = os.stat(path).st_mode & 0o777 + except Exception as e: + return 'error: unreadable (%s)' % e + + if decision == 'approve': + if _marker_count(src) != 1: + return 'error: expected 1 review marker, found %d' % _marker_count(src) + body, err = _flip_marker(path, src, '<!-- autonomy: review -->', '<!-- autonomy: safe -->', note) + elif decision == 'reject': + # only meaningful if a previous run already approved it + if src.count('<!-- autonomy: safe -->') == 0: + return 'recorded (no action: was never approved)' + body, err = _flip_marker(path, src, '<!-- autonomy: safe -->', '<!-- autonomy: review -->', note) + else: + return 'recorded (no action)' + if err or body is None: + return err or 'error: no output produced' + tmp = path + '.tmp' + with open(tmp, 'w', encoding='utf-8') as f: + f.write(body) + os.chmod(tmp, mode) # os.replace would otherwise apply the umask (600 -> 644) + os.replace(tmp, path) + return 'plan %s (%s, mode %o)' % ('approved' if decision == 'approve' else 'reverted', decision, mode) + + +def report_title(rid): + """The bare id is useless in a directive once the report file rotates away.""" + try: + with open(os.path.join(REPORTS, rid + '.json'), encoding='utf-8') as f: + d = json.load(f) + return str(d.get('title') or d.get('message') or rid)[:160] + except Exception: + return rid + + +def apply_report(name, decision, note): + if decision != 'approve': + return 'recorded (no action)' + text = '%s — %s' % (report_title(name), name) + if note: + text = '%s — %s' % (text, note) + r = subprocess.run([sys.executable, STEER, 'add', text, '--priority', 'high'], + capture_output=True, text=True, timeout=60) + if r.returncode != 0: + return 'error: steer add exit %d (%s)' % (r.returncode, (r.stderr or '')[-120:]) + return 'steering directive added' + + +def main(): + applied = load_applied() + rows = read_decisions() + if not rows: + return 0 + + # last decision per item wins; everything earlier in the file is superseded + last_row = {} + for d in rows: + last_row[d.get('item_id', '')] = d['_row'] + + done = 0 + for d in rows: + item = d.get('item_id', '') + dec = d.get('decision', '') + note = d.get('note', '') or '' + key = '%s|%s|%s' % (d.get('ts', ''), item, dec) + if key in applied: + continue + kind, _, name = item.partition(':') + + if last_row.get(item) != d['_row']: + applied[key] = {'item_id': item, 'decision': dec, 'result': 'superseded by a later decision', + 'applied_at': datetime.now(timezone.utc).isoformat(timespec='seconds')} + save_applied(applied) + continue + + try: + if kind == 'plan': + result = apply_plan(name, dec, note) + elif kind == 'report': + result = apply_report(name, dec, note) + else: + result = 'error: unknown item kind' + except subprocess.TimeoutExpired: + result = 'error: steer add timed out' + except Exception as e: # one bad item must not replay the batch + result = 'error: %s: %s' % (type(e).__name__, e) + + applied[key] = {'item_id': item, 'decision': dec, 'result': result, + 'applied_at': datetime.now(timezone.utc).isoformat(timespec='seconds')} + save_applied(applied) + done += 1 + print(('APPLY FAILED %s: %s' if result.startswith('error') else 'applied %s -> %s (%s)') + % ((item, result) if result.startswith('error') else (item, dec, result))) + + # keep the queue file bounded; applied.json stays as the permanent ledger + try: + if os.path.getsize(DEC) > ROTATE_BYTES: + os.replace(DEC, DEC + '.1') + print('rotated decisions.jsonl -> decisions.jsonl.1 (%d bytes)' % ROTATE_BYTES) + except Exception: + pass + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) |