feat(vinpin): VINPIN_RPARTSTORE_ENABLED flag skips Rpartstore during known outage
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Rpartstore is DOWN upstream. Every Renault decode still tried to OPEN it first → launch-error, stuck "Loading application..." app (dirty-resume for the next decode), ~30-50s burned, and the in-memory down-cooldown resets on every worker restart so the first decode after each restart repaid the full cost. That overhead pushed decodes over VINPIN_DECODE_BUDGET_MS → budget abort → sessionPoisoned → cascade. Add a persistent kill-switch: VINPIN_RPARTSTORE_ENABLED=false makes BOTH Renault paths (warm warmRenaultDecode + cold runRenaultFlow) skip opening Rpartstore entirely and route straight to Dialogys — mirroring the existing rpartstoreInCooldown() skip but surviving worker restarts. Flag-disabled is treated as "Rpartstore unavailable" exactly like cooldown, so primaryRan stays false and a clean Dialogys not_found is definitive (no retry thrash). Also gated the _warmUp Rpartstore-open so a future warm session with the flag off pays no launch cost / leaves no stray app. DEFAULT true (only the exact string "false" disables) → behaviour with the flag unset is completely unchanged. tsc clean; vinpin unit tests green (+3 flag tests: warm/cold skip + Dialogys-definitive, and default-true still attempts Rpartstore). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -267,7 +267,7 @@ describe("VinpinDriverService — Rpartstore-down cooldown routes Renault → Di
|
||||
|
||||
const result = await (
|
||||
any.warmRenaultDecode as (p: unknown, v: string, c: unknown, d: number) => Promise<unknown>
|
||||
).call(driver, page, "VF1LM1B0A37829019", {}, FAR_DEADLINE());
|
||||
).call(driver, page, "VF1LM1B0A37829019", { rpartstoreEnabled: true }, FAR_DEADLINE());
|
||||
|
||||
expect(acquireSpy).not.toHaveBeenCalled(); // Rpartstore never opened during the outage
|
||||
expect(runRpartstore).not.toHaveBeenCalled();
|
||||
@@ -502,7 +502,7 @@ describe("VinpinDriverService — runRenaultFlow definitive not_found when Rpart
|
||||
c: unknown,
|
||||
d: number,
|
||||
) => Promise<{ status: string }>
|
||||
).call(driver, page, VIN, {}, FAR_DEADLINE());
|
||||
).call(driver, page, VIN, { rpartstoreEnabled: true }, FAR_DEADLINE());
|
||||
|
||||
it("cooldown (Rpartstore down) + Dialogys not_found → DEFINITIVE not_found, no Rpartstore, no retry", async () => {
|
||||
const driver = new VinpinDriverService();
|
||||
@@ -574,3 +574,107 @@ describe("VinpinDriverService — runRenaultFlow definitive not_found when Rpart
|
||||
expect(outcome.status).toBe("not_found");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* VINPIN_RPARTSTORE_ENABLED=false kill-switch (KNOWN Rpartstore outage). Unlike the
|
||||
* in-memory down-cooldown (which resets on every worker restart, so the first decode
|
||||
* after each restart pays the full Rpartstore launch cost + leaves a stuck app), this
|
||||
* config flag persists: when set, BOTH Renault paths (warm + cold) skip opening
|
||||
* Rpartstore entirely and go straight to Dialogys, and a clean Dialogys not_found is
|
||||
* DEFINITIVE (a flag-disabled Rpartstore is "unavailable" exactly like the cooldown).
|
||||
* Default (unset / not "false") is TRUE — behaviour completely unchanged.
|
||||
*/
|
||||
describe("VinpinDriverService — VINPIN_RPARTSTORE_ENABLED=false skips Rpartstore in both Renault paths", () => {
|
||||
const savedEnv = { ...process.env };
|
||||
const FAR_DEADLINE = () => Date.now() + 5 * 60_000;
|
||||
beforeEach(() => {
|
||||
process.env.VINPIN_ENABLED = "true";
|
||||
mockOcr.mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
process.env = { ...savedEnv };
|
||||
});
|
||||
|
||||
it("COLD runRenaultFlow (flag=false): never opens Rpartstore, goes straight to Dialogys, and a Dialogys not_found is DEFINITIVE (no retry)", async () => {
|
||||
const driver = new VinpinDriverService();
|
||||
const any = driver as unknown as AnyDriver;
|
||||
// NOT in cooldown — the ONLY reason to skip is the disabled flag.
|
||||
const acquire = vi.spyOn(any as never, "acquireLoadedRpartstore");
|
||||
const ensureR = vi.spyOn(any as never, "ensureRpartstore");
|
||||
const runRpartstore = vi.spyOn(any as never, "runRpartstore");
|
||||
const runDialogys = vi
|
||||
.spyOn(any as never, "runDialogys")
|
||||
.mockResolvedValue({ status: "not_found" } as never);
|
||||
|
||||
const outcome = await (
|
||||
(any as AnyDriver).runRenaultFlow as (
|
||||
p: unknown,
|
||||
v: string,
|
||||
c: unknown,
|
||||
d: number,
|
||||
) => Promise<{ status: string }>
|
||||
).call(driver, fakePage(), "VF1553K05TR596166", { rpartstoreEnabled: false }, FAR_DEADLINE());
|
||||
|
||||
expect(acquire).not.toHaveBeenCalled(); // Rpartstore never opened
|
||||
expect(ensureR).not.toHaveBeenCalled();
|
||||
expect(runRpartstore).not.toHaveBeenCalled();
|
||||
expect(runDialogys).toHaveBeenCalledTimes(1); // single shot — no budget-burn retry
|
||||
expect(outcome.status).toBe("not_found"); // definitive — no ambiguous downgrade
|
||||
});
|
||||
|
||||
it("WARM warmRenaultDecode (flag=false): never opens Rpartstore, decodes via Dialogys", async () => {
|
||||
const driver = new VinpinDriverService();
|
||||
const any = driver as unknown as AnyDriver;
|
||||
const acquire = vi.spyOn(any as never, "acquireLoadedRpartstore");
|
||||
const ensureR = vi.spyOn(any as never, "ensureRpartstore");
|
||||
const runRpartstore = vi.spyOn(any as never, "runRpartstore");
|
||||
vi.spyOn(any as never, "ensureWarmWindow").mockResolvedValue(true as never);
|
||||
vi.spyOn(any as never, "runDialogysSearch").mockResolvedValue({
|
||||
status: "found",
|
||||
parsed: { brand: "RENAULT", model: "MEGANE", modelYear: null },
|
||||
rawText: "Megane",
|
||||
via: "dialogys",
|
||||
} as never);
|
||||
const page = { keyboard: { press: vi.fn(async () => undefined) } };
|
||||
|
||||
const result = await (
|
||||
any.warmRenaultDecode as (p: unknown, v: string, c: unknown, d: number) => Promise<unknown>
|
||||
).call(driver, page, "VF1LM1B0A37829019", { rpartstoreEnabled: false }, FAR_DEADLINE());
|
||||
|
||||
expect(acquire).not.toHaveBeenCalled(); // Rpartstore never opened
|
||||
expect(ensureR).not.toHaveBeenCalled();
|
||||
expect(runRpartstore).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
brand: "RENAULT",
|
||||
model: "MEGANE",
|
||||
raw: { source: "vinpin-dialogys" },
|
||||
});
|
||||
});
|
||||
|
||||
it("DEFAULT (flag=true): COLD path still ATTEMPTS Rpartstore (behaviour unchanged)", async () => {
|
||||
const driver = new VinpinDriverService();
|
||||
const any = driver as unknown as AnyDriver;
|
||||
const acquire = vi
|
||||
.spyOn(any as never, "acquireLoadedRpartstore")
|
||||
.mockResolvedValue(true as never);
|
||||
vi.spyOn(any as never, "runRpartstore").mockResolvedValue({
|
||||
status: "found",
|
||||
parsed: { brand: "RENAULT", model: "CLIO", modelYear: null },
|
||||
rawText: "Clio",
|
||||
via: "rpartstore",
|
||||
} as never);
|
||||
|
||||
const outcome = await (
|
||||
(any as AnyDriver).runRenaultFlow as (
|
||||
p: unknown,
|
||||
v: string,
|
||||
c: unknown,
|
||||
d: number,
|
||||
) => Promise<{ status: string }>
|
||||
).call(driver, fakePage(), "VF1553K05TR596166", { rpartstoreEnabled: true }, FAR_DEADLINE());
|
||||
|
||||
expect(acquire).toHaveBeenCalledTimes(1); // Rpartstore still primary by default
|
||||
expect(outcome.status).toBe("found");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -444,7 +444,7 @@ describe("VinpinDriverService — Rpartstore spinner guard", () => {
|
||||
|
||||
const result = await (
|
||||
any.warmRenaultDecode as (p: unknown, v: string, c: unknown, d: number) => Promise<unknown>
|
||||
).call(driver, page, "VF1RFE00653633190", {}, FAR_DEADLINE());
|
||||
).call(driver, page, "VF1RFE00653633190", { rpartstoreEnabled: true }, FAR_DEADLINE());
|
||||
|
||||
expect(result).toMatchObject({
|
||||
brand: "RENAULT",
|
||||
@@ -470,7 +470,7 @@ describe("VinpinDriverService — Rpartstore spinner guard", () => {
|
||||
|
||||
const result = await (
|
||||
any.warmRenaultDecode as (p: unknown, v: string, c: unknown, d: number) => Promise<unknown>
|
||||
).call(driver, page, "VF1LM1B0A37829019", {}, FAR_DEADLINE());
|
||||
).call(driver, page, "VF1LM1B0A37829019", { rpartstoreEnabled: true }, FAR_DEADLINE());
|
||||
|
||||
expect(runRpartstore).not.toHaveBeenCalled(); // never ran the search on a stuck Rpartstore
|
||||
expect(result).toMatchObject({
|
||||
|
||||
@@ -98,6 +98,11 @@ interface VinpinConfig {
|
||||
/** Whether the warm-session daemon path is allowed (kill-switch: set
|
||||
* VINPIN_WARM_DAEMON=false to force the legacy per-decode cold path in prod). */
|
||||
warmDaemon: boolean;
|
||||
/** Whether opening Rpartstore is allowed at all (kill-switch: set
|
||||
* VINPIN_RPARTSTORE_ENABLED=false during a KNOWN Rpartstore outage to skip
|
||||
* opening it entirely and route Renault decodes straight to Dialogys — mirrors
|
||||
* the down-cooldown skip, but persists across worker restarts). DEFAULT true. */
|
||||
rpartstoreEnabled: boolean;
|
||||
}
|
||||
|
||||
function readConfig(): VinpinConfig {
|
||||
@@ -110,6 +115,7 @@ function readConfig(): VinpinConfig {
|
||||
headful: process.env.VINPIN_HEADFUL === "true",
|
||||
budgetMs: Number(process.env.VINPIN_DECODE_BUDGET_MS) || VINPIN_DECODE_BUDGET_MS,
|
||||
warmDaemon: process.env.VINPIN_WARM_DAEMON !== "false",
|
||||
rpartstoreEnabled: process.env.VINPIN_RPARTSTORE_ENABLED !== "false",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -502,9 +508,12 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
await this.establishSession(cfg);
|
||||
const fiatUp = this.establishedFlow === "fiat";
|
||||
// Window 2: Renault Rpartstore — background ePER by raising the grid, then
|
||||
// open (ensureRpartstore opens from the grid and does NOT close ePER).
|
||||
// open (ensureRpartstore opens from the grid and does NOT close ePER). Skipped
|
||||
// entirely when VINPIN_RPARTSTORE_ENABLED=false (known outage): don't pay the
|
||||
// launch cost / leave a stray "Loading application..." app during warm-up.
|
||||
let okR = false;
|
||||
if (await this.raiseGridViaTaskbar(page)) okR = await this.ensureRpartstore(page);
|
||||
if (cfg.rpartstoreEnabled && (await this.raiseGridViaTaskbar(page)))
|
||||
okR = await this.ensureRpartstore(page);
|
||||
// If Rpartstore did NOT come up (e.g. it's DOWN and left its hard launch-error
|
||||
// modal on screen), clear any leftover blocking modal BEFORE opening Dialogys.
|
||||
// Otherwise the launch-error dialog dirties the desktop and can drive the
|
||||
@@ -710,8 +719,12 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
// opening it entirely — it's a persistent server-side outage and reopening only
|
||||
// re-dirties the desktop — and go straight to the proven ~30s Dialogys path.
|
||||
let primary: RenaultOutcome = { status: "ambiguous" };
|
||||
if (this.rpartstoreInCooldown()) {
|
||||
this.logger.log(`warm ${vin}: Rpartstore in down-cooldown — routing straight to Dialogys`);
|
||||
if (this.rpartstoreInCooldown() || !cfg.rpartstoreEnabled) {
|
||||
this.logger.log(
|
||||
cfg.rpartstoreEnabled
|
||||
? `warm ${vin}: Rpartstore in down-cooldown — routing straight to Dialogys`
|
||||
: `warm ${vin}: Rpartstore disabled (VINPIN_RPARTSTORE_ENABLED=false) — routing straight to Dialogys`,
|
||||
);
|
||||
} else if (await this.acquireLoadedRpartstore(page, cfg, deadline, true)) {
|
||||
primary = await this.runRpartstore(page, vin);
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
@@ -1785,8 +1798,15 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
// cooldown AND loaded — otherwise `primary` stays a placeholder "ambiguous" that
|
||||
// must NOT be read as "Rpartstore was unsure" (see the definitive gate below).
|
||||
let primaryRan = false;
|
||||
if (this.rpartstoreInCooldown()) {
|
||||
this.logger.log(`${vin}: Rpartstore in down-cooldown — routing straight to Dialogys`);
|
||||
if (this.rpartstoreInCooldown() || !cfg.rpartstoreEnabled) {
|
||||
// primaryRan stays false → the definitive-not_found gate below treats a clean
|
||||
// Dialogys not_found as definitive (a flag-disabled Rpartstore is "unavailable"
|
||||
// exactly like the down-cooldown).
|
||||
this.logger.log(
|
||||
cfg.rpartstoreEnabled
|
||||
? `${vin}: Rpartstore in down-cooldown — routing straight to Dialogys`
|
||||
: `${vin}: Rpartstore disabled (VINPIN_RPARTSTORE_ENABLED=false) — routing straight to Dialogys`,
|
||||
);
|
||||
} else if (await this.acquireLoadedRpartstore(page, cfg, deadline, false)) {
|
||||
primary = await this.runRpartstore(page, vin);
|
||||
primaryRan = true;
|
||||
@@ -1810,7 +1830,8 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
// downgraded to ambiguous and thrashes the retry loop into the 180s budget +
|
||||
// seat-poison. A genuinely-ambiguous Dialogys (couldn't be reached → ambiguous)
|
||||
// still falls through to the retry, which is the correct transient-failure path.
|
||||
const rpartstoreUnavailable = this.rpartstoreInCooldown() || !primaryRan;
|
||||
const rpartstoreUnavailable =
|
||||
this.rpartstoreInCooldown() || !cfg.rpartstoreEnabled || !primaryRan;
|
||||
if (
|
||||
fallback.status === "not_found" &&
|
||||
(primary.status === "not_found" || rpartstoreUnavailable)
|
||||
|
||||
Reference in New Issue
Block a user