Revamp login guards into general per-firm page guards
Replace the single hardcoded signed-out check with a guards list on each firm: any guard can trip mid-run via a URL substring (checked on every step) or a selector probe (checked when a step fails). Each guard defines recovery steps or a human-wait fallback, a per-run trigger cap, and a resume policy — 'continue' retries the tripped step, 'restart' rewinds the run to step 1. Runner bumped to 0.20.0; the server still ships the legacy signedOut/authSteps shape for stale runners, and a new runner synthesizes a guard from a legacy payload. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
82b98b9d2d
commit
b1dccd7dec
@@ -1,5 +1,5 @@
|
|||||||
import { claimRun } from '@/lib/db';
|
import { claimRun } from '@/lib/db';
|
||||||
import { findAutomation, resolveSteps, resolveAuthSteps } from '@/lib/automations';
|
import { findAutomation, resolveSteps, resolveGuards, resolveAuthSteps } from '@/lib/automations';
|
||||||
import { corsJson, corsPreflight } from '../../cors';
|
import { corsJson, corsPreflight } from '../../cors';
|
||||||
|
|
||||||
/** The Python runner polls this. Returns the run plus the steps to execute, so
|
/** The Python runner polls this. Returns the run plus the steps to execute, so
|
||||||
@@ -20,6 +20,9 @@ export async function POST() {
|
|||||||
firm: found.firm.label,
|
firm: found.firm.label,
|
||||||
label: found.automation.label,
|
label: found.automation.label,
|
||||||
steps: resolveSteps(found.firm, found.automation, safeInputs(row.inputs)),
|
steps: resolveSteps(found.firm, found.automation, safeInputs(row.inputs)),
|
||||||
|
guards: resolveGuards(found.firm),
|
||||||
|
// Pre-0.20 runners know nothing of guards; keep feeding them the
|
||||||
|
// old shape until they are restarted.
|
||||||
authSteps: resolveAuthSteps(found.firm),
|
authSteps: resolveAuthSteps(found.firm),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+38
-19
@@ -15,7 +15,8 @@ class StepError(Exception):
|
|||||||
"""A step refused to run, or ran and could not be verified.
|
"""A step refused to run, or ran and could not be verified.
|
||||||
|
|
||||||
`code` mirrors the CLI's exit codes: 3 = refused before acting,
|
`code` mirrors the CLI's exit codes: 3 = refused before acting,
|
||||||
4 = acted but verification failed, 5 = the session is signed out.
|
4 = acted but verification failed, 5 = a page guard tripped (login
|
||||||
|
redirect, blocking modal, and the like).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, message: str, code: int = 3):
|
def __init__(self, message: str, code: int = 3):
|
||||||
@@ -23,33 +24,44 @@ class StepError(Exception):
|
|||||||
self.code = code
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
class SignedOutError(StepError):
|
class GuardTripped(StepError):
|
||||||
"""The tab is on the login page, so the session has expired.
|
"""A page guard's condition is on the page — the run cannot proceed past it.
|
||||||
|
|
||||||
Distinct from an ordinary failure because nothing is wrong with the
|
Distinct from an ordinary failure because nothing is wrong with the step:
|
||||||
automation — it just cannot proceed until a human signs in.
|
the page has been taken over by something (a login redirect, an expired-
|
||||||
|
session modal) and every selector now resolves against the wrong document.
|
||||||
|
A click or a keystroke aimed at the old page would land on whatever the
|
||||||
|
guard page happens to render in the same place. The runner decides how to
|
||||||
|
recover; the CLI just stops with exit code 5.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, url: str):
|
def __init__(self, guard: dict, url: str):
|
||||||
|
name = guard.get("label") or guard.get("id") or "page"
|
||||||
super().__init__(
|
super().__init__(
|
||||||
f"signed out — the tab is on {url}. Log in to the site, then run this again.",
|
f"{name} guard tripped — the tab is on {url}. "
|
||||||
|
"Deal with the page, then run this again.",
|
||||||
code=5,
|
code=5,
|
||||||
)
|
)
|
||||||
|
self.guard = guard
|
||||||
|
self.url = url
|
||||||
|
|
||||||
|
|
||||||
def check_signed_in(found: dict, signed_out: str) -> None:
|
def check_guards(found: dict, guards: list | None) -> None:
|
||||||
"""Abort if the page redirected to a login screen.
|
"""Abort if the page has tripped any URL guard.
|
||||||
|
|
||||||
Worth doing before every action, not just at the start: a session can expire
|
Worth doing before every action, not just at the start: a session can
|
||||||
mid-run, and once it does, every selector resolves against a login form. A
|
expire (or a guard page appear) mid-run. Only URL guards are checked here —
|
||||||
click or a keystroke aimed at the old page would land on whatever that form
|
the URL rides along with every locate answer, so they are free. Selector
|
||||||
happens to render in the same place.
|
guards cost a locate round trip each and are probed by the runner only once
|
||||||
|
a step has already failed.
|
||||||
"""
|
"""
|
||||||
if not signed_out:
|
if not guards:
|
||||||
return
|
return
|
||||||
url = found.get("url") or ""
|
url = found.get("url") or ""
|
||||||
if signed_out in url:
|
for guard in guards:
|
||||||
raise SignedOutError(url)
|
pattern = guard.get("urlContains") or ""
|
||||||
|
if pattern and pattern in url:
|
||||||
|
raise GuardTripped(guard, url)
|
||||||
|
|
||||||
|
|
||||||
# Display scaling factors that actually exist. Anything else means the two sides
|
# Display scaling factors that actually exist. Anything else means the two sides
|
||||||
@@ -117,6 +129,7 @@ def perform(
|
|||||||
url: str = "",
|
url: str = "",
|
||||||
open_url: str = "",
|
open_url: str = "",
|
||||||
signed_out: str = "",
|
signed_out: str = "",
|
||||||
|
guards: list | None = None,
|
||||||
frame: str = "",
|
frame: str = "",
|
||||||
text: str | None = None,
|
text: str | None = None,
|
||||||
clear: bool = False,
|
clear: bool = False,
|
||||||
@@ -142,6 +155,12 @@ def perform(
|
|||||||
rng = rng or random.Random()
|
rng = rng or random.Random()
|
||||||
say = report or (lambda _m: None)
|
say = report or (lambda _m: None)
|
||||||
|
|
||||||
|
# `guards` is the runner's list of page guards; `signed_out` is the CLI's
|
||||||
|
# single --signed-out pattern. Same check either way, so fold them together.
|
||||||
|
guards = list(guards or [])
|
||||||
|
if signed_out:
|
||||||
|
guards.append({"id": "signed-out", "label": "signed out", "urlContains": signed_out})
|
||||||
|
|
||||||
# `frame` names the <iframe> the element lives in; the extension resolves the
|
# `frame` names the <iframe> the element lives in; the extension resolves the
|
||||||
# selector inside it and offsets the coordinates back. Absent for the common
|
# selector inside it and offsets the coordinates back. Absent for the common
|
||||||
# top-document case, and then locate behaves exactly as before.
|
# top-document case, and then locate behaves exactly as before.
|
||||||
@@ -150,7 +169,7 @@ def perform(
|
|||||||
found = dash.locate(selector, index, url, timeout, open_url, options=frame_opts)
|
found = dash.locate(selector, index, url, timeout, open_url, options=frame_opts)
|
||||||
|
|
||||||
# Before any check that assumes we are on the real page.
|
# Before any check that assumes we are on the real page.
|
||||||
check_signed_in(found, signed_out)
|
check_guards(found, guards)
|
||||||
|
|
||||||
# Give a transient overlay a chance to finish leaving before giving up on it.
|
# Give a transient overlay a chance to finish leaving before giving up on it.
|
||||||
if found.get("covered") and not force:
|
if found.get("covered") and not force:
|
||||||
@@ -160,7 +179,7 @@ def perform(
|
|||||||
while found.get("covered") and time.time() < deadline:
|
while found.get("covered") and time.time() < deadline:
|
||||||
time.sleep(COVERED_POLL_SECONDS)
|
time.sleep(COVERED_POLL_SECONDS)
|
||||||
found = dash.locate(selector, index, url, timeout, open_url, options=frame_opts)
|
found = dash.locate(selector, index, url, timeout, open_url, options=frame_opts)
|
||||||
check_signed_in(found, signed_out)
|
check_guards(found, guards)
|
||||||
if not found.get("covered"):
|
if not found.get("covered"):
|
||||||
say("it cleared")
|
say("it cleared")
|
||||||
|
|
||||||
@@ -231,7 +250,7 @@ def perform(
|
|||||||
# Typing into a field that never took focus, or that ignores the input, fails
|
# Typing into a field that never took focus, or that ignores the input, fails
|
||||||
# silently and is otherwise indistinguishable from success.
|
# silently and is otherwise indistinguishable from success.
|
||||||
after = dash.locate(selector, index, url, timeout, open_url, options=frame_opts)
|
after = dash.locate(selector, index, url, timeout, open_url, options=frame_opts)
|
||||||
check_signed_in(after, signed_out)
|
check_guards(after, guards)
|
||||||
got = after.get("value")
|
got = after.get("value")
|
||||||
if got is None:
|
if got is None:
|
||||||
say("element exposes no value to verify against")
|
say("element exposes no value to verify against")
|
||||||
|
|||||||
+204
-97
@@ -34,7 +34,8 @@ HEARTBEAT_SECONDS = 2.0
|
|||||||
# Bumped whenever the step vocabulary or the locate protocol changes. Reported in
|
# 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
|
# 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.
|
# a stale process fail on a step type it has never heard of.
|
||||||
VERSION = "0.19.0"
|
# 0.20.0: page guards — run-level `guards` replace per-step signedOut/authSteps.
|
||||||
|
VERSION = "0.20.0"
|
||||||
|
|
||||||
# Shared with the heartbeat thread: whether a run is currently executing.
|
# Shared with the heartbeat thread: whether a run is currently executing.
|
||||||
_busy = threading.Event()
|
_busy = threading.Event()
|
||||||
@@ -57,8 +58,14 @@ def heartbeat_loop(dash: Dashboard, opts, stop: threading.Event) -> None:
|
|||||||
stop.wait(HEARTBEAT_SECONDS)
|
stop.wait(HEARTBEAT_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | None = None) -> None:
|
def run_one_step(dash: Dashboard, step: dict, opts, rng, report,
|
||||||
"""Dispatch a single step. Raises StepError / DashboardError on failure."""
|
run_id: int | None = None, guards: list | None = None) -> None:
|
||||||
|
"""Dispatch a single step. Raises StepError / DashboardError on failure,
|
||||||
|
GuardTripped when a URL guard's condition shows up on the page.
|
||||||
|
|
||||||
|
`guards` is empty for recovery steps: they run while a guard's condition
|
||||||
|
holds, so checking it again would trip on the very page they exist to fix.
|
||||||
|
"""
|
||||||
if step["action"] == "wait":
|
if step["action"] == "wait":
|
||||||
time.sleep(float(step.get("seconds", 1)))
|
time.sleep(float(step.get("seconds", 1)))
|
||||||
report(f"waited {step.get('seconds', 1)}s")
|
report(f"waited {step.get('seconds', 1)}s")
|
||||||
@@ -72,7 +79,7 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
|
|||||||
navigate_url=target)
|
navigate_url=target)
|
||||||
# Navigating to a protected page is where an expired session shows up:
|
# Navigating to a protected page is where an expired session shows up:
|
||||||
# the site answers with a redirect to its login form.
|
# the site answers with a redirect to its login form.
|
||||||
actions.check_signed_in(landed, step.get("signedOut", "") or "")
|
actions.check_guards(landed, guards)
|
||||||
report(f"tab is on {landed.get('url', target)}")
|
report(f"tab is on {landed.get('url', target)}")
|
||||||
|
|
||||||
elif step["action"] == "waitFor":
|
elif step["action"] == "waitFor":
|
||||||
@@ -110,6 +117,10 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
|
|||||||
step.get("urlPattern", "") or opts.url,
|
step.get("urlPattern", "") or opts.url,
|
||||||
opts.timeout, step.get("openUrl", "") or "",
|
opts.timeout, step.get("openUrl", "") or "",
|
||||||
options=loc_opts or None)
|
options=loc_opts or None)
|
||||||
|
# A guard page appearing mid-gate would otherwise burn the whole
|
||||||
|
# gate timeout before anyone noticed. GuardTripped is not caught
|
||||||
|
# below, so it escapes the wait as it should.
|
||||||
|
actions.check_guards(seen, guards)
|
||||||
if by_sight:
|
if by_sight:
|
||||||
# An extension too old for the probe quietly does an ordinary
|
# An extension too old for the probe quietly does an ordinary
|
||||||
# locate instead, whose result has no `visible` key — reading
|
# locate instead, whose result has no `visible` key — reading
|
||||||
@@ -197,7 +208,7 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
|
|||||||
index=int(step.get("index", 0)),
|
index=int(step.get("index", 0)),
|
||||||
url=step.get("urlPattern", "") or opts.url,
|
url=step.get("urlPattern", "") or opts.url,
|
||||||
open_url=step.get("openUrl", "") or "",
|
open_url=step.get("openUrl", "") or "",
|
||||||
signed_out=step.get("signedOut", "") or "",
|
guards=guards,
|
||||||
frame=step.get("frame", "") or "",
|
frame=step.get("frame", "") or "",
|
||||||
text=step.get("text"),
|
text=step.get("text"),
|
||||||
clear=bool(step.get("clear")),
|
clear=bool(step.get("clear")),
|
||||||
@@ -224,73 +235,195 @@ def run_status(dash: Dashboard, run_id: int) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def wait_for_sign_in(dash: Dashboard, step: dict, run_id: int, opts, deadline: float) -> bool:
|
def guard_present(dash: Dashboard, guard: dict, step: dict, opts,
|
||||||
"""Block until the tab leaves the login page.
|
probe_timeout: float = 6.0) -> bool | None:
|
||||||
|
"""Is the guard's condition on the page right now? None = could not tell.
|
||||||
|
|
||||||
The runner does not log in — entering credentials is the human's job, and a
|
A URL guard reads the tab's URL off a cheap body locate; a selector guard
|
||||||
keystroke aimed at the wrong field is not something to risk automating. All
|
needs a real DOM probe, whose timeout is kept short — a guard element that
|
||||||
this does is watch the tab and pick the run back up once you are through.
|
exists shows up immediately, so most of the timeout is only ever spent
|
||||||
|
confirming absence. A guard defining both trips when either is present.
|
||||||
"""
|
"""
|
||||||
signed_out = step.get("signedOut", "") or ""
|
|
||||||
pattern = step.get("urlPattern", "") or opts.url
|
pattern = step.get("urlPattern", "") or opts.url
|
||||||
open_url = step.get("openUrl", "") or ""
|
open_url = step.get("openUrl", "") or ""
|
||||||
|
|
||||||
while time.time() < deadline:
|
if guard.get("urlContains"):
|
||||||
if run_status(dash, run_id) == "cancelled":
|
|
||||||
return False
|
|
||||||
time.sleep(3.0)
|
|
||||||
try:
|
try:
|
||||||
landed = dash.locate("body", 0, pattern, opts.timeout, open_url)
|
landed = dash.locate("body", 0, pattern, opts.timeout, open_url)
|
||||||
except DashboardError:
|
except DashboardError:
|
||||||
continue # tab busy mid-redirect; look again shortly
|
return None # tab busy mid-redirect; the caller decides
|
||||||
if signed_out not in (landed.get("url") or ""):
|
if guard["urlContains"] in (landed.get("url") or ""):
|
||||||
return True
|
return True
|
||||||
|
if not guard.get("selector"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if guard.get("selector"):
|
||||||
|
try:
|
||||||
|
dash.locate(guard["selector"], 0, pattern, probe_timeout, open_url)
|
||||||
|
return True
|
||||||
|
except NotFoundError:
|
||||||
|
return False
|
||||||
|
except DashboardError:
|
||||||
|
return None
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def signed_in_now(dash: Dashboard, step: dict, opts, settle_seconds: float = 20.0) -> bool:
|
def guard_cleared(dash: Dashboard, guard: dict, step: dict, opts,
|
||||||
"""Did we actually get back in?
|
settle_seconds: float = 20.0) -> bool:
|
||||||
|
"""Did recovery actually clear the condition?
|
||||||
|
|
||||||
Auth steps can 'succeed' — every click landing — while the site still rejects
|
Recovery steps can 'succeed' — every click landing — while the page stays in
|
||||||
the sign-in, so this confirms rather than assumes before the failed step is
|
the guarded state, so this confirms rather than assumes before resuming.
|
||||||
retried.
|
|
||||||
|
|
||||||
It polls rather than checking once: submitting a login form starts a network
|
It polls rather than checking once: submitting a login form starts a network
|
||||||
round trip and then a redirect, so the tab is still sitting on the login URL
|
round trip and then a redirect, so the tab is still sitting on the guard
|
||||||
for a second or two afterwards. Checking immediately reports a failure for a
|
page for a second or two afterwards. Checking immediately reports a failure
|
||||||
sign-in that is merely still in flight.
|
for a recovery that is merely still in flight.
|
||||||
"""
|
"""
|
||||||
signed_out = step.get("signedOut", "") or ""
|
|
||||||
if not signed_out:
|
|
||||||
return True
|
|
||||||
|
|
||||||
deadline = time.time() + settle_seconds
|
deadline = time.time() + settle_seconds
|
||||||
while True:
|
while True:
|
||||||
try:
|
if guard_present(dash, guard, step, opts) is False:
|
||||||
landed = dash.locate("body", 0, step.get("urlPattern", "") or opts.url,
|
|
||||||
opts.timeout, step.get("openUrl", "") or "")
|
|
||||||
if signed_out not in (landed.get("url") or ""):
|
|
||||||
return True
|
return True
|
||||||
except DashboardError:
|
|
||||||
pass # mid-redirect the tab can be briefly un-injectable
|
|
||||||
|
|
||||||
if time.time() >= deadline:
|
if time.time() >= deadline:
|
||||||
return False
|
return False
|
||||||
time.sleep(1.0)
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_clear(dash: Dashboard, guard: dict, step: dict, run_id: int, opts,
|
||||||
|
deadline: float) -> bool:
|
||||||
|
"""Block until a human deals with the guard's condition.
|
||||||
|
|
||||||
|
The runner does not act here — for the login guard, entering credentials is
|
||||||
|
the human's job, and a keystroke aimed at the wrong field is not something
|
||||||
|
to risk automating. All this does is watch the page and pick the run back
|
||||||
|
up once the condition is gone.
|
||||||
|
"""
|
||||||
|
while time.time() < deadline:
|
||||||
|
if run_status(dash, run_id) == "cancelled":
|
||||||
|
return False
|
||||||
|
time.sleep(3.0)
|
||||||
|
if guard_present(dash, guard, step, opts) is False:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def find_tripped_guard(dash: Dashboard, guards: list, step: dict, opts) -> dict | None:
|
||||||
|
"""After a step failure: is a guard's condition what actually broke it?
|
||||||
|
|
||||||
|
URL guards are caught in flight by check_guards; selector guards cost a
|
||||||
|
locate round trip each, so they are only probed once a step has already
|
||||||
|
failed — a session-expired modal explains a NotFound far better than a
|
||||||
|
wrong selector does.
|
||||||
|
"""
|
||||||
|
for guard in guards:
|
||||||
|
if not guard.get("selector"):
|
||||||
|
continue # URL-only guards were already checked in flight
|
||||||
|
if guard_present(dash, guard, step, opts):
|
||||||
|
return guard
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_guards(run: dict) -> list:
|
||||||
|
"""Guards synthesized from the pre-guard payload shape, so this runner still
|
||||||
|
works against a dashboard that predates run-level guards."""
|
||||||
|
for step in run.get("steps") or []:
|
||||||
|
if step.get("signedOut"):
|
||||||
|
return [{
|
||||||
|
"id": "signed-out",
|
||||||
|
"label": "signed out",
|
||||||
|
"urlContains": step["signedOut"],
|
||||||
|
"resume": "continue",
|
||||||
|
"waitSeconds": float(step.get("signedOutWait", 0) or 0),
|
||||||
|
"recoverySteps": run.get("authSteps") or [],
|
||||||
|
}]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def handle_guard(dash: Dashboard, run_id: int, step_index: int, label: str,
|
||||||
|
guard: dict, step: dict, opts, rng, report,
|
||||||
|
trigger_counts: dict) -> str:
|
||||||
|
"""A guard tripped mid-run: recover, then say where the run picks up.
|
||||||
|
|
||||||
|
Returns "retry" (redo the step that tripped), "restart" (back to step 1),
|
||||||
|
"cancelled", or "fail" — the terminal outcomes have already been reported
|
||||||
|
and the run finished by the time they are returned.
|
||||||
|
"""
|
||||||
|
gid = guard.get("id") or "guard"
|
||||||
|
name = guard.get("label") or gid
|
||||||
|
|
||||||
|
def fail(msg: str) -> str:
|
||||||
|
post_progress(dash, run_id, step_index, label, False, msg)
|
||||||
|
finish(dash, run_id, msg)
|
||||||
|
print(f"✗ run #{run_id} stopped: {msg}", file=sys.stderr)
|
||||||
|
return "fail"
|
||||||
|
|
||||||
|
# A guard whose recovery "works" without clearing the cause would trip
|
||||||
|
# again on the next step, forever. Cap it per run, not per step: a restart
|
||||||
|
# revisits every step, so a per-step cap would never fill up.
|
||||||
|
trigger_counts[gid] = trigger_counts.get(gid, 0) + 1
|
||||||
|
limit = int(guard.get("maxTriggers") or 3)
|
||||||
|
if trigger_counts[gid] > limit:
|
||||||
|
return fail(f"{name} guard tripped {trigger_counts[gid]} times in one run — giving up")
|
||||||
|
|
||||||
|
recovery = guard.get("recoverySteps") or []
|
||||||
|
if recovery:
|
||||||
|
post_progress(dash, run_id, step_index,
|
||||||
|
f"{name} guard tripped — running {len(recovery)} recovery step(s)",
|
||||||
|
False, None)
|
||||||
|
try:
|
||||||
|
for rstep in recovery:
|
||||||
|
rlabel = rstep.get("label") or f"recovery: {rstep['action']}"
|
||||||
|
print(f" [{name}] {rlabel}")
|
||||||
|
# No guards passed: recovery runs on the guard page itself.
|
||||||
|
run_one_step(dash, rstep, opts, rng, report, run_id)
|
||||||
|
except (actions.StepError, DashboardError) as exc:
|
||||||
|
return fail(f"{name} guard recovery failed: {exc}")
|
||||||
|
|
||||||
|
if not guard_cleared(dash, guard, step, opts):
|
||||||
|
return fail(f"{name} guard recovery ran but the condition is still on the page")
|
||||||
|
report(f"{name} guard cleared")
|
||||||
|
|
||||||
|
else:
|
||||||
|
wait_seconds = float(guard.get("waitSeconds") or 0)
|
||||||
|
if wait_seconds <= 0:
|
||||||
|
return fail(f"{name} guard tripped and defines no recovery — stopping")
|
||||||
|
|
||||||
|
mins = wait_seconds / 60
|
||||||
|
note = f"{name} guard tripped — waiting up to {mins:.0f} min for you to deal with it"
|
||||||
|
print(f" {note}", file=sys.stderr)
|
||||||
|
post_progress(dash, run_id, step_index, note, False, None)
|
||||||
|
|
||||||
|
if not wait_for_clear(dash, guard, step, run_id, opts, time.time() + wait_seconds):
|
||||||
|
if run_status(dash, run_id) == "cancelled":
|
||||||
|
print(f"■ run #{run_id} cancelled while waiting on the {name} guard")
|
||||||
|
return "cancelled"
|
||||||
|
return fail(f"{name} guard still tripped after {mins:.0f} min")
|
||||||
|
report(f"{name} guard cleared")
|
||||||
|
|
||||||
|
return "restart" if (guard.get("resume") or "continue") == "restart" else "retry"
|
||||||
|
|
||||||
|
|
||||||
def run_steps(dash: Dashboard, run: dict, opts) -> None:
|
def run_steps(dash: Dashboard, run: dict, opts) -> None:
|
||||||
"""Work through one automation. Reports every step; stops on the first
|
"""Work through one automation. Reports every step; stops on the first
|
||||||
failure, because a half-completed purchase flow should not barrel on."""
|
failure, because a half-completed purchase flow should not barrel on.
|
||||||
|
|
||||||
|
Page guards can interrupt any step. Once one is handled, its `resume`
|
||||||
|
policy decides where the run picks up: "continue" retries the step that
|
||||||
|
tripped, "restart" goes back to the first step — hence a while loop over
|
||||||
|
the step index rather than a plain for."""
|
||||||
run_id = run["id"]
|
run_id = run["id"]
|
||||||
steps = run["steps"]
|
steps = run["steps"]
|
||||||
auth_steps = run.get("authSteps") or []
|
guards = run.get("guards")
|
||||||
|
if guards is None:
|
||||||
|
guards = legacy_guards(run)
|
||||||
rng = random.Random(opts.seed) if opts.seed is not None else random.Random()
|
rng = random.Random(opts.seed) if opts.seed is not None else random.Random()
|
||||||
|
|
||||||
print(f"\n▶ run #{run_id} — {run['label']} ({len(steps)} steps)")
|
print(f"\n▶ run #{run_id} — {run['label']} ({len(steps)} steps)")
|
||||||
|
|
||||||
for i, step in enumerate(steps):
|
trigger_counts: dict[str, int] = {}
|
||||||
|
i = 0
|
||||||
|
while i < len(steps):
|
||||||
|
step = steps[i]
|
||||||
label = step.get("label") or f"{step['action']} {step.get('selector', '')}".strip()
|
label = step.get("label") or f"{step['action']} {step.get('selector', '')}".strip()
|
||||||
print(f" [{i + 1}/{len(steps)}] {label}")
|
print(f" [{i + 1}/{len(steps)}] {label}")
|
||||||
|
|
||||||
@@ -300,74 +433,46 @@ def run_steps(dash: Dashboard, run: dict, opts) -> None:
|
|||||||
detail_parts.append(message)
|
detail_parts.append(message)
|
||||||
print(f" {message}")
|
print(f" {message}")
|
||||||
|
|
||||||
# One retry after a sign-in: the session can only be expired once per step.
|
outcome = None
|
||||||
for attempt in range(2):
|
|
||||||
try:
|
try:
|
||||||
run_one_step(dash, step, opts, rng, report, run_id)
|
run_one_step(dash, step, opts, rng, report, run_id, guards=guards)
|
||||||
break
|
|
||||||
|
|
||||||
except actions.SignedOutError as exc:
|
except actions.GuardTripped as exc:
|
||||||
wait_seconds = float(step.get("signedOutWait", 0) or 0)
|
print(f" {exc}", file=sys.stderr)
|
||||||
|
outcome = handle_guard(dash, run_id, i, label, exc.guard, step,
|
||||||
# Scripted auth first, if the firm defines any; otherwise fall
|
opts, rng, report, trigger_counts)
|
||||||
# back to pausing for a human. Only ever attempted once per step —
|
|
||||||
# auth that "succeeds" without signing in would otherwise loop.
|
|
||||||
if auth_steps and attempt == 0:
|
|
||||||
post_progress(dash, run_id, i, f"signed out — running {len(auth_steps)} auth step(s)",
|
|
||||||
False, exc.args[0])
|
|
||||||
try:
|
|
||||||
for astep in auth_steps:
|
|
||||||
alabel = astep.get("label") or f"auth: {astep['action']}"
|
|
||||||
print(f" [auth] {alabel}")
|
|
||||||
run_one_step(dash, astep, opts, rng, report, run_id)
|
|
||||||
except (actions.StepError, DashboardError) as aexc:
|
|
||||||
msg = f"auth steps failed: {aexc}"
|
|
||||||
post_progress(dash, run_id, i, label, False, msg)
|
|
||||||
finish(dash, run_id, msg)
|
|
||||||
print(f"✗ run #{run_id} stopped: {msg}", file=sys.stderr)
|
|
||||||
return
|
|
||||||
|
|
||||||
if not signed_in_now(dash, step, opts):
|
|
||||||
msg = "auth steps ran but the session is still signed out"
|
|
||||||
post_progress(dash, run_id, i, label, False, msg)
|
|
||||||
finish(dash, run_id, msg)
|
|
||||||
print(f"✗ run #{run_id} stopped: {msg}", file=sys.stderr)
|
|
||||||
return
|
|
||||||
|
|
||||||
report("signed in via auth steps — retrying")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if wait_seconds <= 0 or attempt > 0:
|
|
||||||
print(f" FAILED: {exc}", file=sys.stderr)
|
|
||||||
post_progress(dash, run_id, i, label, False, str(exc))
|
|
||||||
finish(dash, run_id, str(exc))
|
|
||||||
print(f"✗ run #{run_id} stopped at step {i + 1}")
|
|
||||||
return
|
|
||||||
|
|
||||||
mins = wait_seconds / 60
|
|
||||||
note = f"signed out — waiting up to {mins:.0f} min for you to log in"
|
|
||||||
print(f" {note}", file=sys.stderr)
|
|
||||||
post_progress(dash, run_id, i, note, False, exc.args[0])
|
|
||||||
|
|
||||||
if not wait_for_sign_in(dash, step, run_id, opts, time.time() + wait_seconds):
|
|
||||||
if run_status(dash, run_id) == "cancelled":
|
|
||||||
print(f"■ run #{run_id} cancelled while waiting for sign-in")
|
|
||||||
return
|
|
||||||
msg = f"still signed out after {mins:.0f} min"
|
|
||||||
post_progress(dash, run_id, i, label, False, msg)
|
|
||||||
finish(dash, run_id, msg)
|
|
||||||
print(f"✗ run #{run_id} gave up waiting for sign-in")
|
|
||||||
return
|
|
||||||
|
|
||||||
report("signed in — retrying the step")
|
|
||||||
|
|
||||||
except (actions.StepError, DashboardError) as exc:
|
except (actions.StepError, DashboardError) as exc:
|
||||||
|
if run_status(dash, run_id) == "cancelled":
|
||||||
|
print(f"■ run #{run_id} cancelled from the dashboard")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Before writing the failure off, see whether a selector guard's
|
||||||
|
# condition is sitting on the page — an expired-session modal
|
||||||
|
# explains a missing button better than a wrong selector does.
|
||||||
|
tripped = find_tripped_guard(dash, guards, step, opts) if guards else None
|
||||||
|
if tripped is not None:
|
||||||
|
name = tripped.get("label") or tripped.get("id")
|
||||||
|
print(f" step failed with the {name} guard's condition on the page",
|
||||||
|
file=sys.stderr)
|
||||||
|
outcome = handle_guard(dash, run_id, i, label, tripped, step,
|
||||||
|
opts, rng, report, trigger_counts)
|
||||||
|
else:
|
||||||
print(f" FAILED: {exc}", file=sys.stderr)
|
print(f" FAILED: {exc}", file=sys.stderr)
|
||||||
post_progress(dash, run_id, i, label, False, str(exc))
|
post_progress(dash, run_id, i, label, False, str(exc))
|
||||||
finish(dash, run_id, str(exc))
|
finish(dash, run_id, str(exc))
|
||||||
print(f"✗ run #{run_id} stopped at step {i + 1}")
|
print(f"✗ run #{run_id} stopped at step {i + 1}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if outcome == "retry":
|
||||||
|
continue # same step, another go
|
||||||
|
if outcome == "restart":
|
||||||
|
print(f" ↺ restarting run #{run_id} from step 1")
|
||||||
|
i = 0
|
||||||
|
continue
|
||||||
|
if outcome in ("fail", "cancelled"):
|
||||||
|
return # handle_guard already reported and finished
|
||||||
|
|
||||||
status = post_progress(dash, run_id, i + 1, label, True, "; ".join(detail_parts) or None)
|
status = post_progress(dash, run_id, i + 1, label, True, "; ".join(detail_parts) or None)
|
||||||
|
|
||||||
# The dashboard's Stop button shows up here.
|
# The dashboard's Stop button shows up here.
|
||||||
@@ -375,6 +480,8 @@ def run_steps(dash: Dashboard, run: dict, opts) -> None:
|
|||||||
print(f"■ run #{run_id} cancelled from the dashboard")
|
print(f"■ run #{run_id} cancelled from the dashboard")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
finish(dash, run_id, None)
|
finish(dash, run_id, None)
|
||||||
print(f"✓ run #{run_id} complete")
|
print(f"✓ run #{run_id} complete")
|
||||||
|
|
||||||
|
|||||||
+117
-39
@@ -110,7 +110,11 @@ export interface Automation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A step as handed to the runner: the firm's tab pattern and fallback URL
|
/** A step as handed to the runner: the firm's tab pattern and fallback URL
|
||||||
* filled in, so the runner never has to know which firm it is working on. */
|
* filled in, so the runner never has to know which firm it is working on.
|
||||||
|
*
|
||||||
|
* `signedOut`/`signedOutWait` are the pre-guard (runner < 0.20) shape of the
|
||||||
|
* login guard, still stamped on so a stale runner keeps working — a 0.20+
|
||||||
|
* runner ignores them in favour of the run's `guards`. */
|
||||||
export type ResolvedStep = AutomationStep & {
|
export type ResolvedStep = AutomationStep & {
|
||||||
urlPattern?: string;
|
urlPattern?: string;
|
||||||
openUrl?: string;
|
openUrl?: string;
|
||||||
@@ -118,6 +122,59 @@ export type ResolvedStep = AutomationStep & {
|
|||||||
signedOutWait?: number;
|
signedOutWait?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** A condition that can take over the page at any point in a run — a login
|
||||||
|
* redirect, a session-expired modal, a maintenance page, a rate-limit
|
||||||
|
* interstitial. Guards belong to the firm, not to any step: once the page is
|
||||||
|
* in that state, every selector resolves against the wrong document, so the
|
||||||
|
* run must deal with the guard before anything else can proceed.
|
||||||
|
*
|
||||||
|
* How a guard trips:
|
||||||
|
* - `urlContains` — the tab's URL carries this substring. Checked on every
|
||||||
|
* step, for free: the URL rides along with every locate answer.
|
||||||
|
* - `selector` — this element is on the page. A DOM probe costs a round
|
||||||
|
* trip, so the runner checks these when a step fails, where a guard is
|
||||||
|
* the likeliest explanation. A guard may define either or both (both =
|
||||||
|
* tripped when either is present).
|
||||||
|
*
|
||||||
|
* What happens then:
|
||||||
|
* 1. `recoverySteps` run, if there are any. They execute *while the guard
|
||||||
|
* condition holds* — on the login form, under the modal — so they carry
|
||||||
|
* no guard checks of their own; a wrong selector here is worse than
|
||||||
|
* elsewhere. Anything written here lives in this file in plain text,
|
||||||
|
* and this file is in the repo.
|
||||||
|
* 2. With no recovery steps, the run pauses up to `waitSeconds` for a human
|
||||||
|
* to deal with it (sign in, dismiss the thing). No recovery steps and no
|
||||||
|
* wait means tripping the guard fails the run.
|
||||||
|
* 3. Once the condition has verifiably cleared, `resume` decides where the
|
||||||
|
* run picks up: `'continue'` retries the step that tripped and carries
|
||||||
|
* on; `'restart'` goes back to the automation's first step — for
|
||||||
|
* conditions that reset page state (a re-login that lands on the
|
||||||
|
* dashboard, a modal that closes the checkout flow).
|
||||||
|
*/
|
||||||
|
export interface PageGuard {
|
||||||
|
/** Stable id — names the guard in run logs and counts its triggers per run. */
|
||||||
|
id: string;
|
||||||
|
/** Shown in the run log; falls back to `id`. */
|
||||||
|
label?: string;
|
||||||
|
/** Trip when the tab's URL contains this substring. */
|
||||||
|
urlContains?: string;
|
||||||
|
/** Trip when this element is on the page. */
|
||||||
|
selector?: string;
|
||||||
|
/** Steps that clear the condition. Run guard-free, on the guard page itself. */
|
||||||
|
recoverySteps?: AutomationStep[];
|
||||||
|
/** Where the run picks up once the condition clears. */
|
||||||
|
resume: 'restart' | 'continue';
|
||||||
|
/** Seconds to pause for a human when there are no `recoverySteps`. */
|
||||||
|
waitSeconds?: number;
|
||||||
|
/** Times this guard may trip in one run before the run fails; a guard whose
|
||||||
|
* recovery "works" without clearing the cause would otherwise loop.
|
||||||
|
* Default 3. */
|
||||||
|
maxTriggers?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A guard as handed to the runner: recovery steps expanded and resolved. */
|
||||||
|
export type ResolvedGuard = Omit<PageGuard, 'recoverySteps'> & { recoverySteps: ResolvedStep[] };
|
||||||
|
|
||||||
export interface Firm {
|
export interface Firm {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -127,27 +184,11 @@ export interface Firm {
|
|||||||
/** Concrete page to open when no tab matches `urlPattern`. A match pattern
|
/** Concrete page to open when no tab matches `urlPattern`. A match pattern
|
||||||
* can't be navigated to, so this has to be spelled out separately. */
|
* can't be navigated to, so this has to be spelled out separately. */
|
||||||
url: string;
|
url: string;
|
||||||
/** Substring identifying the signed-out page. When a session expires the site
|
/** Conditions that can interrupt a run at any point, and how to get past
|
||||||
* redirects here, and every subsequent selector resolves against a login form
|
* them. Every firm wants at least the signed-out guard: when a session
|
||||||
* — so a run that lands on it must stop rather than click through it. */
|
* expires the site redirects to its login page, and a run that lands there
|
||||||
signedOutPattern: string;
|
* must stop rather than click through the form. See PageGuard. */
|
||||||
/** Seconds to pause and let a human sign in when a run hits the login page.
|
guards: PageGuard[];
|
||||||
* 0 aborts instead. Used only when `authSteps` is empty. */
|
|
||||||
signedOutWaitSeconds: number;
|
|
||||||
/** Steps run when a run lands on the login page, before retrying the step
|
|
||||||
* that hit it. Left empty here on purpose — fill it in yourself.
|
|
||||||
*
|
|
||||||
* Two things to know if you do:
|
|
||||||
* - These run *while on the signed-out page*, so unlike normal steps they
|
|
||||||
* carry no signed-out guard. Nothing stops them clicking around a login
|
|
||||||
* form; that is the point, and also why a wrong selector here is worse
|
|
||||||
* than elsewhere.
|
|
||||||
* - Anything written here lives in this file in plain text, and this file
|
|
||||||
* is in the repo.
|
|
||||||
*
|
|
||||||
* While empty, a run that hits the login page falls back to pausing for
|
|
||||||
* `signedOutWaitSeconds` so you can sign in by hand. */
|
|
||||||
authSteps: AutomationStep[];
|
|
||||||
automations: Automation[];
|
automations: Automation[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,11 +201,18 @@ export const FIRMS: Firm[] = [
|
|||||||
label: 'Tradeify',
|
label: 'Tradeify',
|
||||||
urlPattern: 'https://app-f.tradeify.co/*',
|
urlPattern: 'https://app-f.tradeify.co/*',
|
||||||
url: 'https://app-f.tradeify.co/',
|
url: 'https://app-f.tradeify.co/',
|
||||||
signedOutPattern: '/auth/',
|
guards: [
|
||||||
signedOutWaitSeconds: 300,
|
{
|
||||||
authSteps: [
|
id: 'signed-out',
|
||||||
|
label: 'Signed out',
|
||||||
|
urlContains: '/auth/',
|
||||||
|
resume: 'continue',
|
||||||
|
waitSeconds: 300,
|
||||||
|
recoverySteps: [
|
||||||
{ action: 'click', selector: 'form > div > div:last-child button', label: 'Login' },
|
{ action: 'click', selector: 'form > div > div:last-child button', label: 'Login' },
|
||||||
],
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
automations: [
|
automations: [
|
||||||
{
|
{
|
||||||
id: 'buy-accounts',
|
id: 'buy-accounts',
|
||||||
@@ -248,14 +296,20 @@ export const FIRMS: Firm[] = [
|
|||||||
// well as subdomains, confirmed against a live tab on lucidtrading.com.
|
// well as subdomains, confirmed against a live tab on lucidtrading.com.
|
||||||
urlPattern: 'https://*.lucidtrading.com/*',
|
urlPattern: 'https://*.lucidtrading.com/*',
|
||||||
url: 'https://dash.lucidtrading.com/',
|
url: 'https://dash.lucidtrading.com/',
|
||||||
|
guards: [
|
||||||
|
{
|
||||||
|
id: 'signed-out',
|
||||||
|
label: 'Signed out',
|
||||||
// Signed out lands on the apex; signed in stays on dash. The leading `//`
|
// Signed out lands on the apex; signed in stays on dash. The leading `//`
|
||||||
// anchors this to the start of the host — without it, the substring also
|
// anchors this to the start of the host — without it, the substring also
|
||||||
// matches dash.lucidtrading.com and every step would abort while logged in.
|
// matches dash.lucidtrading.com and every step would abort while logged in.
|
||||||
signedOutPattern: '//lucidtrading.com/',
|
urlContains: '//lucidtrading.com/',
|
||||||
signedOutWaitSeconds: 300,
|
resume: 'continue',
|
||||||
authSteps: [
|
waitSeconds: 300,
|
||||||
|
recoverySteps: [
|
||||||
{ action: 'click', selector: 'button.lucid-login-btn', label: 'Login' },
|
{ action: 'click', selector: 'button.lucid-login-btn', label: 'Login' },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
automations: [
|
automations: [
|
||||||
{
|
{
|
||||||
@@ -371,8 +425,17 @@ function labelled(step: ResolvedStep, iteration: number, total: number): Resolve
|
|||||||
return { ...step, label: `${step.label ?? describeStep(step)} (${iteration}/${total})` };
|
return { ...step, label: `${step.label ?? describeStep(step)} (${iteration}/${total})` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The guard a pre-0.20 runner knew as "the" login guard: the first URL guard.
|
||||||
|
* Its pattern is still stamped onto steps (`signedOut`) and its recovery steps
|
||||||
|
* still ship as `authSteps`, so a stale runner keeps working until restarted.
|
||||||
|
* A 0.20+ runner ignores both in favour of the run-level `guards`. */
|
||||||
|
function legacyLoginGuard(firm: Firm): PageGuard | undefined {
|
||||||
|
return firm.guards.find((g) => g.urlContains);
|
||||||
|
}
|
||||||
|
|
||||||
/** One non-repeat step, with the firm's tab pattern and URLs filled in. */
|
/** One non-repeat step, with the firm's tab pattern and URLs filled in. */
|
||||||
function resolveOne(firm: Firm, step: AutomationStep): ResolvedStep {
|
function resolveOne(firm: Firm, step: AutomationStep): ResolvedStep {
|
||||||
|
const login = legacyLoginGuard(firm);
|
||||||
if (step.action === 'wait') return step;
|
if (step.action === 'wait') return step;
|
||||||
if (step.action === 'navigate') {
|
if (step.action === 'navigate') {
|
||||||
return {
|
return {
|
||||||
@@ -380,8 +443,8 @@ function resolveOne(firm: Firm, step: AutomationStep): ResolvedStep {
|
|||||||
url: step.url ?? firm.url,
|
url: step.url ?? firm.url,
|
||||||
urlPattern: firm.urlPattern,
|
urlPattern: firm.urlPattern,
|
||||||
openUrl: firm.url,
|
openUrl: firm.url,
|
||||||
signedOut: firm.signedOutPattern,
|
signedOut: login?.urlContains,
|
||||||
signedOutWait: firm.signedOutWaitSeconds,
|
signedOutWait: login?.waitSeconds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (step.action === 'waitFor') {
|
if (step.action === 'waitFor') {
|
||||||
@@ -404,8 +467,8 @@ function resolveOne(firm: Firm, step: AutomationStep): ResolvedStep {
|
|||||||
...step,
|
...step,
|
||||||
urlPattern: step.urlPattern ?? firm.urlPattern,
|
urlPattern: step.urlPattern ?? firm.urlPattern,
|
||||||
openUrl: firm.url,
|
openUrl: firm.url,
|
||||||
signedOut: firm.signedOutPattern,
|
signedOut: login?.urlContains,
|
||||||
signedOutWait: firm.signedOutWaitSeconds,
|
signedOutWait: login?.waitSeconds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,19 +514,34 @@ export function resolveSteps(firm: Firm, automation: Automation, inputs: RunInpu
|
|||||||
return expand(automation.steps, firm, automation, inputs);
|
return expand(automation.steps, firm, automation, inputs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Auth steps run on the login page itself, so they get the firm's tab pattern
|
/** Recovery steps run on the guard page itself, so they get the firm's tab
|
||||||
* but deliberately no `signedOut` guard — that guard exists to stop ordinary
|
* pattern but deliberately no `signedOut` stamp — that exists to stop ordinary
|
||||||
* steps acting on a login form, and these are the exception.
|
* steps acting on a login form, and these are the exception.
|
||||||
*
|
*
|
||||||
* They share the expansion path, so a repeat works here too; there are no
|
* They share the expansion path, so a repeat works here too; there are no
|
||||||
* per-run inputs during auth, so `timesFrom` falls back to a single pass. */
|
* per-run inputs during recovery, so `timesFrom` falls back to a single pass. */
|
||||||
export function resolveAuthSteps(firm: Firm): ResolvedStep[] {
|
function resolveRecoverySteps(firm: Firm, steps: AutomationStep[]): ResolvedStep[] {
|
||||||
const stub: Automation = { id: '_auth', label: 'auth', description: '', steps: firm.authSteps };
|
const stub: Automation = { id: '_guard', label: 'guard recovery', description: '', steps };
|
||||||
return expand(firm.authSteps, firm, stub, {}).map((step) =>
|
return expand(steps, firm, stub, {}).map((step) =>
|
||||||
step.action === 'wait' ? step : { ...step, signedOut: undefined, signedOutWait: undefined }
|
step.action === 'wait' ? step : { ...step, signedOut: undefined, signedOutWait: undefined }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Guards as handed to the runner, recovery steps expanded. */
|
||||||
|
export function resolveGuards(firm: Firm): ResolvedGuard[] {
|
||||||
|
return firm.guards.map((guard) => ({
|
||||||
|
...guard,
|
||||||
|
recoverySteps: resolveRecoverySteps(firm, guard.recoverySteps ?? []),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pre-0.20 payload shape: the login guard's recovery steps as `authSteps`.
|
||||||
|
* Kept so a stale runner still recovers from a signed-out redirect. */
|
||||||
|
export function resolveAuthSteps(firm: Firm): ResolvedStep[] {
|
||||||
|
const login = legacyLoginGuard(firm);
|
||||||
|
return login ? resolveRecoverySteps(firm, login.recoverySteps ?? []) : [];
|
||||||
|
}
|
||||||
|
|
||||||
/** What a step is doing, for the run log and the dashboard. */
|
/** What a step is doing, for the run log and the dashboard. */
|
||||||
export function describeStep(step: AutomationStep): string {
|
export function describeStep(step: AutomationStep): string {
|
||||||
if (step.label) return step.label;
|
if (step.label) return step.label;
|
||||||
|
|||||||
@@ -609,7 +609,7 @@ export const RUNNER_TIMEOUT_MS = 7000;
|
|||||||
/** The runner version this server's step vocabulary requires. A running process
|
/** The runner version this server's step vocabulary requires. A running process
|
||||||
* doesn't reload when the source changes, so an older one silently fails on
|
* doesn't reload when the source changes, so an older one silently fails on
|
||||||
* steps it predates — the dashboard warns instead. */
|
* steps it predates — the dashboard warns instead. */
|
||||||
export const RUNNER_EXPECTED_VERSION = '0.19.0';
|
export const RUNNER_EXPECTED_VERSION = '0.20.0';
|
||||||
|
|
||||||
export interface RunnerHeartbeat {
|
export interface RunnerHeartbeat {
|
||||||
at: number;
|
at: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user