Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions python/scripts/runtime_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -1361,12 +1361,20 @@ def read_stop_variables(github: dict[str, Any]) -> dict[str, str]:
else:
endpoint += "/actions"
endpoint += "/variables?per_page=100"
reason = "stop_source_unavailable"
try:
result = subprocess.run(
["gh", "api", "--method", "GET", "--paginate", "--slurp", endpoint],
capture_output=True, text=True, timeout=60, check=False,
)
if result.returncode != 0:
# Classify the response without ever printing private GH output.
if "unknown flag: --slurp" in result.stderr:
reason = "stop_source_cli_unsupported"
elif "HTTP 401" in result.stderr or "HTTP 403" in result.stderr:
reason = "stop_source_permission_denied"
elif "HTTP 404" in result.stderr:
reason = "stop_source_not_accessible"
raise ValueError
pages = json.loads(result.stdout)
if not isinstance(pages, list) or not pages:
Expand All @@ -1384,10 +1392,13 @@ def read_stop_variables(github: dict[str, Any]) -> dict[str, str]:
raise ValueError
values[name] = value
if len(values) != total:
reason = "stop_source_incomplete"
raise ValueError
return values
except subprocess.TimeoutExpired:
raise ValueError("stop_source_timeout") from None
except (OSError, subprocess.SubprocessError, ValueError, TypeError, KeyError, IndexError):
raise ValueError("stop_source_unavailable") from None
raise ValueError(reason) from None


def read_disabled_profile_apply_variables(github: dict[str, Any]) -> dict[str, dict[str, str]]:
Expand Down Expand Up @@ -1733,15 +1744,18 @@ def command_stop(args: argparse.Namespace) -> int:
if args.yes and args.confirm != "STOP_ONLY":
print("stop requires --confirm STOP_ONLY for writes", file=sys.stderr)
return 2
stage = "event"
try:
request = load_stop_request()
stage = "configuration"
apply_hk_stop = getattr(args, "apply_hk_stop", False)
if apply_hk_stop:
if not args.yes:
raise ValueError("stop_platform_apply_requires_saved_stop")
require_hk_stop_target(request)
require_production_writer_ref()
saved = execute_stop(request, apply=True)
stage = "platform_dispatch"
dispatch_hk_stop(request)
result = {
**saved,
Expand All @@ -1751,9 +1765,22 @@ def command_stop(args: argparse.Namespace) -> int:
}
else:
result = execute_stop(request, apply=args.yes)
except (OSError, ValueError, TypeError, KeyError):
except (OSError, ValueError, TypeError, KeyError) as error:
# Underlying errors and private target/config values stay out of logs.
print("stop_not_verified; do not retry or infer platform state", file=sys.stderr)
safe_reasons = {
"stop_event_unverified", "stop_source_unavailable", "stop_source_cli_unsupported",
"stop_source_permission_denied", "stop_source_not_accessible", "stop_source_incomplete",
"stop_source_timeout", "stop_source_changed", "stop_write_outcome_unverified",
"stop_readback_unverified", "stale_writer_ref_rejected", "stop_platform_target_unsupported",
"stop_saved_configuration_required", "stop_platform_dispatch_unverified",
"stop_platform_apply_requires_saved_stop",
}
reason = str(error)
if reason == "stop requires an unambiguous current target with matching identity":
reason = "stop_identity_mismatch"
elif reason not in safe_reasons:
reason = "stop_event_unverified" if stage == "event" else "stop_configuration_unverified"
print(f"stop_not_verified; stage={stage}; reason={reason}; do not retry or infer platform state", file=sys.stderr)
return 2
print(json.dumps(result, sort_keys=True))
return 0
Expand Down
40 changes: 39 additions & 1 deletion python/tests/test_runtime_stop.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,9 +259,47 @@ def test_variable_read_checks_complete_pagination_without_output(self):
for output in bad:
with self.subTest(output=output), patch.object(runtime_settings.subprocess, "run", return_value=
subprocess.CompletedProcess([], 0, output, "synthetic-sensitive-error")), \
self.assertRaisesRegex(ValueError, "^stop_source_unavailable$"):
self.assertRaisesRegex(ValueError, "^stop_source_(unavailable|incomplete)$"):
runtime_settings.read_stop_variables(self.request["github"])

def test_variable_reader_reports_only_fixed_failure_categories(self):
cases = [
("unknown flag: --slurp", "stop_source_cli_unsupported"),
("private endpoint (HTTP 403)", "stop_source_permission_denied"),
("private endpoint (HTTP 401)", "stop_source_permission_denied"),
("private endpoint (HTTP 404)", "stop_source_not_accessible"),
("private transport details", "stop_source_unavailable"),
]
for stderr, reason in cases:
with self.subTest(reason=reason), patch.object(runtime_settings.subprocess, "run", return_value=
subprocess.CompletedProcess([], 1, "private body", stderr)), \
self.assertRaisesRegex(ValueError, f"^{reason}$"):
runtime_settings.read_stop_variables(self.request["github"])
with patch.object(runtime_settings.subprocess, "run", side_effect=subprocess.TimeoutExpired("private", 60)), \
self.assertRaisesRegex(ValueError, "^stop_source_timeout$"):
runtime_settings.read_stop_variables(self.request["github"])

def test_cli_failure_is_diagnostic_without_exposing_details_or_retrying(self):
for error, reason in [
(ValueError("stop_source_permission_denied"), "stop_source_permission_denied"),
(ValueError("stop_write_outcome_unverified"), "stop_write_outcome_unverified"),
(ValueError("private target and token"), "stop_configuration_unverified"),
(ValueError("stop_source_timeout private token"), "stop_configuration_unverified"),
]:
with self.subTest(reason=reason), patch.object(runtime_settings, "load_stop_request", return_value=self.request), \
patch.object(runtime_settings, "execute_stop", side_effect=error) as execute, \
contextlib.redirect_stderr(io.StringIO()) as stderr:
self.assertEqual(runtime_settings.main(["stop", "--yes", "--confirm", "STOP_ONLY"]), 2)
execute.assert_called_once()
self.assertEqual(stderr.getvalue(),
f"stop_not_verified; stage=configuration; reason={reason}; do not retry or infer platform state\n")
with patch.object(runtime_settings, "load_stop_request", side_effect=KeyError("private event")), \
patch.object(runtime_settings, "execute_stop") as execute, contextlib.redirect_stderr(io.StringIO()) as stderr:
self.assertEqual(runtime_settings.main(["stop"]), 2)
execute.assert_not_called()
self.assertIn("stage=event; reason=stop_event_unverified", stderr.getvalue())
self.assertNotIn("private", stderr.getvalue())

def test_variable_reader_uses_actions_for_repository_and_encodes_environment(self):
repository = "QuantStrategyLab/BinancePlatform"
for scope, expected in [
Expand Down
6 changes: 6 additions & 0 deletions tests/console_runtime_state_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ function sources(overrides = {}) {
};
}

test("pending option layer has one editable label across draft states", () => {
assert.equal((accountsSource.match(/t\("待应用期权层"\)/g) || []).length, 1);
assert.match(accountsSource, /<label className="cash-floor-field">\{t\("待应用期权层"\)\}\s*<select value=\{optionValue\}/);
assert.match(accountsSource, /controller\.revertOption\(\)/);
});

test("runtime mode normalization keeps paper identity while dispatch stays fail closed", () => {
const base = { platform: "ibkr", target_name: "example", strategy_profile: "tqqq_growth_income", apply: "false" };
assert.equal(__test.normalizeSwitchInputs({ ...base, execution_mode: "paper" }).execution_mode, "dry_run");
Expand Down
1 change: 0 additions & 1 deletion web/strategy-switch-console/frontend/src/AccountsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,6 @@ function DailyAccountSettings({ row, refreshToken, stopAllowed, stopLabel, stopR
</div>
{view.review.draft && incomeDirty && <p className="section-note" role="status">{t("草案版本或账户来源已变化,请取消或核对后再保存。")}</p>}
{incomeNotice && <p role="status">{t(view.notice)}</p>}
{(optionDirty || savedOption) && <div className="setting-facts"><p><span>{t("待应用期权层")}</span><strong>{optionValue === "" ? t("沿用当前") : optionValue === "true" ? t("开") : t("关")}</strong></p></div>}
<label className="cash-floor-field">{t("待应用期权层")}
<select value={optionValue} disabled={!canSaveCash} onChange={event => {
const next = event.target.value;
Expand Down
4 changes: 2 additions & 2 deletions web/strategy-switch-console/v2_asset_map.js

Large diffs are not rendered by default.

Loading