Let the extension update itself, and host the dashboard

Chrome has no update channel for an unpacked extension, so every pull left one
manual step: click reload on its card. The dashboard now publishes the version
its own checkout ships at /api/autobuyer/status, and the extension reloads
itself when it is behind — chrome.runtime.reload() re-reads the folder from
disk, which the update check has already pulled into. It only ever runs code
that is on disk, so a reload that changes nothing is reported and waited out
rather than repeated; a dashboard that is behind is ignored.

Also adds the deploy stack for running the dashboard on the droplet, next to
the autotrader one: it joins that nginx's network and renders a server block
into its conf.d rather than standing up a second ingress. AUTOFIRMER_DB moves
the SQLite file onto a volume, since the working directory in a container is
replaced on every deploy.

And waitFor gained `hidden`: an absent gate matched on DOM presence only, so a
modal that closes by going display:none could never satisfy it. The extension
grew a visibility probe for this — locate cannot answer it, because absent and
hidden are both errors there and a wait gate has to tell them apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-08-31 12:30:59 -05:00
co-authored by Claude Opus 5
parent 1f4459985a
commit d1e8668440
18 changed files with 637 additions and 28 deletions
+25 -6
View File
@@ -34,7 +34,7 @@ HEARTBEAT_SECONDS = 2.0
# Bumped whenever the step vocabulary or the locate protocol changes. Reported in
# the heartbeat so the dashboard can say "restart your runner" instead of letting
# a stale process fail on a step type it has never heard of.
VERSION = "0.17.0"
VERSION = "0.18.0"
# Shared with the heartbeat thread: whether a run is currently executing.
_busy = threading.Event()
@@ -80,9 +80,14 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
# `absent`, until it is gone. Nothing is clicked or typed. Whatever has to
# change the page — a person solving a challenge, a modal closing itself,
# a slow server — happens outside this run.
#
# `hidden` widens an absent gate from "removed from the DOM" to "no
# longer visible": a modal that closes by going display:none never stops
# matching its selector, so the plain gate can only ever time out.
selector = step["selector"]
absent = bool(step.get("absent"))
goal = "disappear" if absent else "appear"
by_sight = absent and bool(step.get("hidden"))
goal = "disappear from view" if by_sight else "disappear" if absent else "appear"
timeout_s = float(step.get("timeoutSeconds", 120))
deadline = time.time() + timeout_s
announced = False
@@ -93,10 +98,24 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
raise actions.StepError("cancelled while waiting")
try:
dash.locate(selector, int(step.get("index", 0)),
step.get("urlPattern", "") or opts.url,
opts.timeout, step.get("openUrl", "") or "")
if not absent:
seen = dash.locate(selector, int(step.get("index", 0)),
step.get("urlPattern", "") or opts.url,
opts.timeout, step.get("openUrl", "") or "",
options={"op": "visible"} if by_sight else None)
if by_sight:
# An extension too old for the probe quietly does an ordinary
# locate instead, whose result has no `visible` key — reading
# that as "gone" would wave the gate through on a visible
# element, so insist the answer is the one we asked for.
if seen.get("op") != "visible":
raise actions.StepError(
"waitFor `hidden` needs AutoBuyer extension 0.12.0+ — "
"reload it in chrome://extensions"
)
if not seen.get("visible"):
report(f"{selector} is out of sight ({seen.get('reason') or 'hidden'})")
break
elif not absent:
report(f"{selector} appeared")
break
# Still there. Keep waiting for it to go.