aboutsummaryrefslogtreecommitdiff
path: root/scripts/newos4-feedback-apply.py
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/newos4-feedback-apply.py')
-rwxr-xr-xscripts/newos4-feedback-apply.py203
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())