main
John Lauer Publish 0.1.26 0b71841
#!/usr/bin/env python3
"""Opt-in Hydrogen progress widget. Reuses artifacts; never calls an AI/provider."""
import argparse,pathlib,json,os,time,datetime,hashlib,subprocess,http.server,urllib.request,signal,re
CONFIG=pathlib.Path.home()/'.adom/aiflow-widget.json'
p=argparse.ArgumentParser();p.add_argument('action',choices=['enable','disable','serve','stop','event','status']);p.add_argument('--ai-thread');p.add_argument('--run',type=pathlib.Path);p.add_argument('--port',type=int,default=int(os.environ.get('HYDROGEN_WIDGET_PORT','27020')));p.add_argument('--file',type=pathlib.Path);p.add_argument('--label');p.add_argument('--step');p.add_argument('--progress',type=float);a=p.parse_args()
def read(p,default):
 try:return json.loads(p.read_text())
 except (OSError,ValueError):return default

def write(p,data):p.parent.mkdir(parents=True,exist_ok=True);q=p.with_suffix('.tmp');q.write_text(json.dumps(data,indent=2));q.replace(p)
def cfg():return read(CONFIG,{'enabled':False})
_ledger_cache={}
def thumbnail(run,src):
 src=src.resolve()
 if not src.is_relative_to(run.resolve()) or not src.is_file():return None
 name=hashlib.sha256(src.read_bytes()).hexdigest()+'.png';dest=run/'widget-thumbs'/name;dest.parent.mkdir(exist_ok=True)
 if not dest.exists():subprocess.run(['ffmpeg','-y','-v','error','-i',str(src),'-vf','scale=256:192:force_original_aspect_ratio=decrease','-frames:v','1',str(dest)],check=True,timeout=15)
 preview=run/'widget-previews'/name;preview.parent.mkdir(exist_ok=True)
 if not preview.exists():subprocess.run(['ffmpeg','-y','-v','error','-i',str(src),'-vf','scale=1024:768:force_original_aspect_ratio=decrease','-frames:v','1',str(preview)],check=True,timeout=15)
 return name

def ledger_events(run):
 path=run/'run.jsonl'
 if not path.exists():return []
 stat=path.stat();key=(str(path),stat.st_mtime_ns,stat.st_size)
 if key in _ledger_cache:return _ledger_cache[key]
 # Read only the tail of the append-only ledger; never write it.
 with path.open('rb') as f:
  start=max(0,stat.st_size-2*1024*1024);f.seek(start)
  if start:f.readline()
  lines=f.readlines()
 events=[]
 candidates=[]
 for line in lines:
  try:e=json.loads(line)
  except ValueError:continue
  if e.get('event')!='artifact':continue
  file=e.get('file')
  if not file or pathlib.Path(file).suffix.lower() not in ['.png','.jpg','.jpeg']:continue
  candidates.append(e)
 for e in candidates[-12:]:
  file=e['file'];src=pathlib.Path(file);src=src if src.is_absolute() else run/src
  try:
   name=thumbnail(run,src)
   if not name:continue
   stamp=e.get('t') or e.get('at');unix=datetime.datetime.fromisoformat(stamp.replace('Z','+00:00')).timestamp()
   events.append({'step':e.get('step'),'label':e.get('caption') or e.get('kind','Saved artifact'),'thumb':name,'at':stamp,'unix':unix})
  except (OSError,ValueError,subprocess.SubprocessError):continue
 _ledger_cache.clear();_ledger_cache[key]=events[-12:];return events[-12:]

def data():
 c=cfg()
 if not c.get('enabled'):return {'enabled':False,'step':'Widget off','label':'Updates disabled','thumbs':[],'progress':None}
 run=pathlib.Path(c['run']);r=read(run/'run.json',{});w=read(run/'widget-state.json',{});step=r.get('currentStep','Starting')
 events=sorted(ledger_events(run)+w.get('events',[]),key=lambda e:e.get('unix',0));latest=events[-1] if events else {}
 visits=[v for group in r.get('steps',{}).values() for v in group.get('visits',[])]
 visit=max(visits,key=lambda v:v.get('at',''),default={});step_at=visit.get('at','');event_at=latest.get('at','')
 activity_step=latest.get('step') if event_at>step_at else step
 names=[s['name'] for s in r.get('flow',{}).get('steps',[]) if s.get('name')]
 completed=sum(bool(r.get('steps',{}).get(n,{}).get('visits')) and all(v.get('end') for v in r['steps'][n]['visits']) for n in names)
 progress=latest.get('progress') if event_at>step_at else None
 basis='activity progress' if progress is not None else 'completed planned steps; not time remaining'
 if progress is None and names and activity_step==step:progress=round(100*completed/len(names))
 age=max(0,time.time()-latest.get('unix',time.time()))
 return {'enabled':True,'step':activity_step or step,'ledgerStep':step,'label':latest.get('label') or 'Waiting for a saved artifact','progress':progress,'progressBasis':basis,'completedSteps':completed,'plannedSteps':len(names),'updatedAt':latest.get('at'),'staleSeconds':round(age),'thumbs':[{'url':'/thumb/'+e['thumb'],'preview':'/preview-image/'+e['thumb'],'label':e.get('label',''),'at':e.get('at'),'step':e.get('step')} for e in events[-3:] if e.get('thumb')],'run':str(run),'mode':'Reuses saved artifacts; no AI calls; extra capture disabled','delivered':bool(r.get('delivery'))}
PAGE='''<!doctype html><meta charset="utf-8"><style>*{box-sizing:border-box}html,body{margin:0;background:transparent;color:#dce7f4;font:11px system-ui;overflow:hidden;height:100%}#root{height:100%;display:flex;align-items:center;gap:5px;padding:0 3px}#pictures{display:flex;gap:2px;height:calc(100vh - 2px)}img{height:100%;width:auto;max-width:46px;object-fit:contain;border-radius:3px}#info{min-width:70px;max-width:160px;flex:1}#step,#label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}#step{font-weight:650}#label{font-size:10px;color:#a9bbcf}#track{height:3px;background:#354455;margin-top:2px;border-radius:2px}#bar{height:100%;background:#59c6ee;max-width:100%;border-radius:2px}#mark{font-size:9px;color:#a9bbcf;white-space:nowrap}@media(max-height:33px){#info{display:flex;align-items:center;gap:5px;max-width:230px}#label{display:none}#track{width:35px;margin:0}#step{max-width:145px}#pictures img:not(:last-child){display:none}}#expanded{display:none}@media(min-height:100px){#root{display:none}#expanded{height:100vh;display:grid;grid-template-rows:auto minmax(0,1fr) auto auto;gap:6px;padding:10px;background:#171e26;font:12px system-ui}#large{max-width:none;width:100%;height:100%;object-fit:contain;min-height:0;background:#27333f}#caption{font-size:12px;line-height:1.35;overflow-wrap:anywhere}#choices{display:flex;gap:6px}#choices button{height:48px;width:74px;background:#27333f;border:2px solid transparent;border-radius:4px;cursor:pointer;padding:2px}#choices button[aria-pressed=true]{border-color:#00b8b1}#choices img{max-width:none;width:100%;height:100%;object-fit:contain}#stamp{color:#a9bbcf;font-size:10px}}</style><section id="expanded" aria-label="Activity previews"><b id="expanded-step"></b><img id="large" alt=""><div><div id="caption"></div><div id="stamp"></div></div><div id="choices"></div></section><div id="root"><div id="pictures"></div><div id="info"><div id="step">Adom AI Flow</div><div id="label">Waiting</div><div id="track"><div id="bar"></div></div></div><span id="mark"></span></div><script>let last={},chosen='',lastView='',lastHostMessage='',imageGeneration=0;
function text(id,value){let node=document.querySelector(id),next=value==null?'':String(value);if(node.textContent!==next)node.textContent=next}
function attr(node,name,value){if(node.getAttribute(name)!==value)node.setAttribute(name,value)}
function rowsFor(d){let seen=new Set(),rows=[];for(let t of [...(d.thumbs||[])].reverse()){let key=t.preview||t.url;if(!seen.has(key)){seen.add(key);rows.push(t)}}return rows.reverse()}
function publish(d,force=false){let message={hdWidget:{width:innerHeight>=34?265:250,expand:d.enabled?{width:560,height:400}:null}},key=JSON.stringify(message);if(force||key!==lastHostMessage){lastHostMessage=key;window.parent.postMessage(message,'*')}}
function syncChildren(parent,nodes){nodes.forEach((node,i)=>{if(parent.children[i]!==node)parent.insertBefore(node,parent.children[i]||null)});for(let child of [...parent.children])if(!nodes.includes(child))child.remove()}
const imageLoads=new Map();
function preparedImage(src){if(!imageLoads.has(src)){let image=new Image();image.src=src;imageLoads.set(src,image.decode().then(()=>image).catch(()=>{imageLoads.delete(src);return null}))}return imageLoads.get(src)}
async function smallImages(rows){let generation=++imageGeneration,parent=document.querySelector('#pictures'),existing=new Map([...parent.children].map(i=>[i.getAttribute('src'),i]));let nodes=await Promise.all(rows.map(async t=>{let i=existing.get(t.url)||await preparedImage(t.url);if(i)attr(i,'alt',t.label||'');return i}));if(generation===imageGeneration)syncChildren(parent,nodes.filter(Boolean))}
function expanded(d){let rows=rowsFor(d),selected=rows.find(t=>(t.preview||t.url)===chosen)||rows[rows.length-1];text('#expanded-step','Adom AI Flow · '+(d.step||'Waiting'));let large=document.querySelector('#large');if(selected){let src=selected.preview||selected.url;chosen=src;attr(large,'alt',selected.label||'');if(large.getAttribute('src')!==src)preparedImage(src).then(image=>{if(image&&chosen===src)attr(large,'src',src)});text('#caption',selected.label);text('#stamp',selected.at)}else{if(large.hasAttribute('src'))large.removeAttribute('src');text('#caption',d.label);text('#stamp','')}
let parent=document.querySelector('#choices'),existing=new Map([...parent.children].map(b=>[b.dataset.key,b]));let nodes=rows.map(t=>{let key=t.preview||t.url,b=existing.get(key);if(!b){b=document.createElement('button');b.dataset.key=key;let i=document.createElement('img');i.src=t.url;i.alt='';b.appendChild(i);b.onclick=()=>{chosen=key;expanded(last)}}attr(b,'aria-label',t.label||'Preview');attr(b,'aria-pressed',String(key===chosen));return b});syncChildren(parent,nodes)}
function draw(d){last=d;publish(d);let rows=rowsFor(d),view=JSON.stringify([d.step,d.label,d.progress,d.enabled,rows,innerHeight>=100]);if(view===lastView)return;lastView=view;text('#label',[d.step,d.label].filter(Boolean).join(' · '));let bar=document.querySelector('#bar'),width=d.progress==null?'0%':d.progress+'%';if(bar.style.width!==width)bar.style.width=width;text('#mark',d.progress==null?'…':Math.round(d.progress)+'%');smallImages(rows);if(innerHeight>=100)expanded(d)}
async function tick(){try{let r=await fetch('/data.json',{cache:'no-store'});if(!r.ok)throw Error('feed');draw(await r.json())}catch(e){text('#label','Feed unavailable');lastView=''}setTimeout(tick,document.hidden?30000:4000)}
addEventListener('keydown',e=>{if(e.key==='Escape'&&innerHeight>=100)window.parent.postMessage({hdWidget:{collapse:true}},'*')});
addEventListener('resize',()=>draw(last));
addEventListener('load',()=>publish(last,true));
tick();
</script>'''
if a.action in ['enable','disable']:
 c=cfg();c['enabled']=a.action=='enable'
 if a.run:c['run']=str(a.run.resolve())
 if c['enabled']:assert c.get('run'),'--run required to enable';assert (pathlib.Path(c['run'])/'run.json').exists(),'run.json missing'
 c['port']=a.port;c['captureMode']='reuse-only';write(CONFIG,c);print(json.dumps(c))
elif a.action=='event':
 c=cfg();run=(a.run or pathlib.Path(c.get('run','.'))).resolve()
 if not c.get('enabled') or str(run)!=c.get('run'):print('Widget updates disabled for this run');sys_exit=0
 else:
  r=read(run/'run.json',{});e={'step':a.step or r.get('currentStep','working'),'label':a.label or 'Saved artifact','at':datetime.datetime.now(datetime.timezone.utc).isoformat(),'unix':time.time(),'progress':a.progress}
  if a.progress is not None:assert 0<=a.progress<=100,'progress must be 0..100'
  if a.file:
   name=thumbnail(run,a.file);assert name,'artifact must exist inside this run'
   e['thumb']=name
  state=read(run/'widget-state.json',{'events':[]});state['events']=(state['events']+[e])[-12:];write(run/'widget-state.json',state);print(json.dumps(e))
elif a.action=='status':print(json.dumps(data(),indent=2))
elif a.action=='stop':
 pidfile=pathlib.Path.home()/'.adom/aiflow-widget.pid'
 if pidfile.exists():
  pid=int(pidfile.read_text());cmd=pathlib.Path(f'/proc/{pid}/cmdline')
  if cmd.exists() and str(pathlib.Path(__file__).resolve()).encode() in cmd.read_bytes() and b'serve' in cmd.read_bytes():os.kill(pid,signal.SIGTERM)
  pidfile.unlink(missing_ok=True)
else:
 class H(http.server.BaseHTTPRequestHandler):
  def do_GET(self):
   if self.path in ['/','/index.html']:body=PAGE.encode();kind='text/html'
   elif self.path=='/health':body=b'OK';kind='text/plain'
   elif self.path=='/data.json':body=json.dumps(data()).encode();kind='application/json'
   elif self.path.startswith(('/thumb/','/preview-image/')):
    c=cfg();name=self.path.split('/')[-1]
    if not c.get('enabled') or not re.fullmatch(r'[0-9a-f]{64}\.png',name):self.send_error(404);return
    file=pathlib.Path(c['run'])/('widget-previews' if self.path.startswith('/preview-image/') else 'widget-thumbs')/name
    if not file.is_file() and self.path.startswith('/preview-image/'):file=pathlib.Path(c['run'])/'widget-thumbs'/name
    if not file.is_file():self.send_error(404);return
    body=file.read_bytes();kind='image/png'
   else:self.send_error(404);return
   self.send_response(200);self.send_header('Content-Type',kind);self.send_header('Cache-Control','private, max-age=31536000, immutable' if kind=='image/png' and ('/thumb/' in self.path or file.parent.name=='widget-previews') else 'no-store');self.end_headers();self.wfile.write(body)
  def log_message(self,*args):pass
 server=http.server.ThreadingHTTPServer(('127.0.0.1',a.port),H);(pathlib.Path.home()/'.adom/aiflow-widget.pid').write_text(str(os.getpid()));print('AI Flow widget listening',a.port,flush=True);server.serve_forever()