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.50
01ab14c
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
"""Issue #141: the last route's numbers and every command that ended with findings or an error show
in `status`, in run.json and on the timeline's data endpoint (GET /state), and stay there across a
board change until the command runs again. A small synthetic board whose net J7-J8 cannot be routed
(J8 sits outside the outline), a fake kicad-cli that reports one unconnected item on the routed board;
no dashboard, no bridge. Usage: python3 tests/attention-workflow.py <adom-aiflow binary>"""
import json, os, pathlib, socket, subprocess, sys, tempfile, time, urllib.request
binary = str(pathlib.Path(sys.argv[1]).resolve())
def board_text(shift=0.0, j8=(40, 40)):
pads = [("J1", "A", 2 + shift, 2), ("J2", "A", 6, 2), ("J3", "B", 2, 8), ("J4", "B", 18, 8), ("J7", "C", 10, 5), ("J8", "C", *j8)]
t = '(kicad_pcb\n\t(version 20260206)\n\t(generator "test")\n\t(layers\n\t\t(0 "F.Cu" signal)\n\t\t(2 "B.Cu" signal)\n\t\t(25 "Edge.Cuts" user)\n\t\t(31 "F.CrtYd" user)\n\t)\n'
for r, net, x, y in pads:
t += f'\t(footprint "T:{r}"\n\t\t(layer "F.Cu")\n\t\t(at {x} {y})\n\t\t(property "Reference" "{r}")\n\t\t(fp_rect\n\t\t\t(start -0.9 -0.9)\n\t\t\t(end 0.9 0.9)\n\t\t\t(stroke\n\t\t\t\t(width 0.05)\n\t\t\t\t(type solid)\n\t\t\t)\n\t\t\t(fill no)\n\t\t\t(layer "F.CrtYd")\n\t\t)\n\t\t(pad "1" thru_hole circle\n\t\t\t(at 0 0)\n\t\t\t(size 1.2 1.2)\n\t\t\t(drill 0.6)\n\t\t\t(layers "*.Cu")\n\t\t\t(net "{net}")\n\t\t)\n\t)\n'
t += '\t(gr_rect\n\t\t(start 0 0)\n\t\t(end 20 10)\n\t\t(layer "Edge.Cuts")\n\t)\n)\n'
return t
with tempfile.TemporaryDirectory(prefix='aiflow-attention-') as td:
p = pathlib.Path(td)
(p / 'b.kicad_pcb').write_text(board_text())
(p / 'b2.kicad_pcb').write_text(board_text(0.5))
(p / 'b3.kicad_pcb').write_text(board_text(0.5, (14, 5)))
(p / 'spec.json').write_text(json.dumps({"board": "attention fixture", "clearance": 0.2}))
fake = p / 'kicad-cli'
# one unconnected item on the routed board only, so the gate ends with findings
fake.write_text('#!/usr/bin/env python3\nimport sys, json\na = sys.argv[1:]\n'
'if "--help" in a: print("--refill-zones"); sys.exit(0)\n'
'out = a[a.index("--output") + 1]\nrouted = any("routed" in x for x in a)\n'
'json.dump({"violations": [], "unconnected_items": ([{"type": "unconnected_items", "description": "Missing connection between items", "items": [{"description": "PTH pad 1 [C] of J8"}]}] if routed else [])}, open(out, "w"))\n')
fake.chmod(0o755)
run = p / 'run'
env = dict(os.environ, AIFLOW_DASHBOARD='off', ADOM_AIFLOW_KICAD_CLI=str(fake))
env.pop('KICAD_REMOTE_TARGET', None)
def call(*args):
return subprocess.run([binary, '--run', str(run), '--ai-thread', 'attention-test', *args], env=env, capture_output=True, text=True, timeout=180)
def data():
return json.loads((run / 'run.json').read_text())
def timeline():
# with the dashboard off nothing refreshes the file by itself: `timeline write` does
assert call('timeline', 'write').returncode == 0
return json.loads((run / 'timeline' / 'timeline.json').read_text())
r = call('start', '--board', str(p / 'b.kicad_pcb'), '--spec', str(p / 'spec.json'), '--engine', 'test')
assert r.returncode == 0, r.stdout + r.stderr
# route ends with findings (J8 is off the board): exit 0, never a refusal
r = call('route', '--passes', '2')
assert r.returncode == 0, r.stdout + r.stderr
assert r.stdout.startswith('FINDINGS'), r.stdout
d = data()
lr = d['lastRoute']
assert lr['unrouted'] >= 1 and lr['connections'] >= lr['unrouted'] and lr['routed'] == lr['connections'] - lr['unrouted'], lr
assert 'C' in lr['netsUnrouted'] and lr['vias'] is not None and lr['boardRevision'] == 1, lr
chk = d['checks']['route']
assert chk['kind'] == 'findings' and chk['step'] == 'routing' and chk['count'] >= 1 and 'routing closed' in chk['lines'][0], chk
led = [json.loads(l) for l in (run / 'run.jsonl').read_text().splitlines()]
assert any(e.get('event') == 'command-outcome' and e['key'] == 'route' and e['outcome'] == 'findings' for e in led)
# an error (could not run) is recorded as an error, distinct from findings
r = call('gate', '--plan', str(p / 'missing-plan.json'))
assert r.returncode != 0 and data()['checks']['gate']['kind'] == 'error', r.stdout
# the gate on the route's plan ends with findings and replaces the error
r = call('gate')
assert r.returncode == 0 and r.stdout.startswith('FINDINGS'), r.stdout + r.stderr
d = data()
assert d['checks']['gate']['kind'] == 'findings' and d['lastGate']['unconnected'] == 1, (d['checks'], d.get('lastGate'))
# status names the numbers and the open checks; a status call itself clears nothing
r = call('status')
if os.environ.get('SHOW'): print(r.stdout)
assert r.returncode == 0, r.stdout + r.stderr
assert 'last route' in r.stdout and f"{lr['routed']} of {lr['connections']} connection(s) routed, {lr['unrouted']} unrouted" in r.stdout, r.stdout
assert 'measured on the current board' in r.stdout and 'needs a look (2 command(s)' in r.stdout and '[findings] `route`' in r.stdout, r.stdout
assert 'needs a look (' in r.stdout.split('Hint:', 1)[1], 'the step table names the routing step'
# the timeline: the routing step carries the badge, the route card the numbers
tl = timeline()
if os.environ.get('SHOW'): print(json.dumps({k: tl[k] for k in ('route', 'checks')}, indent=1), [(s['name'], s['attention'] and s['attention']['kind']) for s in tl['steps']])
routing = [s for s in tl['steps'] if s['name'] == 'routing']
assert routing, [s['name'] for s in tl['steps']]
if routing:
a = routing[0]['attention']
assert a and a['kind'] == 'findings' and a['count'] >= 2 and {i['key'] for i in a['items']} == {'route', 'gate'}, a
assert tl['route']['route']['unrouted'] == lr['unrouted'] and tl['route']['route']['onCurrentBoard'] is True, tl['route']
assert {c['key'] for c in tl['checks']} == {'route', 'gate'}, tl['checks']
# the board changes: outcomes are voided, the route card stays, labelled with its board
r = call('adopt-board', '--board', str(p / 'b2.kicad_pcb'), '--evidence', 'test: moved J1 by 0.5 mm')
assert r.returncode == 0, r.stdout + r.stderr
d = data()
assert d['lastRoute'] == lr, 'a new board keeps the last route record'
r = call('status')
assert 'previous board' in r.stdout and f"{lr['unrouted']} unrouted" in r.stdout and '[findings] `route`' in r.stdout, r.stdout
tl = timeline()
assert tl['route']['route']['onCurrentBoard'] is False and 'previous board' in tl['route']['route']['measuredOn'], tl['route']
# the data endpoint serves the same: GET /state from `timeline serve`
with socket.socket() as sock:
sock.bind(('127.0.0.1', 0)); port = sock.getsockname()[1]
proc = subprocess.Popen([binary, '--run', str(run), 'timeline', 'serve', '--port', str(port)], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
for _ in range(100):
try: state = json.load(urllib.request.urlopen(f'http://127.0.0.1:{port}/state', timeout=3)); break
except OSError: time.sleep(.05)
else: raise AssertionError('server did not start')
st = state['timeline']
assert st['route']['route']['unrouted'] == lr['unrouted'] and st['route']['route']['onCurrentBoard'] is False, st['route']
assert any(c['key'] == 'route' and c['kind'] == 'findings' for c in st['checks']), st['checks']
finally:
proc.terminate(); proc.wait(timeout=5)
# the next route replaces the record and, ending with findings again, keeps the check; a clean
# run of a command clears its own entry only
r = call('route', '--passes', '2')
d = data()
assert r.returncode == 0 and d['lastRoute']['boardRevision'] == 2 and d['lastRoute']['board'] != lr['board'], d['lastRoute']
assert d['checks']['route']['kind'] == 'findings', d['checks']
tl = timeline()
assert tl['route']['route']['onCurrentBoard'] is True, tl['route']
# J8 moved onto the board: the route closes, its check clears; the gate's stays until the gate runs
assert call('adopt-board', '--board', str(p / 'b3.kicad_pcb'), '--evidence', 'test: J8 onto the board').returncode == 0
r = call('route', '--passes', '4')
assert r.returncode == 0 and r.stdout.startswith('OK'), r.stdout + r.stderr
d = data()
assert d['lastRoute']['unrouted'] == 0 and 'route' not in d['checks'] and 'gate' in d['checks'], (d['lastRoute'], d['checks'])
r = call('status')
assert 'last route' in r.stdout and ', 0 unrouted;' in r.stdout and '`route`' not in r.stdout and '`gate`' in r.stdout, r.stdout
print('PASS: route findings, gate error then findings, status lines, step badge, route card, board change labelled "previous board", GET /state carries them, a clean route clears its check only')