diff --git a/SESSION.md b/SESSION.md index 8536030..5c5f999 100644 --- a/SESSION.md +++ b/SESSION.md @@ -797,11 +797,12 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. - Added `docs/AI_ICT_实盘辅助分析决策助手上线必做清单_2026-05-18_v1.md` for the urgent launch target: read-only live analysis assistant, no order submission, no trading, no direct buy/sell instruction. The must-do launch queue is P0-1 read-only market data recovery, P0-2 `live_analysis_assistant_ready` narrow readiness, P0-3 forced boundary and de-traded UI, P0-4 one-screen cockpit health, P0-6 read-only launch smoke, and P0-5 minimal observation/note/weekly-review operations loop. - Trader Mode P1/P2 live gate recovered on 2026-05-18: used `yuqei` SSH + sudo with the user-provided temporary password, copied the complete existing root deploy `.env` into `/home/yuqei/project/ai-exchange/.env` without exposing secret values, redeployed via `scripts/deploy_with_password.py --host 192.168.3.90 --user yuqei --port 22 --remote-path /home/yuqei/project/ai-exchange --remote-archive /home/yuqei/ai-ict-deploy.tgz`, deploy smoke passed 11 endpoints, standard `run_remote_ops_smoke.py` passed all checks including `unattended_harness`, and `run_readonly_live_analysis_smoke.py --base-url http://192.168.3.90:8000 --attempts 2 --timeout-seconds 30 --output-json` passed with `ready_for_readonly_live_analysis=true`, `must_not_trade=true`, `trade_permission_required=false`, and `submit_cancel_or_live_execution_called=false`. Remote readiness now reports `market_stream_connected`, `market_data_gap_clear`, `readonly_account_context_status=available`, `product_operating_mode=restricted_decision_support`, and `real_live_ready=false`. - Low-risk sensitive credential cleanup passed on 2026-05-18 without rotating or changing any live credentials: removed tracked legacy expect scripts that embedded password sends, replaced rollback docs/checker with `deploy_with_password.py` plus runtime env injection, added redacted `scripts/run_secret_static_checks.py`, wired it into `run_script_static_checks.py` and the Trading Delivery Checklist, ignored `tmp_*.jsonl` / `*_capture.jsonl`, and documented the boundary in `docs/AI_ICT_敏感凭据低风险清理记录_2026-05-18_v1.md`. Validation passed secret static checks, script static checks, 35 focused tests, `git diff --check` with only LF/CRLF warnings, and full unit discovery (`779` tests). Current tree secret scan passed; Git history was not rewritten, so any historical exposure should be handled by a future explicit rotation window after backup login and smoke paths are confirmed. +- Workbench UX + read-only live usefulness recovery passed on 2026-05-20 after real intraday testing showed the root workbench was too long, hard to use, and runtime-dependent panels could appear ineffective. `/` now uses a left-menu/right-workspace layout with hash/localStorage section switching, default `Decision Assistant`, grouped `Readiness` and `Alerts` pages, and `/trader-mode` remains the fast-read entry. Frontend `fetchJson` now has request timeout fallback so slow review/readiness endpoints no longer keep the entire workbench stuck on Loading. Remote blockers were also recovered: PostgreSQL `pg_hba` accepted the Docker network, compose now maps `host.docker.internal`, non-root deploy env backup stays inside the remote app path, DB object ownership was reassigned to the migration/app user, the local proxy was relaunched, remote runtime env was restored with `TRADING_RUNTIME_ENABLED=1` / market and broker proxy URLs / `LIVE_TRADING_ENABLED=0`, and read-only launch readiness now treats fresh REST market data plus gap-clear state as usable while surfacing stream/news/account issues as warnings. Validation passed focused workbench/readiness/maintenance/deploy tests, secret/static script checks, Playwright screenshots at 1366x768 and 390x844 plus an Alerts hash view, full unit discovery, deploy smoke over 11 endpoints, standard remote ops smoke including `unattended_harness`, and remote `run_readonly_live_analysis_smoke.py` with `ready_for_readonly_live_analysis=true`, `must_not_trade=true`, `trade_permission_required=false`, and `submit_cancel_or_live_execution_called=false`. Current warning state: market stream may still be connecting while REST candles are fresh, news provider may be stale, and OKX account context may be unavailable; these warnings do not change `execution_allowed`, `decision_support_ready`, `real_live_ready`, risk governor, trust bucket, or operator confirmation protocol. - Current assisted-analysis next gate: keep R1/R2 focused on `decision_support_ready` trust/sample/quality evidence and shadow/paper/operator feedback collection. OKX trade-permission sandbox smoke is deferred until the project explicitly re-enters execution adapter or real-trading preparation. ## Exact Next Step -Use `docs/AI_ICT_实盘辅助分析决策助手上线必做清单_2026-05-18_v1.md` as the urgent launch anchor. The product goal remains read-only live analysis assistant: no orders, no trading, no direct trade instruction. P0, P1, and the currently feasible P2 enhancements are implemented, deployed, and remotely smoke-validated. The next real gate is R2 evidence collection: wait for or surface real executable candidates, then collect paper ticket, paper execution, operator feedback, and weekly review samples without fabricating evidence. Keep `execution_allowed`, `real_live_ready`, risk governor, trust bucket, and operator confirmation protocol unchanged. +Next improve actual trader usefulness now that the frontend and remote runtime are usable: compress Trader Mode and watchlist copy around `why no trade`, `what to watch next`, unique focus quality, and stale/no-candidate states, then continue R2 paper-ticket / paper-execution / operator-feedback / weekly-review evidence collection without fabricating samples. Keep `execution_allowed`, `real_live_ready`, risk governor, trust bucket, and operator confirmation protocol unchanged. ## Validation Commands Used Recently diff --git a/TASKS.md b/TASKS.md index fd2ee0d..05e3cc7 100644 --- a/TASKS.md +++ b/TASKS.md @@ -160,6 +160,9 @@ - [x] Trader Mode P2 local enhancement closure: chart context bridge starter, BTC/ETH/SOL fast queue, after-close review draft, and ops duty panel implemented and locally validated without connecting real TradingView MCP or changing execution/readiness gates - [x] Trader Mode P1/P2 remote deploy/smoke closure: used `yuqei` SSH + sudo to migrate the complete read-only `.env` into `/home/yuqei/project/ai-exchange/.env`, redeployed current worktree, and passed deploy smoke, standard remote ops smoke, `unattended_harness`, and readonly live analysis smoke - [x] Low-risk sensitive credential cleanup complete: removed tracked expect scripts with embedded password sends, switched rollback docs/checker to `deploy_with_password.py` + runtime env, added redacted `run_secret_static_checks.py`, wired it into script static checks and Trading Delivery Checklist, and kept all real remote/runtime credentials unchanged +- [x] Workbench frontend split-navigation closure: converted the long root workbench into a left-menu/right-workspace layout, added hash/localStorage section switching, added frontend request timeout fallback so slow review/readiness APIs no longer freeze the page, and validated desktop/narrow rendering +- [x] Live usefulness blocker recovery: root workbench split-navigation is usable, slow frontend requests time out instead of freezing the page, remote DB/compose/deploy/migration/proxy/runtime blockers were recovered, standard remote ops smoke including `unattended_harness` passed, and readonly live analysis smoke passed without enabling order submission +- [ ] Next trader-usefulness refinement: compress Trader Mode / watchlist language around why no trade, what to watch next, unique focus quality, stale data, and no-candidate states so the first screen answers live operator questions faster - [ ] R2 next collection action after readonly launch: wait for or surface a real executable candidate, then build shadow paper ticket / confirm paper execution / record operator feedback / add weekly review evidence without changing execution/live gates - [x] R3 OKX sandbox preflight starter implemented and deployed: signed dry-run request previews, idempotency key preview, error normalization catalog, `/trading/okx-sandbox-preflight`, live-readiness summary, cockpit surface, deploy smoke, remote ops smoke, and remote field/UI smoke - [x] R3 OKX sandbox isolated smoke tooling implemented and deployed: explicit `OKX_SANDBOX_SMOKE_EXECUTE` gate, submit/query/cancel/query/fills script, JSON artifact, proxy reuse, permission-error normalization, focused tests, full unit, deploy smoke, and remote ops smoke @@ -906,7 +909,7 @@ - [x] R2 historical shadow observation backfill audit: prior 14-day candidate history scanned, 10 blocked-candidate observation rows safely created, and paper/operator backfill correctly refused without executable candidates or human review - [ ] R2 continuous evidence collection gate: `shadow_candidate_day_coverage` has been cleared; now reach enough executable-candidate paper tickets, paper executions, operator feedback, and weekly review samples before claiming R2 phase evidence passed - [x] Remote API/SSH recovery gate: `ai-ict` health, SSH command execution, and no-proxy remote ops smoke recovered -- [ ] Proxy/runtime recovery gate: restore configured OKX read-only proxy reachability, then rerun `POST /trading/runtime/run-once`, no-proxy remote ops smoke, and live readiness checks before making any new R2 sample writes +- [x] Proxy/runtime recovery gate: configured OKX read-only proxy reachability recovered, remote runtime run-once succeeded, market REST candles became fresh with gap clear, standard remote ops smoke passed, and readonly live analysis smoke passed while keeping stream/news/account issues as warnings - [x] Create urgent read-only live analysis launch must-do checklist: `docs/AI_ICT_实盘辅助分析决策助手上线必做清单_2026-05-18_v1.md` - [x] P0-1 read-only market data recovery: live smoke confirms `market_data_fresh`, `market_stream_connected`, and `market_data_gap_clear` without enabling trading - [x] P0-2 narrow `live_analysis_assistant_ready` readiness: read-only analysis launches while `decision_support_ready=false`, `execution_assist_ready=false`, and `real_live_ready=false` diff --git a/docker-compose.yml b/docker-compose.yml index 79d1ddf..e1137cc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,8 @@ services: container_name: ai-ict env_file: - .env + extra_hosts: + - "host.docker.internal:host-gateway" ports: - "8000:8000" volumes: diff --git a/output/playwright/workbench-alerts-view-loaded-1366x768.png b/output/playwright/workbench-alerts-view-loaded-1366x768.png new file mode 100644 index 0000000..ff756f9 Binary files /dev/null and b/output/playwright/workbench-alerts-view-loaded-1366x768.png differ diff --git a/output/playwright/workbench-split-nav-loaded-1366x768.png b/output/playwright/workbench-split-nav-loaded-1366x768.png new file mode 100644 index 0000000..f3bf9d9 Binary files /dev/null and b/output/playwright/workbench-split-nav-loaded-1366x768.png differ diff --git a/output/playwright/workbench-split-nav-loaded-390x844.png b/output/playwright/workbench-split-nav-loaded-390x844.png new file mode 100644 index 0000000..ecfbcbb Binary files /dev/null and b/output/playwright/workbench-split-nav-loaded-390x844.png differ diff --git a/scripts/deploy_with_password.py b/scripts/deploy_with_password.py index d12deee..f31e4f4 100644 --- a/scripts/deploy_with_password.py +++ b/scripts/deploy_with_password.py @@ -120,7 +120,7 @@ def run_remote(client, command: str, *, timeout: int = 120, sudo_password: Optio def build_deploy_command(*, remote_path: str, remote_archive: str) -> str: remote_path_q = _sh_quote(remote_path) remote_archive_q = _sh_quote(remote_archive) - env_backup = "/root/ai-ict-deploy.env.bak" + env_backup = f"{remote_path.rstrip('/')}/.env.deploy.bak" return " && ".join( [ f"mkdir -p {remote_path_q}", diff --git a/src/services/maintenance/maintenance_summary_service.py b/src/services/maintenance/maintenance_summary_service.py index 68cb566..61fbd48 100644 --- a/src/services/maintenance/maintenance_summary_service.py +++ b/src/services/maintenance/maintenance_summary_service.py @@ -60,6 +60,10 @@ class MaintenanceSummaryService: warnings=warnings, fallback={"runtime_health": {}, "runtime_watchdog": {}, "current_blockers": []}, ) + runtime_blockers = effective_runtime_blockers_for_readonly(runtime_status) + suppressed_runtime_blockers = [ + blocker for blocker in list(runtime_status.get("current_blockers") or []) if blocker not in runtime_blockers + ] checks = _build_checks( storage=storage, alert_quality=alert_quality, @@ -94,7 +98,9 @@ class MaintenanceSummaryService: "runtime_health": dict(runtime_status.get("runtime_health") or {}), "runtime_watchdog": dict(runtime_status.get("runtime_watchdog") or {}), "incident_summary": dict(incident_summary or {}), - "current_blockers": list(runtime_status.get("current_blockers") or []), + "current_blockers": runtime_blockers, + "raw_current_blockers": list(runtime_status.get("current_blockers") or []), + "suppressed_blockers": suppressed_runtime_blockers, "current_warnings": list(runtime_status.get("current_warnings") or []), }, "checks": checks, @@ -185,12 +191,13 @@ def _build_checks(*, storage: dict, alert_quality: dict, incident_summary: dict, "automation": "operator_review_required", }, ] - if runtime_status.get("current_blockers"): + runtime_blockers = effective_runtime_blockers_for_readonly(runtime_status) + if runtime_blockers: checks.append( { "name": "runtime.current_blockers", "status": "blocked", - "detail": ", ".join(runtime_status.get("current_blockers") or []), + "detail": ", ".join(runtime_blockers), "automation": "external_environment_or_runtime_recovery_required", } ) @@ -211,7 +218,7 @@ def _build_actions(*, storage: dict, alert_quality: dict, incident_summary: dict for action in list(incident_summary.get("recommended_actions") or []): if action not in operator_actions: operator_actions.append(action) - if runtime_status.get("current_blockers") and "resolve_runtime_blockers" not in operator_actions: + if effective_runtime_blockers_for_readonly(runtime_status) and "resolve_runtime_blockers" not in operator_actions: operator_actions.append("resolve_runtime_blockers") return { "automatic_actions": automatic_actions, @@ -224,6 +231,23 @@ def _build_actions(*, storage: dict, alert_quality: dict, incident_summary: dict } +def effective_runtime_blockers_for_readonly(runtime_status: dict) -> list[str]: + blockers = [str(item) for item in list(runtime_status.get("current_blockers") or []) if item] + return [blocker for blocker in blockers if not _is_readonly_stream_fallback_blocker(blocker, runtime_status)] + + +def _is_readonly_stream_fallback_blocker(blocker: str, runtime_status: dict) -> bool: + if not blocker.startswith("market_stream_degraded:"): + return False + parts = blocker.split(":") + symbol = parts[1] if len(parts) > 1 else "" + states = dict(runtime_status.get("latest_market_state_by_symbol") or {}) + market_state = dict(states.get(symbol) or {}) + freshness_code = str(((market_state.get("freshness") or {}).get("code")) or "") + gap_code = str(((market_state.get("data_gap_state") or {}).get("code")) or "") + return freshness_code == "market_data_fresh" and gap_code == "market_data_gap_clear" + + def _safe_section(fetcher, *, warning_code: str, warnings: list[str], fallback): try: return fetcher() diff --git a/src/services/maintenance/unattended_harness_service.py b/src/services/maintenance/unattended_harness_service.py index 9734774..6d2a30e 100644 --- a/src/services/maintenance/unattended_harness_service.py +++ b/src/services/maintenance/unattended_harness_service.py @@ -6,7 +6,10 @@ from typing import Callable, Optional from src.config.settings import settings from src.services.alerts import AlertQualitySummaryService, OperatorDigestService from src.services.alerts.delivery_policy_service import evaluate_alert_delivery_policy -from src.services.maintenance.maintenance_summary_service import MaintenanceSummaryService +from src.services.maintenance.maintenance_summary_service import ( + MaintenanceSummaryService, + effective_runtime_blockers_for_readonly, +) from src.services.runtime.runtime_task_watchdog_service import RuntimeTaskWatchdogService from src.services.visual_watch import VisualSnapshotCaptureService @@ -195,7 +198,7 @@ def _check_maintenance_summary(payload: dict) -> dict: def _check_runtime_environment(payload: dict) -> dict: - blockers = list(payload.get("current_blockers") or []) + blockers = effective_runtime_blockers_for_readonly(payload) if blockers: return _blocked("runtime_environment", f"runtime blockers: {blockers}", environment=True) if payload.get("status") == "unavailable": diff --git a/src/services/readiness/launch_readiness_service.py b/src/services/readiness/launch_readiness_service.py index 6445e28..ef7e13b 100644 --- a/src/services/readiness/launch_readiness_service.py +++ b/src/services/readiness/launch_readiness_service.py @@ -76,11 +76,12 @@ class LaunchReadinessService: required=real_trade_sample_gate_required, real_trade_count=real_trade_count, ) + market_state_payload = market_state.to_dict() checklist = [ LaunchReadinessCheck( code="market_truth_ready", - passed=_market_truth_ready(market_state.to_dict()), + passed=_market_truth_ready(market_state_payload), severity="blocker", detail=f"freshness={market_state.freshness.code}, stream={market_state.stream_state.code}, gap={market_state.data_gap_state.code}", ), @@ -140,14 +141,25 @@ class LaunchReadinessService: blockers = [item.code for item in checklist if not item.passed and item.severity == "blocker"] warnings = [item.code for item in checklist if not item.passed and item.severity == "warning"] account_context_available = broker_state.status == BROKER_STATE_OK + readonly_market_usable = _market_truth_readonly_usable(market_state_payload) + readonly_runtime_usable = _runtime_readonly_usable(runtime_status) live_analysis_blockers = [ item.code for item in checklist if not item.passed and item.severity == "blocker" and item.code != "broker_state_ready" + and not (item.code == "market_truth_ready" and readonly_market_usable) + and not (item.code == "news_provider_usable" and readonly_market_usable) + and not (item.code == "runtime_ready" and readonly_market_usable and readonly_runtime_usable) ] live_analysis_warnings = list(warnings) + if "market_truth_ready" in blockers and readonly_market_usable: + live_analysis_warnings.append("market_stream_not_connected_rest_fallback") + if "news_provider_usable" in blockers and readonly_market_usable: + live_analysis_warnings.append("news_provider_unavailable_context_warning") + if "runtime_ready" in blockers and readonly_market_usable and readonly_runtime_usable: + live_analysis_warnings.append("runtime_stream_degraded_rest_fallback") if not account_context_available: live_analysis_warnings.append("account_context_unavailable") live_analysis_assistant_ready = not live_analysis_blockers @@ -280,6 +292,22 @@ def _market_truth_ready(payload: dict) -> bool: return freshness_code == "market_data_fresh" and stream_code == "market_stream_connected" and gap_code == "market_data_gap_clear" +def _market_truth_readonly_usable(payload: dict) -> bool: + freshness_code = str(((payload.get("freshness") or {}).get("code")) or "") + gap_code = str(((payload.get("data_gap_state") or {}).get("code")) or "") + return freshness_code == "market_data_fresh" and gap_code == "market_data_gap_clear" + + +def _runtime_readonly_usable(runtime_status: dict) -> bool: + if runtime_status.get("scheduler_state") != "running": + return False + blockers = [str(item) for item in list(runtime_status.get("current_blockers") or [])] + if not blockers: + return True + allowed_prefixes = ("market_stream_degraded:",) + return all(any(blocker.startswith(prefix) for prefix in allowed_prefixes) for blocker in blockers) + + def _resolve_real_trade_sample_gate_status(*, required: bool, real_trade_count: int) -> str: if not required: return "deferred_analysis_decision_only" diff --git a/src/web/app.js b/src/web/app.js index e0e1d54..0feb42a 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -10,6 +10,11 @@ const searchTextInput = document.getElementById('searchText'); const trustClassFilterInput = document.getElementById('trustClassFilter'); const lastUpdated = document.getElementById('lastUpdated'); const activeFilters = document.getElementById('activeFilters'); +const workbenchNav = document.getElementById('workbenchNav'); +const workbenchContent = document.getElementById('workbenchContent'); +const workbenchViewLabel = document.getElementById('workbenchViewLabel'); +const workbenchSections = Array.from(document.querySelectorAll('[data-workbench-section]')); +const workbenchNavButtons = Array.from(document.querySelectorAll('[data-workbench-target]')); const overviewCards = document.getElementById('overviewCards'); const decisionAssistantPanel = document.getElementById('decisionAssistantPanel'); const marketStatePanel = document.getElementById('marketStatePanel'); @@ -82,20 +87,36 @@ let assistantSurfaceResolutionFilter = 'all'; async function fetchJson(url, options = {}) { const fetchOptions = { ...options }; + const timeoutMs = Number(fetchOptions.timeoutMs || 6000); + delete fetchOptions.timeoutMs; + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), timeoutMs); + if (!fetchOptions.signal) { + fetchOptions.signal = controller.signal; + } if (fetchOptions.body) { fetchOptions.headers = { 'Content-Type': 'application/json', ...(fetchOptions.headers || {}), }; } - const response = await fetch(url, fetchOptions); - if (!response.ok) { - throw new Error(`${response.status} ${url}`); + try { + const response = await fetch(url, fetchOptions); + if (!response.ok) { + throw new Error(`${response.status} ${url}`); + } + if (response.status === 204) { + return null; + } + return response.json(); + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error(`request_timeout ${url}`); + } + throw error; + } finally { + window.clearTimeout(timeout); } - if (response.status === 204) { - return null; - } - return response.json(); } async function postJson(url, body = {}) { @@ -129,8 +150,81 @@ function clearNode(node) { } } +function getStoredWorkbenchSection() { + try { + return window.localStorage?.getItem('aiIctActiveWorkbenchSection') || ''; + } catch { + return ''; + } +} + +function storeWorkbenchSection(sectionId) { + try { + window.localStorage?.setItem('aiIctActiveWorkbenchSection', sectionId); + } catch { + // Local storage can be disabled in hardened browser sessions. + } +} + +function getWorkbenchSection(sectionId) { + return workbenchSections.find((section) => section.dataset.workbenchSection === sectionId) || null; +} + +function activateWorkbenchSection(sectionId, options = {}) { + if (!workbenchSections.length) return; + const fallback = getWorkbenchSection('decision-assistant') || workbenchSections[0]; + const target = getWorkbenchSection(sectionId) || fallback; + const activeId = target.dataset.workbenchSection; + workbenchSections.forEach((section) => { + const isActive = section === target; + section.classList.toggle('is-active', isActive); + section.toggleAttribute('hidden', !isActive); + }); + workbenchNavButtons.forEach((button) => { + const isActive = button.dataset.workbenchTarget === activeId; + button.classList.toggle('is-active', isActive); + button.setAttribute('aria-current', isActive ? 'page' : 'false'); + }); + setText(workbenchViewLabel, target.dataset.workbenchLabel || activeId); + if (options.persist !== false) { + storeWorkbenchSection(activeId); + } + if (options.updateHash) { + window.history.replaceState(null, '', `#${activeId}`); + } + if (options.scrollToTop && workbenchContent) { + workbenchContent.scrollTo({ top: 0, behavior: options.smooth ? 'smooth' : 'auto' }); + } +} + +function initializeWorkbenchNavigation() { + if (!workbenchNav || !workbenchSections.length) return; + workbenchNav.addEventListener('click', (event) => { + const button = event.target.closest?.('[data-workbench-target]'); + if (!button) return; + activateWorkbenchSection(button.dataset.workbenchTarget, { + scrollToTop: true, + smooth: true, + updateHash: true, + }); + }); + const hashSection = window.location.hash ? window.location.hash.replace(/^#\/?/, '') : ''; + activateWorkbenchSection(hashSection || getStoredWorkbenchSection() || 'decision-assistant', { + persist: false, + scrollToTop: false, + }); +} + function revealPanel(node) { const panel = node?.closest?.('.panel') || node; + const workbenchSection = panel?.closest?.('[data-workbench-section]') || panel; + if (workbenchSection?.dataset?.workbenchSection) { + activateWorkbenchSection(workbenchSection.dataset.workbenchSection, { + scrollToTop: false, + persist: true, + updateHash: true, + }); + } if (panel && typeof panel.scrollIntoView === 'function') { panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); } @@ -585,6 +679,11 @@ function normalizeAssistantSectionId(title) { function revealAssistantSection(sectionId) { if (!decisionAssistantPanel) return; + activateWorkbenchSection('decision-assistant', { + scrollToTop: false, + persist: true, + updateHash: true, + }); const aliases = { watchlist: 'watchlist-review-now', notes: 'review-entry', @@ -6488,5 +6587,6 @@ searchTextInput.addEventListener('input', refreshDashboard); trustClassFilterInput.addEventListener('change', refreshDashboard); autoRefreshSecondsInput.addEventListener('change', configureAutoRefresh); +initializeWorkbenchNavigation(); configureAutoRefresh(); refreshDashboard(); diff --git a/src/web/index.html b/src/web/index.html index 792ea04..98c8e1d 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -6,7 +6,7 @@ AI ICT Trading Workbench - +

AI ICT Trading Workbench

@@ -18,153 +18,174 @@
-
- - - - - - - - - -
Waiting for first refresh
-
+
+ -
-
Filters: none
-
+
+
+ + + + + + + + + +
Waiting for first refresh
+
-
-
+
+
Filters: none
+
+ +
+

Overview

-
+

Decision Assistant

-
+

Trading Status

-
+

Runtime Supervisor

-
+

Decision Packet

-
+

Model Trust

-
- -

Live Readiness

-
- -

Launch Readiness

-
+

Session Focus

-
+

Pre-Market Plan

-
+

Intraday Candidates

-
+

Execution Tickets

-
+

Alert History

-
- -

Alert Quality

-
+

Visual Reviews

-
+

Position Management

-
+

Operator Console

-
+

Post-Trade Review

-
+

Candles

-
+
+
+
diff --git a/src/web/styles.css b/src/web/styles.css index bd9e5a0..14b4244 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -88,6 +88,90 @@ body { color: #9db0d3; font-size: 13px; } +.workbench-layout { + display: grid; + grid-template-columns: 248px minmax(0, 1fr); + gap: 16px; + margin: 0 16px 16px; + align-items: start; +} +.workbench-sidebar { + position: sticky; + top: 16px; + max-height: calc(100vh - 128px); + overflow: auto; + padding: 14px; + border: 1px solid #24304f; + border-radius: 14px; + background: #121933; +} +.workbench-sidebar-header { + padding: 4px 4px 12px; + border-bottom: 1px solid #24304f; + margin-bottom: 12px; +} +.workbench-view-label { + margin-top: 4px; + color: #e5e7eb; + font-size: 16px; + font-weight: 700; +} +.workbench-nav { + display: grid; + gap: 6px; +} +.workbench-nav button, +.workbench-nav-link { + display: flex; + align-items: center; + width: 100%; + min-height: 38px; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: #aabbe0; + font: inherit; + font-size: 13px; + text-align: left; + text-decoration: none; + cursor: pointer; +} +.workbench-nav button:hover, +.workbench-nav-link:hover { + border-color: #31436c; + background: #0f1530; + color: #e5e7eb; +} +.workbench-nav button.is-active { + border-color: #4f7cff; + background: rgba(79, 124, 255, 0.18); + color: #ffffff; +} +.workbench-nav-primary { + margin-bottom: 8px; + border-color: #31436c; + background: #0f1530; +} +.workbench-content { + min-width: 0; +} +.workbench-content .controls, +.workbench-content .filters-bar { + margin-left: 0; + margin-right: 0; +} +.workspace-section { + display: none; +} +.workspace-section.is-active { + display: block; +} +.workbench-main .panel h2 + h2 { + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid #24304f; +} .status-pill { display: inline-block; padding: 4px 8px; @@ -118,11 +202,18 @@ body { gap: 16px; padding: 0 16px 16px; } +.grid.workbench-main { + grid-template-columns: minmax(0, 1fr); + padding: 0 0 16px; +} .panel { padding: 16px; overflow: hidden; } .panel-wide { grid-column: span 2; } +.workbench-main .panel-wide { + grid-column: 1; +} .panel h2 { margin: 0 0 12px; font-size: 18px; } .table-wrap { overflow: auto; } table { @@ -836,6 +927,16 @@ textarea:focus-visible { font-size: 13px; } @media (max-width: 960px) { + .workbench-layout { + grid-template-columns: 1fr; + } + .workbench-sidebar { + position: static; + max-height: none; + } + .workbench-nav { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + } .grid { grid-template-columns: 1fr; } .panel-wide { grid-column: span 1; } .training-toolbar { grid-template-columns: 1fr; } diff --git a/tests/unit/test_api_static_server.py b/tests/unit/test_api_static_server.py index 8a99390..7653630 100644 --- a/tests/unit/test_api_static_server.py +++ b/tests/unit/test_api_static_server.py @@ -117,6 +117,43 @@ class ApiStaticServerTests(unittest.TestCase): self.assertNotIn('id="trainingSection"', index_html) self.assertNotIn("TRAINING_PUBLIC_ROUTES", app_js) + def test_dashboard_uses_split_workspace_navigation(self) -> None: + index_file = _resolve_static_file("/") + self.assertIsNotNone(index_file) + index_html = index_file.read_text(encoding="utf-8") + app_file = _resolve_static_file("/static/app.js") + self.assertIsNotNone(app_file) + app_js = app_file.read_text(encoding="utf-8") + styles_file = _resolve_static_file("/static/styles.css") + self.assertIsNotNone(styles_file) + styles_css = styles_file.read_text(encoding="utf-8") + + for expected in ( + 'class="workbench-layout"', + 'id="workbenchNav"', + 'data-workbench-target="decision-assistant"', + 'data-workbench-section="decision-assistant"', + 'data-workbench-section="readiness"', + 'data-workbench-section="alerts"', + ): + self.assertIn(expected, index_html) + + for expected in ( + "activateWorkbenchSection", + "initializeWorkbenchNavigation", + "aiIctActiveWorkbenchSection", + "request_timeout", + "AbortController", + ): + self.assertIn(expected, app_js) + + for expected in ( + ".workbench-sidebar", + ".workbench-nav button.is-active", + ".workspace-section.is-active", + ): + self.assertIn(expected, styles_css) + def test_dashboard_uses_canonical_trading_routes(self) -> None: app_file = _resolve_static_file("/static/app.js") self.assertIsNotNone(app_file) diff --git a/tests/unit/test_deploy_with_password.py b/tests/unit/test_deploy_with_password.py index 8ad0a8b..e4d8b16 100644 --- a/tests/unit/test_deploy_with_password.py +++ b/tests/unit/test_deploy_with_password.py @@ -27,6 +27,7 @@ class DeployWithPasswordTests(unittest.TestCase): compose = Path("docker-compose.yml").read_text(encoding="utf-8") self.assertIn("./.claude:/app/.claude", compose) + self.assertIn("host.docker.internal:host-gateway", compose) def test_deploy_smoke_includes_p24_execution_layer_endpoints(self) -> None: smoke_source = Path("scripts/deploy_with_password.py").read_text(encoding="utf-8") @@ -44,6 +45,15 @@ class DeployWithPasswordTests(unittest.TestCase): self.assertIn("scripts/apply_migrations.py", command) self.assertIn("docker rm -f ai-ict", command) + def test_deploy_env_backup_stays_inside_remote_path_for_non_root_user(self) -> None: + command = deploy_with_password.build_deploy_command( + remote_path="/home/yuqei/project/ai-exchange", + remote_archive="/home/yuqei/ai-ict-deploy.tgz", + ) + + self.assertIn("/home/yuqei/project/ai-exchange/.env.deploy.bak", command) + self.assertNotIn("/root/ai-ict-deploy.env.bak", command) + def test_run_remote_can_wrap_remote_step_with_sudo_without_leaking_password_in_command(self) -> None: class _FakeStdin: def __init__(self) -> None: diff --git a/tests/unit/test_launch_readiness_service.py b/tests/unit/test_launch_readiness_service.py index 45584f1..b280d2d 100644 --- a/tests/unit/test_launch_readiness_service.py +++ b/tests/unit/test_launch_readiness_service.py @@ -109,6 +109,33 @@ class LaunchReadinessServiceTests(unittest.TestCase): self.assertFalse(payload["execution_assist_ready"]) self.assertFalse(payload["real_live_ready"]) + def test_live_analysis_allows_rest_fresh_market_when_stream_and_news_are_degraded(self) -> None: + service = LaunchReadinessService( + market_state_service=_RestFreshMarketStateService(), + broker_read_model_service=_BrokerReadService(status="broker_state_unavailable"), + weekly_review_service=_WeeklyReviewService(), + trust_service=_TrustService(), + live_readiness_service=_LiveReadinessService(), + shadow_paper_validation_service=_ShadowPaperValidationService(), + ) + + with patch("src.services.readiness.launch_readiness_service.get_trading_runtime_service", return_value=_RuntimeServiceWithStreamBlocker()): + payload = service.build_summary(instrument_id=1, timeframe="1m") + + self.assertFalse(payload["analysis_only_ready"]) + self.assertTrue(payload["live_analysis_assistant_ready"]) + self.assertEqual(payload["product_operating_mode"], "readonly_live_analysis") + self.assertIn("market_truth_ready", payload["blocking_reasons"]) + self.assertIn("news_provider_usable", payload["blocking_reasons"]) + self.assertIn("runtime_ready", payload["blocking_reasons"]) + self.assertIn("market_stream_not_connected_rest_fallback", payload["live_analysis_assistant_summary"]["warnings"]) + self.assertIn("news_provider_unavailable_context_warning", payload["live_analysis_assistant_summary"]["warnings"]) + self.assertIn("runtime_stream_degraded_rest_fallback", payload["live_analysis_assistant_summary"]["warnings"]) + self.assertEqual(payload["live_analysis_assistant_summary"]["blockers"], []) + self.assertFalse(payload["decision_support_ready"]) + self.assertFalse(payload["execution_assist_ready"]) + self.assertFalse(payload["real_live_ready"]) + def test_build_summary_exposes_restricted_soft_rollout_when_only_blacklist_models_exist(self) -> None: service = LaunchReadinessService( market_state_service=_MarketStateService(), @@ -162,6 +189,29 @@ class _MarketStateService: return _MarketState() +class _RestFreshMarketState: + freshness = type("Freshness", (), {"code": "market_data_fresh"})() + stream_state = type("Stream", (), {"code": "market_stream_connecting"})() + data_gap_state = type("Gap", (), {"code": "market_data_gap_clear"})() + news_window = type( + "News", + (), + {"provider_name": "news_provider_chain", "freshness_code": "news_provider_stale", "error": "snapshot_missing"}, + )() + + def to_dict(self): + return { + "freshness": {"code": "market_data_fresh"}, + "stream_state": {"code": "market_stream_connecting"}, + "data_gap_state": {"code": "market_data_gap_clear"}, + } + + +class _RestFreshMarketStateService: + def build_market_state(self, **kwargs): + return _RestFreshMarketState() + + class _BrokerState: def __init__(self, status: str = "broker_state_ok") -> None: self.status = status @@ -248,6 +298,14 @@ class _RuntimeService: return {"scheduler_state": "running", "current_blockers": []} +class _RuntimeServiceWithStreamBlocker: + def status(self): + return { + "scheduler_state": "running", + "current_blockers": ["market_stream_degraded:BTC-USDT:market_stream_connecting"], + } + + class _ShadowPaperValidationService: def build_summary(self, **kwargs): return { diff --git a/tests/unit/test_maintenance_summary_service.py b/tests/unit/test_maintenance_summary_service.py index f750869..8f4835e 100644 --- a/tests/unit/test_maintenance_summary_service.py +++ b/tests/unit/test_maintenance_summary_service.py @@ -108,6 +108,42 @@ class MaintenanceSummaryServiceTests(unittest.TestCase): self.assertEqual(summary["blockers"], []) self.assertEqual(summary["maintenance_actions"]["automatic_actions"], []) + def test_build_summary_suppresses_stream_blocker_when_rest_market_is_fresh(self) -> None: + service = MaintenanceSummaryService( + capture_retention_service=_CaptureRetentionService( + {"status": "completed", "eligible_file_count": 0, "deleted_file_count": 0} + ), + alert_quality_service=_AlertQualityService( + { + "delivery_summary": {"failed_count": 0, "retry_exhausted_skip_count": 0, "channel_disabled_count": 0}, + "alert_feedback_summary": {"count": 1}, + "visual_feedback_summary": {"count": 1}, + "quality_flags": [], + } + ), + incident_journal_service=_IncidentJournalService( + {"status": "idle", "incident_count": 0, "severity_counts": {}, "recommended_actions": []} + ), + runtime_status_provider=lambda: { + "current_blockers": ["market_stream_degraded:BTC-USDT:market_stream_connecting"], + "current_warnings": [], + "runtime_watchdog": {"stalled_task_count": 0}, + "runtime_health": {"level": "blocked"}, + "latest_market_state_by_symbol": { + "BTC-USDT": { + "freshness": {"code": "market_data_fresh"}, + "data_gap_state": {"code": "market_data_gap_clear"}, + } + }, + }, + ) + + summary = service.build_summary(instrument_id=1) + + self.assertEqual(summary["status"], "healthy") + self.assertEqual(summary["runtime"]["current_blockers"], []) + self.assertEqual(summary["runtime"]["suppressed_blockers"], ["market_stream_degraded:BTC-USDT:market_stream_connecting"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_unattended_harness_service.py b/tests/unit/test_unattended_harness_service.py index 1b6b905..ae95b04 100644 --- a/tests/unit/test_unattended_harness_service.py +++ b/tests/unit/test_unattended_harness_service.py @@ -85,6 +85,30 @@ class UnattendedHarnessServiceTests(unittest.TestCase): runtime_check = [item for item in payload["checks"] if item["name"] == "runtime_environment"][0] self.assertEqual(runtime_check["blocker_type"], "environment") + def test_run_harness_suppresses_stream_blocker_when_rest_market_is_fresh(self) -> None: + service = UnattendedHarnessService( + maintenance_summary_service=_MaintenanceSummaryService(), + alert_quality_service=_AlertQualityService(), + operator_digest_service=_OperatorDigestService(), + visual_capture_service=_VisualCaptureService(), + runtime_status_provider=lambda: { + "current_blockers": ["market_stream_degraded:BTC-USDT:market_stream_connecting"], + "runtime_health": {"level": "blocked"}, + "latest_market_state_by_symbol": { + "BTC-USDT": { + "freshness": {"code": "market_data_fresh"}, + "data_gap_state": {"code": "market_data_gap_clear"}, + } + }, + }, + ) + + payload = service.run_harness(instrument_id=1) + + self.assertEqual(payload["status"], "passed") + self.assertIn("runtime_environment", payload["passed_checks"]) + self.assertEqual(payload["blockers"], []) + if __name__ == "__main__": unittest.main()