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
Publish 0.2.20
29b161b
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
"""Real CLI/server lifecycle with a fake Hydrogen API (a test-owned adom-cli on PATH); no CAD or real tabs touched.
Covers: default intake, reuse, closed-tab recovery, per-run server isolation, resume, stopped-server
recovery, SSE live channel and reconnect replay, run labels and tab names, the old hex-named tab
renamed in the same pane, the tab registry, the redundant-tab challenge (same thread, other thread),
finished-tab reuse, --new-tab --why, timeline list, timeline close, headless override, visible UI failure.
"""
import json,os,pathlib,subprocess,sys,tempfile,time,urllib.request,urllib.error,signal
binary=str(pathlib.Path(sys.argv[1]).resolve())
DOT='AI Flow · '
with tempfile.TemporaryDirectory(prefix='aiflow-dashboard-test-') as root:
p=pathlib.Path(root);bindir=p/'bin';bindir.mkdir();state=p/'hydrogen.json';calls=p/'calls.jsonl';home=p/'home'
# two panes; the human's own tab must never be touched
state.write_text(json.dumps({'panes':{'left':[{'name':'Visual Studio Code','url':None}],'right':[{'name':'Human notes','url':'http://human/'}]}}))
mock=bindir/'adom-cli';mock.write_text('''#!/usr/bin/env python3
import sys,json,os,pathlib
args=sys.argv[1:];p=pathlib.Path(os.environ['TEST_HYDROGEN']);d=json.loads(p.read_text())
with open(os.environ['TEST_CALLS'],'a')as f:f.write(json.dumps(args)+'\\n')
def opt(k):return args[args.index(k)+1] if k in args else None
def find(name):
for pane,tabs in d['panes'].items():
for t in tabs:
if t['name']==name:return pane,t
return None,None
def save():p.write_text(json.dumps(d))
if args[:3]==['hydrogen','workspace','tabs']:print(json.dumps({'tabs':[{'name':t['name'],'panelId':pane,'url':t['url']} for pane,tabs in d['panes'].items() for t in tabs]}))
elif args[:3]==['hydrogen','workspace','get']:print(json.dumps({'root':{'type':'split','children':[{'type':'leaf','id':pane,'tabs':[{'panelState':{'displayName':t['name'],'url':t['url']}} for t in tabs]} for pane,tabs in d['panes'].items()]}}))
elif args[:3]==['hydrogen','webview','open-or-refresh']:
name=opt('--name');url=opt('--url');pane,t=find(name)
if t:t['url']=url;save();print(json.dumps({'ok':True,'action':'refreshed'}))
else:d['panes'].setdefault(opt('--panel-id') or 'right',[]).append({'name':name,'url':url});save();print(json.dumps({'ok':True,'action':'created'}))
elif args[:3]==['hydrogen','webview','navigate']:
pane,t=find(opt('--name'))
if t:t['url']=args[3]
save();print(json.dumps({'ok':True}))
elif args[:3]==['hydrogen','workspace','remove-tab']:
pane,t=find(opt('--name'))
if not t:print(json.dumps({'ok':False}));sys.exit(1)
d['panes'][pane].remove(t);save();print(json.dumps({'ok':True}))
else:print('{}')
''');mock.chmod(0o755)
env=dict(os.environ,PATH=str(bindir)+os.pathsep+os.environ['PATH'],TEST_HYDROGEN=str(state),TEST_CALLS=str(calls),AIFLOW_HOME=str(home));env.pop('AIFLOW_DASHBOARD',None);env.pop('VSCODE_PROXY_URI',None)
runs={n:p/n for n in ['one','two','three','four']}
def run(r,*args,thread='astra',extra=None,code=0):
res=subprocess.run([binary,'--run',str(r),'--ai-thread',thread,*args],env=extra or env,capture_output=True,text=True,timeout=35)
assert code is None or res.returncode==code,(args,res.returncode,res.stdout,res.stderr);return res
def bare(*args,code=0):
res=subprocess.run([binary,'--ai-thread','astra',*args],env=env,capture_output=True,text=True,timeout=35,cwd=root)
assert code is None or res.returncode==code,(args,res.stdout,res.stderr);return res
def log():return [json.loads(l) for l in calls.read_text().splitlines()]
def opens():return sum(c[:3]==['hydrogen','webview','open-or-refresh'] for c in log())
def tabs():return {t['name']:(pane,t['url']) for pane,ts in json.loads(state.read_text())['panes'].items() for t in ts}
def flow_tabs():return {n:v for n,v in tabs().items() if n.startswith(DOT)}
def cfg(r):return json.loads((r/'dashboard.json').read_text())
def reg():return json.loads((home/'dashboards.json').read_text())['dashboards']
def entry(r):return next(e for e in reg() if e['runDir']==str(r.resolve()))
def edit(r,f):
d=json.loads((r/'run.json').read_text());f(d);(r/'run.json').write_text(json.dumps(d))
def alive(url):
try:return json.load(urllib.request.urlopen(url+'dashboard.json',timeout=2))['runId']
except Exception:return None
try:
# --- default intake: one tab named for the run, never a hash or 'adom-aiflow'
run(runs['one'],'intake','--prompt-time','2026-10-09T10:00:00Z','--engine','codex');assert opens()==1
a=cfg(runs['one']);assert a['name']==DOT+'one',a;assert alive(a['url'])==a['runId']
tl=json.loads((runs['one']/'timeline'/'timeline.json').read_text())
assert tl['title']=='one' and tl['aiThread']=='astra' and tl['runFolder']=='one' and tl['engine']=='KiCad',tl
assert 'adom-aiflow' not in json.dumps({k:tl[k] for k in ['title','label']})
e=entry(runs['one']);assert e['aiThread']=='astra' and e['label']=='one' and e['finished'] is False and e['port']==a['port'],e
run(runs['one'],'status');assert opens()==1,'repeated command must reuse tab'
# --- proxy change navigates the named tab
s=json.loads(state.read_text());s['panes']['right'][-1]['url']='http://old-proxy/';state.write_text(json.dumps(s))
run(runs['one'],'status');assert opens()==1 and tabs()[DOT+'one'][1]==a['url'],'proxy change must navigate the existing named tab'
# --- closed tab reopens
s=json.loads(state.read_text());s['panes']['right']=[t for t in s['panes']['right'] if not t['name'].startswith(DOT)];state.write_text(json.dumps(s))
run(runs['one'],'status');assert opens()==2,'closed tab must reopen'
# --- the label follows the ORIGINAL board's stem; the tab is renamed in its pane
edit(runs['one'],lambda d:(d.__setitem__('sourceBoard','/boards/buck-12v5v.kicad_pcb'),d.__setitem__('boards',[str(runs['one']/'board-3.kicad_pcb')])))
run(runs['one'],'status');ft=flow_tabs();assert list(ft)==[DOT+'buck-12v5v'] and ft[DOT+'buck-12v5v'][0]=='right',ft
tl=json.loads((runs['one']/'timeline'/'timeline.json').read_text());assert tl['title']=='buck-12v5v' and tl['board']=='buck-12v5v.kicad_pcb',tl
# --- migration: a 0.2.19 tab "AI Flow · <hex>" in the left pane is replaced in the same pane
hexname=DOT+a['runId'][:8]
s=json.loads(state.read_text());s['panes']['right']=[t for t in s['panes']['right'] if not t['name'].startswith(DOT)];s['panes']['left'].append({'name':hexname,'url':a['url']});state.write_text(json.dumps(s))
c=cfg(runs['one']);c['name']=hexname;(runs['one']/'dashboard.json').write_text(json.dumps(c))
r=json.loads((home/'dashboards.json').read_text());r['dashboards']=[];(home/'dashboards.json').write_text(json.dumps(r))
n0=len(log());run(runs['one'],'status');new=log()[n0:]
o=[c for c in new if c[:3]==['hydrogen','webview','open-or-refresh']];rm=[c for c in new if c[:3]==['hydrogen','workspace','remove-tab']]
assert len(o)==1 and o[0][o[0].index('--panel-id')+1]=='left' and o[0][o[0].index('--name')+1]==DOT+'buck-12v5v',new
assert rm==[['hydrogen','workspace','remove-tab','--name',hexname]],rm
assert flow_tabs()=={DOT+'buck-12v5v':('left',a['url'])},flow_tabs()
assert cfg(runs['one'])['name']==DOT+'buck-12v5v' and entry(runs['one'])['name']==DOT+'buck-12v5v'
a=cfg(runs['one'])
# --- resume activates the existing tab without reopening
count=opens();run(runs['one'],'prompt','--text','resume');assert opens()==count
# --- stopped server is restarted behind the same tab
for line in subprocess.check_output(['ps','-eo','pid,args'],text=True).splitlines():
if binary in line and str(runs['one']) in line and 'timeline serve' in line:os.kill(int(line.split()[0]),signal.SIGTERM)
time.sleep(.2);run(runs['one'],'status');assert opens()==count,'server restart should reuse the tab'
assert alive(a['url'])==a['runId']
# --- CLI -> HTTP control -> SSE updates an existing view, and reconnect replays it
stream=urllib.request.urlopen(a['url']+'events',timeout=5)
assert stream.headers['X-Accel-Buffering']=='no'
def event(connection):
while True:
line=connection.readline().decode()
if line.startswith('data: '):return json.loads(line[6:])
initial=event(stream);assert initial['timeline']['steps'] and initial['timeline']['aiThread']=='astra'
count=opens();run(runs['one'],'timeline','present','--json',json.dumps({'caption':'Live test update','title':'No extra tab'}));assert opens()==count
deadline=time.time()+5;received=event(stream)
while not (received.get('presentation') or {}).get('caption') and time.time()<deadline:received=event(stream)
assert received['presentation']['caption']=='Live test update'
stream.close()
stream=urllib.request.urlopen(a['url']+'events',timeout=5);assert event(stream)['presentation']['caption']=='Live test update';stream.close()
req=urllib.request.Request(a['url']+'state',data=json.dumps({'presentation':{'caption':'anonymous'}}).encode(),headers={'Content-Type':'application/json'})
try:urllib.request.urlopen(req);raise AssertionError('anonymous mutation accepted')
except urllib.error.HTTPError as e:assert e.code==400 and 'caller_identity_required' in e.read().decode()
assert json.load(urllib.request.urlopen(a['url']+'state'))['presentation']['caption']=='Live test update'
assert 'timeline serve'not in (runs['one']/'run.jsonl').read_text()
# --- same thread, second unfinished run: challenged, no new tab
count=opens();res=run(runs['two'],'intake','--prompt-time','2026-10-09T10:00:00Z','--engine','claude')
assert opens()==count and 'name' not in cfg(runs['two']),res.stderr
assert 'WARNING: no new AI Flow tab for this run' in res.stderr and DOT+'buck-12v5v' in res.stderr and '--reuse-tab' in res.stderr and '--new-tab --why' in res.stderr and 'keeps the human' in res.stderr,res.stderr
res=run(runs['two'],'status');assert opens()==count and 'WARNING: this run has no dashboard tab' in res.stderr,res.stderr
res=run(runs['two'],'timeline','open',code=2);assert 'ERROR' in res.stdout+res.stderr and '--reuse-tab' in res.stdout+res.stderr and opens()==count
res=run(runs['two'],'timeline','open','--new-tab',code=2);assert '--why' in res.stdout+res.stderr and opens()==count
# --- --new-tab --why opens and records the reason
res=run(runs['two'],'timeline','open','--new-tab','--why','the human compares both layouts side by side');assert opens()==count+1,res
b=cfg(runs['two']);assert b['name']==DOT+'two' and b['port']!=a['port'];assert entry(runs['two'])['why']=='the human compares both layouts side by side'
assert alive(a['url'])==a['runId'] and alive(b['url'])==b['runId'],'second run stole first server'
# --- close --run stops only that run's verified server and tab
res=run(runs['two'],'timeline','close');assert DOT+'two' not in tabs() and 'stopped timeline server' in res.stdout,res.stdout
time.sleep(.3);assert alive(b['url']) is None and alive(a['url'])==a['runId']
assert all(e['runDir']!=str(runs['two'].resolve()) for e in reg())
# --- a finished run's tab is reused (renamed in place) by the same thread's next run
edit(runs['one'],lambda d:d['clock'].__setitem__('finishTime','2026-10-09T12:00:00Z'))
n0=len(log());res=run(runs['three'],'intake','--prompt-time','2026-10-09T13:00:00Z','--engine','codex');new=log()[n0:]
assert 'reused tab' in res.stderr and 'finished' in res.stderr,res.stderr
assert flow_tabs()=={DOT+'three':('left',cfg(runs['three'])['url'])},flow_tabs()
o=[c for c in new if c[:3]==['hydrogen','webview','open-or-refresh']];assert o and o[0][o[0].index('--panel-id')+1]=='left',new
assert ['hydrogen','workspace','remove-tab','--name',DOT+'buck-12v5v'] in new
time.sleep(.3);assert alive(a['url']) is None,'the finished run that gave its tab up keeps no server'
assert all(e['runDir']!=str(runs['one'].resolve()) for e in reg())
# --- another thread on the same board: warning, no tab; --new-tab --why adds the thread to the name
reqs=p/'req.json';reqs.write_text(json.dumps({'title':'three'}))
count=opens();res=run(runs['four'],'intake','--prompt-time','2026-10-09T14:00:00Z','--engine','claude','--requirements',str(reqs),thread='nova')
assert opens()==count and 'is already being driven by thread "astra"' in res.stderr and DOT+'three' in res.stderr and '--new-tab --why' in res.stderr,res.stderr
run(runs['four'],'timeline','open','--new-tab','--why','second opinion on the same board',thread='nova');assert opens()==count+1
assert cfg(runs['four'])['name']==DOT+'three · nova',cfg(runs['four'])
assert json.loads((runs['four']/'timeline'/'timeline.json').read_text())['aiThread']=='nova'
# --- timeline list: registry reconciled with Hydrogen; stray AI Flow tabs are unknown; human tabs never listed
s=json.loads(state.read_text());s['panes']['right'].append({'name':DOT+'stray','url':'http://stray/'});state.write_text(json.dumps(s))
res=bare('timeline','list');out=res.stdout
assert DOT+'three\n' in out and DOT+'three · nova' in out and 'AI thread nova' in out and 'AI thread astra' in out and 'not finished' in out and 'server alive' in out,out
assert DOT+'stray' in out and 'unknown' in out and 'Human notes' not in out and 'second opinion on the same board' in out,out
# --- timeline close: refuses non-AI-Flow names; --finished closes only finished AI Flow tabs
res=bare('timeline','close','--name','Human notes',code=2);assert 'Human notes' in tabs()
edit(runs['three'],lambda d:d['clock'].__setitem__('deliveredTime','2026-10-09T15:00:00Z'))
before=set(tabs());res=bare('timeline','close','--finished');after=set(tabs())
assert before-after=={DOT+'three'},(before-after,res.stdout)
assert {'Human notes','Visual Studio Code',DOT+'stray',DOT+'three · nova'}<=after
removed=[c[4] for c in log() if c[:3]==['hydrogen','workspace','remove-tab']];assert 'Human notes' not in removed and 'Visual Studio Code' not in removed
bare('timeline','close','--name',DOT+'stray');assert DOT+'stray' not in tabs() and 'Human notes' in tabs()
# --- headless override and visible UI failure
off=dict(env,AIFLOW_DASHBOARD='off');run(p/'headless','intake','--prompt-time','2026-10-09T10:00:00Z',extra=off);assert not (p/'headless'/'dashboard.json').exists()
mock.write_text('#!/bin/sh\nexit 1\n');run(runs['four'],'status',thread='nova')
res=run(runs['four'],'status',thread='nova');assert 'Dashboard unavailable'in res.stderr
print('PASS: intake tab named for the run, reuse, navigate, closed-tab recovery, label from the original board with rename in place, hex tab migrated in its pane, resume, server restart, SSE + replay, same-thread challenge, --new-tab --why, close --run with verified server stop, finished-tab reuse, other-thread warning, name collision adds the thread, list, close --finished/--name guard, headless override, visible UI failure')
finally:
# Only terminate test-owned timeline processes identified by exact temporary run path.
for line in subprocess.check_output(['ps','-eo','pid,args'],text=True).splitlines():
if binary in line and root in line and 'timeline serve'in line:
os.kill(int(line.split()[0]),signal.SIGTERM)