app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
main
John Lauer
0.1.28: await dashboard shutdown lease and verify native board IPC readiness
7e71a2b
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
#!/usr/bin/env python3
"""AI Flow silkscreen observer: persisted solver events, no provider calls or CAD edits."""
from recording_check import check as check_recording
import argparse,base64,hashlib,json,os,pathlib,subprocess,sys,time,threading,http.server,urllib.request,urllib.parse,signal
P=pathlib.Path;ROOT=P(__file__).resolve().parent;REG=P.home()/'.adom/instances/aiflow-silkscreen';REG.mkdir(parents=True,exist_ok=True)
def atomic(p,d):
q=p.with_suffix('.tmp');q.write_text(json.dumps(d,indent=2));q.replace(p)
p=argparse.ArgumentParser();p.add_argument('action',choices=['start','serve','show','ls','state','control','stop']);p.add_argument('--run',required=True,type=P);p.add_argument('--ai-thread',required=True);p.add_argument('--port',default='auto');p.add_argument('--surface',choices=['wv','pup'],default='wv');p.add_argument('--target');p.add_argument('--url');p.add_argument('--json',default='{}');p.add_argument('--reason');a=p.parse_args();run=a.run.resolve();key=hashlib.sha256((a.ai_thread+str(run)).encode()).hexdigest()[:16];reg=REG/(key+'.json');snap=REG/(key+'-snapshot.json');folder=run/'silkscreen-dashboard';folder.mkdir(exist_ok=True);events=folder/'events.jsonl';manifest=folder/'board.json';version=hashlib.sha256((ROOT/'index.html').read_bytes()+P(__file__).read_bytes()).hexdigest()[:12]
def entry():
try:
e=json.loads(reg.read_text());r=json.load(urllib.request.urlopen('http://127.0.0.1:'+str(e['port'])+'/health',timeout=2));return e if r.get('key')==key else None
except Exception:return None
def request(e,path,data=None):
body=None if data is None else json.dumps(dict(data,aiThread=a.ai_thread)).encode();return json.load(urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:'+str(e['port'])+path,data=body,headers={'Content-Type':'application/json'}),timeout=5))
if a.action=='ls':
for f in REG.glob('*.json'):
if f.name.endswith('-snapshot.json'):continue
e=json.loads(f.read_text())
try:os.kill(e['pid'],0)
except ProcessLookupError:f.unlink();continue
print(json.dumps(dict(e,versionDrift=e.get('version')!=version)))
sys.exit()
e=entry()
if a.action in ['start','show']:
if not e:
log=open(folder/'server.log','ab');subprocess.Popen([sys.executable,str(P(__file__).resolve()),'serve','--run',str(run),'--ai-thread',a.ai_thread,'--port',a.port],stdout=log,stderr=log,start_new_session=True)
for _ in range(80):
time.sleep(.1);e=entry()
if e:break
if not e:raise SystemExit('ERROR: server did not become healthy; see '+str(folder/'server.log'))
url=a.url or ('http://'+str(e['port'])+'.localhost:'+urllib.parse.urlparse((P.home()/'.adom/hd-proxy-url').read_text().strip()).port.__str__()+'/' if (P.home()/'.adom/hd-proxy-url').exists() else 'http://127.0.0.1:'+str(e['port'])+'/')
if a.action=='show':
if a.surface=='wv':opened=subprocess.run(['adom-cli','hydrogen','webview','open-or-refresh','--name','AI Flow silkscreen','--url',url],check=True,capture_output=True,text=True);print(opened.stdout);e['tabId']=json.loads(opened.stdout).get('tabId');atomic(reg,e)
else:
if not a.target or not a.url:raise SystemExit('ERROR: pup needs --target and a desktop-reachable --url')
subprocess.run(['adom-bridge','pup_open_window',json.dumps({'url':url,'background':True}),'--target',a.target,'--ai-thread',a.ai_thread],check=True)
print(json.dumps(dict(e,url=url,reused=True)));sys.exit()
if a.action in ['state','control','stop']:
if not e:raise SystemExit('ERROR: not running; use start')
if a.action=='stop' and not a.reason:raise SystemExit('ERROR reason_required: explain why this task-owned server should stop')
result=request(e,'/shutdown' if a.action=='stop' else '/control' if a.action=='control' else '/state',dict(json.loads(a.json),reason=a.reason) if a.action!='state' else None)
if a.action=='stop':
import fcntl
deadline=time.monotonic()+10
with open(REG/(key+'.lock'),'a') as lease:
while True:
try:fcntl.flock(lease,fcntl.LOCK_EX|fcntl.LOCK_NB);fcntl.flock(lease,fcntl.LOCK_UN);break
except BlockingIOError:
if time.monotonic()>=deadline:raise SystemExit('ERROR: shutdown accepted but this server still holds its lease. Hint: check state before restarting; do not kill a shared process.')
time.sleep(.1)
print(json.dumps(result));sys.exit()
if e:print('OK: already running');sys.exit()
settingspath=REG/'settings.json';settings=json.loads(settingspath.read_text()) if settingspath.exists() else dict(ttlHours=24,speed=4,showObstacles=True)
state=json.loads(snap.read_text()) if snap.exists() else dict(mode='live',cursor=-1,playing=False,layer='F.SilkS',view='2d');clients=set();lock=threading.RLock();last=time.time();revision=0;history=[];offset=0
def payload():return dict(state,settings=settings,events=history,aiThread=a.ai_thread,version=version)
def save():atomic(snap,state)
def broadcast():
global revision
revision+=1
# Bounded state messages contain the current event; history is fetched separately.
data=dict(state,settings=settings,event=history[state['cursor']] if 0<=state['cursor']<len(history) else None,total=len(history),revision=revision)
for q in list(clients):
try:q.write(('event: state\ndata: '+json.dumps(data)+'\n\n').encode());q.flush()
except Exception:clients.discard(q)
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self,*args):pass
def response(self,data,code=200,ctype='application/json'):
b=data if isinstance(data,bytes) else json.dumps(data).encode();self.send_response(code);self.send_header('Content-Type',ctype);self.send_header('Cache-Control','no-store');self.send_header('Content-Length',str(len(b)));self.end_headers();self.wfile.write(b)
def do_GET(self):
global last
last=time.time();path=urllib.parse.urlparse(self.path).path
if path=='/health':return self.response(dict(key=key,version=version))
if path=='/state':return self.response(payload())
if path=='/settings':return self.response(dict(settings=settings,enums={'ttlHours':'1..168','speed':'0.5..60','showObstacles':'boolean'}))
if path=='/stream':
self.send_response(200);self.send_header('Content-Type','text/event-stream');self.send_header('Cache-Control','no-cache');self.send_header('X-Accel-Buffering','no');self.end_headers();self.wfile.write((':'+(' '*2048)+'\n\n').encode());clients.add(self.wfile);broadcast()
try:
while True:time.sleep(15);self.wfile.write(b': heartbeat\n\n');self.wfile.flush()
except Exception:clients.discard(self.wfile)
return
if path in ['/','/index.html']:return self.response((ROOT/'index.html').read_bytes(),ctype='text/html')
if path=='/board.json':return self.response(manifest.read_bytes() if manifest.exists() else b'{"obstacles":[],"labels":[],"bounds":[0,0,100,100]}')
if path.startswith('/recordings/'):
f=(folder/path.lstrip('/')).resolve()
if not f.is_relative_to((folder/'recordings').resolve()) or not f.is_file():return self.response({'error':'not found'},404)
return self.response(f.read_bytes(),ctype='video/webm')
if path.startswith('/models/'):
f=(folder/path.lstrip('/')).resolve()
if not f.is_relative_to((folder/'models').resolve()) or not f.is_file():return self.response({'error':'not found'},404)
return self.response(f.read_bytes(),ctype='model/gltf-binary')
self.response({'error':'not found'},404)
def do_POST(self):
global last
last=time.time()
try:
if int(self.headers.get('Content-Length','0'))>(40_000_000 if self.path=='/recording' else 65536):raise ValueError('request too large')
body=json.loads(self.rfile.read(int(self.headers.get('Content-Length','0'))))
if not body.get('aiThread'):return self.response(dict(errorCode='caller_identity_required',hint='Pass aiThread or CLI --ai-thread.'),400)
with lock:
if self.path=='/recording':
video=base64.b64decode(body['video'],validate=True)
if len(video)<1024 or video[:4]!=bytes.fromhex('1a45dfa3'):raise ValueError('Refused empty or invalid WebM recording')
clips=folder/'recordings';clips.mkdir(exist_ok=True);name='replay-'+str(time.time_ns())+'.webm';(clips/name).write_bytes(video)
quality=check_recording(clips/name,body.get('elapsedSeconds',0));accepted=body.get('accepted') is not False and quality['accepted']
state['lastRecording']={'file':str(clips/name),'url':'recordings/'+name,'kind':body.get('kind','walkthrough'),'bytes':len(video),'replay':True,'accepted':accepted,'rejectedReason':body.get('rejectedReason') or quality.get('reason'),'frameValidation':quality};state['recordRequest']=None;save();broadcast();self.response({'ok':True,'recording':state['lastRecording']});return
if self.path=='/shutdown':
if not body.get('reason'):raise ValueError('reason_required')
self.response({'ok':True});threading.Thread(target=shutdown,daemon=True).start();return
if self.path=='/settings':
for k,v in body.items():
if k=='aiThread':continue
if k=='ttlHours' and isinstance(v,(int,float)) and 1<=v<=168:settings[k]=v
elif k=='speed' and isinstance(v,(int,float)) and .5<=v<=60:settings[k]=v
elif k=='showObstacles' and isinstance(v,bool):settings[k]=v
else:raise ValueError('Allowed settings: ttlHours 1..168, speed .5..60, showObstacles boolean')
atomic(settingspath,settings)
elif self.path=='/control':
for k,v in body.items():
if k in ['aiThread','reason']:continue
if k=='record' and v in ['overview','walkthrough']:state['recordRequest']={'id':time.time_ns(),'kind':v}
elif k=='mode' and v in ['live','replay']:state[k]=v
elif k=='layer' and v in ['F.SilkS','B.SilkS']:state[k]=v
elif k=='view' and v in ['2d','3d']:state[k]=v
elif k in ['playing','summary'] and isinstance(v,bool):state[k]=v
elif k=='cursor' and isinstance(v,int) and -1<=v<len(history):state[k]=v
else:raise ValueError('Allowed controls: mode live/replay, layer F.SilkS/B.SilkS, view 2d/3d, playing boolean, cursor within event history')
if state['mode']=='live':state['cursor']=len(history)-1;state['playing']=False
else:return self.response({'error':'not found'},404)
save();broadcast();self.response({'ok':True,'state':state})
except (ValueError,TypeError,KeyError) as ex:self.response({'error':str(ex)},400)
http.server.ThreadingHTTPServer.allow_reuse_address=True
import fcntl
lease=open(REG/(key+'.lock'),'w')
try:fcntl.flock(lease,fcntl.LOCK_EX|fcntl.LOCK_NB)
except BlockingIOError:raise SystemExit('OK: another instance for this run/thread is starting or running')
for attempt in range(30):
try:
server=http.server.ThreadingHTTPServer(('127.0.0.1',0 if a.port=='auto' else int(a.port)),Handler);break
except OSError:
if attempt==29:raise
time.sleep(.1)
server.daemon_threads=True
atomic(reg,dict(pid=os.getpid(),port=server.server_port,thread=a.ai_thread,run=str(run),version=version,startedAt=time.time()))
def shutdown():
save();current=json.loads(reg.read_text()) if reg.exists() else {};reg.unlink(missing_ok=True)
if current.get('tabId'):
subprocess.run(['adom-cli','hydrogen','workspace','remove-tab','--tab-id',current['tabId']],capture_output=True)
server.shutdown()
def watch():
global offset,history
previous=time.time()
while True:
time.sleep(.05)
with lock:
if events.exists():
with events.open() as f:
f.seek(offset)
while True:
pos=f.tell();line=f.readline()
if not line or not line.endswith('\n'):break
try:history.append(json.loads(line))
except json.JSONDecodeError:pass
offset=f.tell()
if state['mode']=='live' and state['cursor']!=len(history)-1:state['cursor']=len(history)-1;broadcast()
if state['mode']=='replay' and state['playing']:
start=max([i for i,e in enumerate(history) if e.get('kind')=='start'] or [0])
eligible=[i for i in range(start,len(history)) if (not history[i].get('layer') or history[i].get('layer')==state.get('layer')) and (not state.get('summary') or history[i].get('kind') in ['placement','unresolved'])]
speed=max(1,len(eligible)/5) if state.get('summary') else settings['speed']
elapsed=time.time()-previous
if elapsed>=1/speed:
remaining=[i for i in eligible if i>state['cursor']];count=max(1,int(elapsed*speed));previous+=count/speed
if remaining:state['cursor']=remaining[min(count,len(remaining))-1]
if len(remaining)<=count:state['cursor']=len(history)-1;state['playing']=False
broadcast()
else:previous=time.time()
if time.time()-last>settings['ttlHours']*3600:shutdown();return
threading.Thread(target=watch,daemon=True).start();signal.signal(signal.SIGTERM,lambda *_:threading.Thread(target=shutdown,daemon=True).start());print(json.dumps({'port':server.server_port}),flush=True);server.serve_forever();save();reg.unlink(missing_ok=True)