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:
Brandon Li
2026-09-14 12:50:14 -05:00
co-authored by Claude Fable 5
parent 82b98b9d2d
commit b1dccd7dec
5 changed files with 371 additions and 164 deletions
+122 -44
View File
@@ -110,7 +110,11 @@ export interface Automation {
}
/** 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 & {
urlPattern?: string;
openUrl?: string;
@@ -118,6 +122,59 @@ export type ResolvedStep = AutomationStep & {
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 {
id: string;
label: string;
@@ -127,27 +184,11 @@ export interface Firm {
/** 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. */
url: string;
/** Substring identifying the signed-out page. When a session expires the site
* redirects here, and every subsequent selector resolves against a login form
* — so a run that lands on it must stop rather than click through it. */
signedOutPattern: string;
/** Seconds to pause and let a human sign in when a run hits the login page.
* 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[];
/** Conditions that can interrupt a run at any point, and how to get past
* them. Every firm wants at least the signed-out guard: when a session
* expires the site redirects to its login page, and a run that lands there
* must stop rather than click through the form. See PageGuard. */
guards: PageGuard[];
automations: Automation[];
}
@@ -160,10 +201,17 @@ export const FIRMS: Firm[] = [
label: 'Tradeify',
urlPattern: 'https://app-f.tradeify.co/*',
url: 'https://app-f.tradeify.co/',
signedOutPattern: '/auth/',
signedOutWaitSeconds: 300,
authSteps: [
{ action: 'click', selector: 'form > div > div:last-child button', label: 'Login' },
guards: [
{
id: 'signed-out',
label: 'Signed out',
urlContains: '/auth/',
resume: 'continue',
waitSeconds: 300,
recoverySteps: [
{ action: 'click', selector: 'form > div > div:last-child button', label: 'Login' },
],
},
],
automations: [
{
@@ -248,14 +296,20 @@ export const FIRMS: Firm[] = [
// well as subdomains, confirmed against a live tab on lucidtrading.com.
urlPattern: 'https://*.lucidtrading.com/*',
url: 'https://dash.lucidtrading.com/',
// 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
// matches dash.lucidtrading.com and every step would abort while logged in.
signedOutPattern: '//lucidtrading.com/',
signedOutWaitSeconds: 300,
authSteps: [
{ action: 'click', selector: 'button.lucid-login-btn', label: 'Login' },
guards: [
{
id: 'signed-out',
label: 'Signed out',
// 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
// matches dash.lucidtrading.com and every step would abort while logged in.
urlContains: '//lucidtrading.com/',
resume: 'continue',
waitSeconds: 300,
recoverySteps: [
{ action: 'click', selector: 'button.lucid-login-btn', label: 'Login' },
],
},
],
automations: [
{
@@ -371,8 +425,17 @@ function labelled(step: ResolvedStep, iteration: number, total: number): Resolve
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. */
function resolveOne(firm: Firm, step: AutomationStep): ResolvedStep {
const login = legacyLoginGuard(firm);
if (step.action === 'wait') return step;
if (step.action === 'navigate') {
return {
@@ -380,8 +443,8 @@ function resolveOne(firm: Firm, step: AutomationStep): ResolvedStep {
url: step.url ?? firm.url,
urlPattern: firm.urlPattern,
openUrl: firm.url,
signedOut: firm.signedOutPattern,
signedOutWait: firm.signedOutWaitSeconds,
signedOut: login?.urlContains,
signedOutWait: login?.waitSeconds,
};
}
if (step.action === 'waitFor') {
@@ -404,8 +467,8 @@ function resolveOne(firm: Firm, step: AutomationStep): ResolvedStep {
...step,
urlPattern: step.urlPattern ?? firm.urlPattern,
openUrl: firm.url,
signedOut: firm.signedOutPattern,
signedOutWait: firm.signedOutWaitSeconds,
signedOut: login?.urlContains,
signedOutWait: login?.waitSeconds,
};
}
@@ -451,19 +514,34 @@ export function resolveSteps(firm: Firm, automation: Automation, inputs: RunInpu
return expand(automation.steps, firm, automation, inputs);
}
/** Auth steps run on the login page itself, so they get the firm's tab pattern
* but deliberately no `signedOut` guard — that guard exists to stop ordinary
/** Recovery steps run on the guard page itself, so they get the firm's tab
* pattern but deliberately no `signedOut` stamp — that exists to stop ordinary
* 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
* per-run inputs during auth, so `timesFrom` falls back to a single pass. */
export function resolveAuthSteps(firm: Firm): ResolvedStep[] {
const stub: Automation = { id: '_auth', label: 'auth', description: '', steps: firm.authSteps };
return expand(firm.authSteps, firm, stub, {}).map((step) =>
* per-run inputs during recovery, so `timesFrom` falls back to a single pass. */
function resolveRecoverySteps(firm: Firm, steps: AutomationStep[]): ResolvedStep[] {
const stub: Automation = { id: '_guard', label: 'guard recovery', description: '', steps };
return expand(steps, firm, stub, {}).map((step) =>
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. */
export function describeStep(step: AutomationStep): string {
if (step.label) return step.label;