diff --git a/.github/workflows/deploy-strategy-switch-console.yml b/.github/workflows/deploy-strategy-switch-console.yml index f82fbdb4..cff9c485 100644 --- a/.github/workflows/deploy-strategy-switch-console.yml +++ b/.github/workflows/deploy-strategy-switch-console.yml @@ -20,7 +20,7 @@ on: default: "off" options: ["off", "baseline", "candidate"] external_flow_enable: - description: "Enable the existing external-flow contract while retaining all other settings." + description: "Enable the existing external-flow contract on its pinned reviewed release. For current main set the CONSOLE_EXTERNAL_FLOW_ENABLED Environment variable instead." required: true type: boolean default: false @@ -671,6 +671,7 @@ jobs: UX1_EXPECTED_EVIDENCE_HASHES: ${{ vars.UX1_EXPECTED_EVIDENCE_HASHES }} ACCOUNT_FACTS_READ_MODEL_ENABLED: ${{ vars.ACCOUNT_FACTS_READ_MODEL_ENABLED }} RUNTIME_DAILY_READ_MODEL_ENABLED: ${{ vars.RUNTIME_DAILY_READ_MODEL_ENABLED }} + CONSOLE_EXTERNAL_FLOW_ENABLED: ${{ vars.CONSOLE_EXTERNAL_FLOW_ENABLED }} STRATEGY_SWITCH_SYNC_TOKEN: ${{ secrets.STRATEGY_SWITCH_SYNC_TOKEN || secrets.RUNTIME_SETTINGS_GH_TOKEN }} STRATEGY_HEALTH_SYNC_TOKEN: ${{ secrets.STRATEGY_HEALTH_SYNC_TOKEN }} CONTROL_PLANE_SYNC_TOKEN: ${{ secrets.CONTROL_PLANE_SYNC_TOKEN }} @@ -1398,6 +1399,10 @@ jobs: ): raise SystemExit("EXECUTION_EVIDENCE_SYNC_TOKEN must be non-empty without surrounding whitespace or HTTP control characters when runtime daily is enabled.") runtime_daily_value = "true" if runtime_daily_flag == "true" else "false" + external_flow_flag = os.environ.get("CONSOLE_EXTERNAL_FLOW_ENABLED", "") + if external_flow_flag not in ("", "true", "false"): + raise SystemExit("CONSOLE_EXTERNAL_FLOW_ENABLED must be empty, true, or false.") + external_flow_value = "true" if external_flow_flag == "true" else "false" text = source.read_text(encoding="utf-8") account_id = os.environ.get("CLOUDFLARE_ACCOUNT_ID", "").strip() if account_id and "\naccount_id" not in text: @@ -1431,6 +1436,19 @@ jobs: raise SystemExit("wrangler.toml.example must define RUNTIME_DAILY_READ_MODEL_ENABLED once under [vars].") text = text.replace(runtime_daily_placeholder, f'RUNTIME_DAILY_READ_MODEL_ENABLED = "{runtime_daily_value}"', 1) + external_flow_line = f'CONSOLE_EXTERNAL_FLOW_ENABLED = "{external_flow_value}"' + external_flow_default = 'CONSOLE_EXTERNAL_FLOW_ENABLED = "false"' + if text.count(external_flow_default) == 1: + text = text.replace(external_flow_default, external_flow_line, 1) + elif "CONSOLE_EXTERNAL_FLOW_ENABLED" in text: + raise SystemExit("wrangler.toml.example must declare CONSOLE_EXTERNAL_FLOW_ENABLED with the closed default value.") + else: + # Reviewed historical sources predate the declaration, and a variable the + # config does not declare is erased by the next ordinary deploy, so render + # it into [vars] instead of refusing to publish those pinned sources. + # The account-facts check above already requires a [vars] section. + text = text.replace("[vars]\n", f"[vars]\n{external_flow_line}\n", 1) + mode = os.environ.get("UX1_PREVIEW_MODE", "") if mode and mode != "github_actions": raise SystemExit("UX1_PREVIEW_MODE must be empty or github_actions.") diff --git a/docs/console-performance-charts.md b/docs/console-performance-charts.md index cfa65d8d..35b022b0 100644 --- a/docs/console-performance-charts.md +++ b/docs/console-performance-charts.md @@ -214,6 +214,13 @@ The existing external-flow switch continues to control exact-time ingress and performance calculation; it is not enabled merely to store dates. No new credential or storage service is used. +`CONSOLE_EXTERNAL_FLOW_ENABLED` is an ordinary non-secret Worker variable. Set it +to `true` in the `runtime-strategy-switch` GitHub Environment, then deploy normally: +`release_frontend_assets=false` and no historical `source_sha`. The deploy workflow +renders the value into `wrangler.toml`, so a later release keeps it instead of +dropping an undeclared variable. A frontend-asset release removes `[vars]` from the +generated config and inherits the live bindings, so it cannot activate the switch. + `external_flow.v2` retains v1 exact-time windows and also accepts native date windows: `time_precision: "date"`, inclusive ISO `period_start`/`period_end` of at most 31 days, and `coverage: "reported"`. These records contain `effective_date` diff --git a/python/tests/test_ux1_deploy_workflow.py b/python/tests/test_ux1_deploy_workflow.py index eb57ee48..f058b82b 100644 --- a/python/tests/test_ux1_deploy_workflow.py +++ b/python/tests/test_ux1_deploy_workflow.py @@ -49,7 +49,8 @@ def run_config_generator(self, ux1_env): worker_dir.mkdir() (worker_dir / "wrangler.toml.example").write_text( '[vars]\nEXISTING = "retained"\nACCOUNT_FACTS_READ_MODEL_ENABLED = "false"\n' - 'RUNTIME_DAILY_READ_MODEL_ENABLED = "false"\n\n' + 'RUNTIME_DAILY_READ_MODEL_ENABLED = "false"\nCONSOLE_EXTERNAL_FLOW_ENABLED = "false"\n' + '\n' '# [[kv_namespaces]]\n' '# binding = "STRATEGY_SWITCH_CONFIG"\n' '# id = "replace-with-cloudflare-kv-namespace-id"\n\n', @@ -60,6 +61,7 @@ def run_config_generator(self, ux1_env): "WORKER_DIR": str(worker_dir), "CLOUDFLARE_API_TOKEN": "synthetic-cloudflare-token", "STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID": "a" * 32, + "CONSOLE_EXTERNAL_FLOW_ENABLED": "", **ux1_env, } result = subprocess.run( @@ -94,6 +96,7 @@ def test_enabled_config_is_toml_serialized_without_secret(self): self.assertEqual(variables["EXISTING"], "retained") self.assertEqual(variables["ACCOUNT_FACTS_READ_MODEL_ENABLED"], "false") self.assertEqual(variables["RUNTIME_DAILY_READ_MODEL_ENABLED"], "false") + self.assertEqual(variables["CONSOLE_EXTERNAL_FLOW_ENABLED"], "false") self.assertEqual(variables["UX1_PREVIEW_MODE"], "github_actions") self.assertEqual(variables["UX1_UES_REVISION"], "a" * 40) self.assertEqual(variables["UX1_RUNTIME_EPOCH"], "b" * 32) @@ -114,6 +117,7 @@ def test_empty_mode_preserves_local_binding_config(self): self.assertEqual(config["vars"]["EXISTING"], "retained") self.assertEqual(config["vars"]["ACCOUNT_FACTS_READ_MODEL_ENABLED"], "false") self.assertEqual(config["vars"]["RUNTIME_DAILY_READ_MODEL_ENABLED"], "false") + self.assertEqual(config["vars"]["CONSOLE_EXTERNAL_FLOW_ENABLED"], "false") self.assertFalse(any(key.startswith("UX1_") for key in config["vars"])) def test_invalid_or_incomplete_enabled_config_fails(self): diff --git a/tests/console_catalog_release_validation.mjs b/tests/console_catalog_release_validation.mjs index f9ee4100..85c47ce4 100644 --- a/tests/console_catalog_release_validation.mjs +++ b/tests/console_catalog_release_validation.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -83,7 +83,7 @@ test("the deploy workflow syncs catalogs only after an explicit manual choice", assert.doesNotMatch(workflow, /git diff --quiet "\$BEFORE" HEAD -- platform-config\.json/); }); -test("the deploy renderer pins account-facts and runtime-daily flags and fails closed before deploy", () => { +test("the deploy renderer pins account-facts, runtime-daily and external-flow flags and fails closed before deploy", () => { const workflow = readFileSync(new URL("../.github/workflows/deploy-strategy-switch-console.yml", import.meta.url), "utf8"); const template = new URL("../web/strategy-switch-console/wrangler.toml.example", import.meta.url); const heredoc = workflow.match(/ python3 - <<'PY'\n([\s\S]*?)\n PY\n/); @@ -95,13 +95,15 @@ test("the deploy renderer pins account-facts and runtime-daily flags and fails c assert.match(workflow, /Sync optional IBKR account-facts ingress token/); assert.match(workflow, /secret put IBKR_ACCOUNT_FACTS_SYNC_TOKEN/); assert.match(workflow, /RUNTIME_DAILY_READ_MODEL_ENABLED: \$\{\{ vars\.RUNTIME_DAILY_READ_MODEL_ENABLED \}\}/); + assert.match(workflow, /CONSOLE_EXTERNAL_FLOW_ENABLED: \$\{\{ vars\.CONSOLE_EXTERNAL_FLOW_ENABLED \}\}/); assert.match(workflow, /EXECUTION_EVIDENCE_SYNC_TOKEN: \$\{\{ secrets\.EXECUTION_EVIDENCE_SYNC_TOKEN \}\}/); assert.match(workflow, /node tests\/console_catalog_release_validation\.mjs/); - function render({ flag, token, ibkrToken, runtimeDailyFlag, executionEvidenceToken } = {}) { + function render({ flag, token, ibkrToken, runtimeDailyFlag, executionEvidenceToken, externalFlowFlag, example } = {}) { const directory = mkdtempSync(join(tmpdir(), "qsl-config-render-")); try { - copyFileSync(template, join(directory, "wrangler.toml.example")); + if (example === undefined) copyFileSync(template, join(directory, "wrangler.toml.example")); + else writeFileSync(join(directory, "wrangler.toml.example"), example, "utf8"); const env = { ...process.env, WORKER_DIR: directory, STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID: "synthetic-kv-id", CLOUDFLARE_ACCOUNT_ID: "synthetic-account-id" }; for (const key of ["CLOUDFLARE_API_TOKEN", "CLOUDFLARE_WRANGLER_CONFIG_TOML", "UX1_RESEARCH_JOB_TOKEN"]) delete env[key]; env.UX1_PREVIEW_MODE = ""; @@ -118,6 +120,8 @@ test("the deploy renderer pins account-facts and runtime-daily flags and fails c else env.RUNTIME_DAILY_READ_MODEL_ENABLED = runtimeDailyFlag; if (executionEvidenceToken === undefined) delete env.EXECUTION_EVIDENCE_SYNC_TOKEN; else env.EXECUTION_EVIDENCE_SYNC_TOKEN = executionEvidenceToken; + if (externalFlowFlag === undefined) delete env.CONSOLE_EXTERNAL_FLOW_ENABLED; + else env.CONSOLE_EXTERNAL_FLOW_ENABLED = externalFlowFlag; const result = spawnSync("python3", ["-c", renderer], { encoding: "utf8", env }); const target = join(directory, "wrangler.toml"); const exists = existsSync(target); @@ -217,4 +221,37 @@ test("the deploy renderer pins account-facts and runtime-daily flags and fails c assert.equal(toml, ""); assert.doesNotMatch(result.stdout + result.stderr, /synthetic-token/); } + + // Without a [vars] entry only the pinned release path can set this flag, and the + // next ordinary wrangler deploy erases it. Rendering it from the Environment keeps + // enabling external flows a configuration change instead of a stale-source release. + for (const [externalFlowFlag, expected] of [["true", "true"], ["false", "false"], ["", "false"], [undefined, "false"]]) { + const { result, toml } = render({ externalFlowFlag }); + assert.equal(result.status, 0, result.stderr); + assert.equal((toml.match(/^CONSOLE_EXTERNAL_FLOW_ENABLED\s*=/gm) || []).length, 1); + assert.equal(parsedConfig(toml).vars.CONSOLE_EXTERNAL_FLOW_ENABLED, expected); + } + + for (const externalFlowFlag of ["TRUE", " true", "true ", "enabled"]) { + const { result, toml, targetExists } = render({ externalFlowFlag }); + assert.notEqual(result.status, 0, `accepted invalid external-flow flag ${JSON.stringify(externalFlowFlag)}`); + assert.match(result.stderr, /CONSOLE_EXTERNAL_FLOW_ENABLED/); + assert.equal(targetExists, false, "an invalid external-flow flag must fail before creating a deploy config"); + assert.equal(toml, ""); + } + + // Reviewed pinned sources predate the declaration, so requiring it would stop every + // historical release path from publishing; the value has to be injected instead. + const historicalExample = '[vars]\nACCOUNT_FACTS_READ_MODEL_ENABLED = "false"\nRUNTIME_DAILY_READ_MODEL_ENABLED = "false"\n'; + const injected = render({ externalFlowFlag: "true", example: historicalExample }); + assert.equal(injected.result.status, 0, injected.result.stderr); + assert.equal((injected.toml.match(/^CONSOLE_EXTERNAL_FLOW_ENABLED\s*=/gm) || []).length, 1); + assert.equal(parsedConfig(injected.toml).vars.CONSOLE_EXTERNAL_FLOW_ENABLED, "true"); + + // A template that already states the switch must state it closed, or the checked-in + // default stops being the fail-closed value the deploy contract assumes. + const openDefault = render({ externalFlowFlag: "false", example: historicalExample + 'CONSOLE_EXTERNAL_FLOW_ENABLED = "true"\n' }); + assert.notEqual(openDefault.result.status, 0, "accepted a template declaring the switch open"); + assert.match(openDefault.result.stderr, /CONSOLE_EXTERNAL_FLOW_ENABLED/); + assert.equal(openDefault.targetExists, false); }); diff --git a/tests/console_deploy_safety_validation.mjs b/tests/console_deploy_safety_validation.mjs index 426ad490..c9d741de 100644 --- a/tests/console_deploy_safety_validation.mjs +++ b/tests/console_deploy_safety_validation.mjs @@ -543,11 +543,17 @@ for (const frontendMode of ["false", "true"]) { const text = readFileSync(join(temp, "wrangler.toml"), "utf8"); const parsed = spawnSync("python3", ["-c", "import json,sys,tomllib;print(json.dumps(tomllib.loads(sys.stdin.read())))"], { input: text, encoding: "utf8" }); assert.equal(parsed.status, 0, parsed.stderr); - renderedConfigs.push(JSON.parse(parsed.stdout)); + const rendered = JSON.parse(parsed.stdout); + renderedConfigs.push(rendered); if (frontendMode === "true") { assert.match(text, /\[vars\]\n\n\[triggers\]/); assert.doesNotMatch(text, /ACCOUNT_FACTS_READ_MODEL_ENABLED =/); - } else assert.match(text, /ACCOUNT_FACTS_READ_MODEL_ENABLED = "true"/); + } else { + assert.match(text, /ACCOUNT_FACTS_READ_MODEL_ENABLED = "true"/); + // A normal wrangler deploy uploads only what wrangler.toml declares, so the + // external-flow switch has to be in the rendered config to survive one. + assert.equal(rendered.vars.CONSOLE_EXTERNAL_FLOW_ENABLED, "false", "an ordinary deploy must carry the external-flow switch"); + } assert.ok(!result.stdout.includes("SYNTHETIC_CONFIG_SENTINEL") && !result.stderr.includes("SYNTHETIC_CONFIG_SENTINEL")); configCases++; } finally { rmSync(temp, { recursive: true, force: true }); } diff --git a/web/strategy-switch-console/wrangler.toml.example b/web/strategy-switch-console/wrangler.toml.example index 3380c335..5e954502 100644 --- a/web/strategy-switch-console/wrangler.toml.example +++ b/web/strategy-switch-console/wrangler.toml.example @@ -33,6 +33,9 @@ ACCOUNT_FACTS_READ_MODEL_ENABLED = "false" # The deploy workflow renders this from the runtime-strategy-switch Environment value. # Keep daily runtime read access disabled unless explicitly enabled for deployment. RUNTIME_DAILY_READ_MODEL_ENABLED = "false" +# The deploy workflow renders this from the runtime-strategy-switch Environment value. +# Keep the checked-in default closed; use the Environment value as the deployment source of truth. +CONSOLE_EXTERNAL_FLOW_ENABLED = "false" # Website visibility only; does not disable trading or remove platform support. # Comma-separated platform IDs. Set to an empty string to show all platforms. STRATEGY_SWITCH_HIDDEN_PLATFORMS = "qmt"