1111
1212from __future__ import annotations
1313
14+ import argparse
15+ import hashlib
1416import json
1517from pathlib import Path
1618
1921TARGET = ROOT / "web" / "strategy-switch-console" / "config.js"
2022STRATEGY_TARGET = ROOT / "web" / "strategy-switch-console" / "strategy_profiles_asset.js"
2123STRATEGY_EXAMPLE_TARGET = ROOT / "web" / "strategy-switch-console" / "strategy-profiles.example.json"
24+ RUNTIME_CATALOG_PROJECTION_TARGET = ROOT / "web" / "strategy-switch-console" / "runtime-catalog-projection.json"
25+ RUNTIME_CATALOG_PROJECTION_SCHEMA_VERSION = "qsl.runtime_catalog_projection.v1"
2226
2327
2428def build_config_module (config : dict ) -> str :
@@ -27,6 +31,7 @@ def build_config_module(config: dict) -> str:
2731 domains = config .get ("domains" , {})
2832 meta = config .get ("meta" , {})
2933 runtime_authority = meta .get ("runtime_authority" , {}) if isinstance (meta , dict ) else {}
34+ runtime_catalog_projection = build_runtime_catalog_projection (config )
3035
3136 # ── platformConfig (replaces hardcoded in index.html) ──
3237 platform_config = {}
@@ -172,6 +177,8 @@ def build_config_module(config: dict) -> str:
172177 "" ,
173178 f"export const RUNTIME_AUTHORITY_STATUS = { json .dumps (runtime_authority , indent = 2 , ensure_ascii = False )} ;" ,
174179 "" ,
180+ f"export const RUNTIME_CATALOG_PROJECTION = { json .dumps (runtime_catalog_projection , indent = 2 , ensure_ascii = False )} ;" ,
181+ "" ,
175182 f"export const DEFAULT_ACCOUNT_OPTIONS = { json .dumps (default_accounts , indent = 2 , ensure_ascii = False )} ;" ,
176183 "" ,
177184 f"export const PLATFORM_REPOSITORIES = { json .dumps (repositories , indent = 2 , ensure_ascii = False )} ;" ,
@@ -293,28 +300,118 @@ def _strategy_profile_gate_fields(sdata: dict) -> dict[str, object]:
293300 }
294301
295302
296- def main () -> int :
297- config = json .loads (SOURCE .read_text (encoding = "utf-8" ))
303+ def _config_content_sha256 (config : dict ) -> str :
304+ canonical = json .dumps (config , ensure_ascii = False , sort_keys = True , separators = ("," , ":" ))
305+ return "sha256:" + hashlib .sha256 (canonical .encode ("utf-8" )).hexdigest ()
298306
299- # Generate config.js
300- module = build_config_module (config )
301- TARGET .write_text (module , encoding = "utf-8" )
302307
303- # Generate strategy_profiles_asset.js and keep strategy-profiles.example.json aligned.
308+ def build_runtime_catalog_projection (config : dict ) -> dict [str , object ]:
309+ """Project catalog gates without claiming an observed runtime state.
310+
311+ This is deliberately separate from control-plane and execution-evidence
312+ snapshots. A checked-in strategy catalog can describe which modes a
313+ workflow may consider, but cannot prove that a target is deployed, healthy,
314+ funded, or permitted to trade.
315+ """
304316 profiles = build_strategy_profile_entries (config )
305- STRATEGY_TARGET .write_text (
306- "// Generated by python/scripts/build_platform_config.py from platform-config.json\n "
307- f"export const DEFAULT_STRATEGY_PROFILES = { json .dumps (profiles , indent = 2 , ensure_ascii = False )} ;\n " ,
308- encoding = "utf-8" ,
309- )
310- STRATEGY_EXAMPLE_TARGET .write_text (
311- json .dumps (profiles , ensure_ascii = False , separators = ("," , ":" )) + "\n " ,
312- encoding = "utf-8" ,
317+ status_profiles = [
318+ {
319+ field : profile [field ]
320+ for field in (
321+ "profile" ,
322+ "label" ,
323+ "label_en" ,
324+ "label_zh" ,
325+ "domain" ,
326+ "lifecycle_stage" ,
327+ "runtime_enabled" ,
328+ "can_switch_live" ,
329+ "allowed_execution_modes" ,
330+ "blocked_live_reason" ,
331+ )
332+ }
333+ for profile in profiles
334+ ]
335+ lifecycle_stage_counts : dict [str , int ] = {}
336+ for profile in status_profiles :
337+ stage = str (profile ["lifecycle_stage" ])
338+ lifecycle_stage_counts [stage ] = lifecycle_stage_counts .get (stage , 0 ) + 1
339+
340+ meta = config .get ("meta" , {})
341+ return {
342+ "schema_version" : RUNTIME_CATALOG_PROJECTION_SCHEMA_VERSION ,
343+ "data_status" : "catalog_only" ,
344+ "source" : {
345+ "path" : "platform-config.json" ,
346+ "content_sha256" : _config_content_sha256 (config ),
347+ "catalog_as_of" : meta .get ("last_updated" ) if isinstance (meta , dict ) else None ,
348+ },
349+ "policy" : {
350+ "catalog_is_runtime_observation" : False ,
351+ "catalog_can_authorize_promotion_or_trading" : False ,
352+ "historical_lifecycle_inventory_is_authoritative" : False ,
353+ "observed_state_sources" : [
354+ "qsl_control_plane_dashboard.v1" ,
355+ "qsl_execution_evidence_dashboard.v1" ,
356+ ],
357+ },
358+ "runtime_authority" : meta .get ("runtime_authority" , {}) if isinstance (meta , dict ) else {},
359+ "summary" : {
360+ "strategy_profile_count" : len (status_profiles ),
361+ "runtime_enabled_count" : sum (1 for profile in status_profiles if profile ["runtime_enabled" ]),
362+ "live_switchable_count" : sum (1 for profile in status_profiles if profile ["can_switch_live" ]),
363+ "lifecycle_stage_counts" : dict (sorted (lifecycle_stage_counts .items ())),
364+ },
365+ "strategies" : status_profiles ,
366+ }
367+
368+
369+ def _generated_outputs (config : dict ) -> dict [Path , str ]:
370+ profiles = build_strategy_profile_entries (config )
371+ projection = build_runtime_catalog_projection (config )
372+ return {
373+ TARGET : build_config_module (config ),
374+ STRATEGY_TARGET : (
375+ "// Generated by python/scripts/build_platform_config.py from platform-config.json\n "
376+ f"export const DEFAULT_STRATEGY_PROFILES = { json .dumps (profiles , indent = 2 , ensure_ascii = False )} ;\n "
377+ ),
378+ STRATEGY_EXAMPLE_TARGET : json .dumps (profiles , ensure_ascii = False , separators = ("," , ":" )) + "\n " ,
379+ RUNTIME_CATALOG_PROJECTION_TARGET : json .dumps (projection , ensure_ascii = False , indent = 2 , sort_keys = True ) + "\n " ,
380+ }
381+
382+
383+ def main () -> int :
384+ parser = argparse .ArgumentParser (description = "Generate strategy-switch console assets from platform-config.json" )
385+ parser .add_argument (
386+ "--check" ,
387+ action = "store_true" ,
388+ help = "Fail when the generated runtime catalog projection does not match platform-config.json" ,
313389 )
390+ args = parser .parse_args ()
391+ config = json .loads (SOURCE .read_text (encoding = "utf-8" ))
392+ outputs = _generated_outputs (config )
393+
394+ if args .check :
395+ # sync_strategy_switch_page_asset.py intentionally renders the strategy
396+ # JS asset with a different (but semantically equivalent) formatter.
397+ # The projection has one generator, so it is safe to use as a strict
398+ # source-freshness guard even after the full console build pipeline.
399+ checkable = {RUNTIME_CATALOG_PROJECTION_TARGET : outputs [RUNTIME_CATALOG_PROJECTION_TARGET ]}
400+ stale = [path for path , expected in checkable .items () if not path .exists () or path .read_text (encoding = "utf-8" ) != expected ]
401+ if stale :
402+ for path in stale :
403+ print (f"Generated asset is stale: { path .relative_to (ROOT )} " )
404+ return 1
405+ print ("Generated runtime catalog projection matches platform-config.json" )
406+ return 0
407+
408+ for path , content in outputs .items ():
409+ path .write_text (content , encoding = "utf-8" )
314410
315411 print (f"Generated: { TARGET } " )
316412 print (f"Generated: { STRATEGY_TARGET } " )
317413 print (f"Generated: { STRATEGY_EXAMPLE_TARGET } " )
414+ print (f"Generated: { RUNTIME_CATALOG_PROJECTION_TARGET } " )
318415 return 0
319416
320417
0 commit comments