From b88f7c35215cb2d251fb1f344de1ef3f277afe1b Mon Sep 17 00:00:00 2001 From: Yu <1305203710@qq.com> Date: Sun, 19 Jul 2026 10:38:44 +0800 Subject: [PATCH 1/4] Back up scheduling and topology updates --- .gitignore | 4 + README.md | 2 + configs/batch_experiments.json | 12 + configs/cloudsim_core_experiments.json | 19 + configs/fragmentation_green_experiments.json | 59 + configs/objective_ablation_experiments.json | 38 + docs/batch-carbon-scheduling.md | 128 ++ docs/cloudsim-core-experiments.md | 98 ++ docs/fragmentation-green-experiments.md | 79 ++ examples/cloudsimplus/README.md | 10 + .../examples/HuaweiDciTianjunExperiment.java | 278 ++++- .../examples/tianjun/TianjunHttpBridge.java | 319 ++++- .../tianjun-carbon-intensity-trace.csv | 13 + .../resources/tianjun-power-profiles.json | 9 + pyproject.toml | 1 + scripts/analyze_cloudsim_core.py | 203 ++++ scripts/calibrate_b6_weights.py | 189 +++ scripts/report_green_validation.py | 141 +++ scripts/run_cloudsim_core_experiments.ps1 | 137 +++ .../application/batch_scheduling_service.py | 1078 +++++++++++++++++ src/tianjun/application/control_plane.py | 221 +++- src/tianjun/application/node_registry.py | 28 + src/tianjun/application/policy_workflow.py | 1 + src/tianjun/application/task_lease_service.py | 2 + src/tianjun/chat/runtime.py | 24 +- src/tianjun/cli/commands/serve.py | 12 +- src/tianjun/core/policy.py | 24 +- src/tianjun/domain/__init__.py | 40 +- src/tianjun/domain/batch.py | 204 ++++ src/tianjun/domain/carbon.py | 134 ++ src/tianjun/domain/common.py | 37 +- src/tianjun/domain/execution.py | 24 + src/tianjun/domain/node.py | 63 + src/tianjun/domain/policy.py | 29 +- src/tianjun/domain/resource.py | 24 + src/tianjun/domain/task.py | 17 + src/tianjun/experiments/__init__.py | 11 + src/tianjun/experiments/assignment.py | 161 +++ src/tianjun/experiments/report.py | 149 +++ src/tianjun/experiments/runner.py | 427 +++++++ src/tianjun/experiments/weights.py | 67 + src/tianjun/integrations/mcp_server.py | 75 +- .../interfaces/dashboard/static/css/base.css | 2 + .../interfaces/dashboard/static/css/nav.css | 6 +- .../dashboard/static/css/pages/model.css | 47 + .../dashboard/static/css/pages/scheduling.css | 203 ++++ .../dashboard/static/css/pages/topology.css | 35 + .../interfaces/dashboard/static/index.html | 1 + .../interfaces/dashboard/static/js/api.js | 35 +- .../dashboard/static/js/pages/model.js | 97 +- .../dashboard/static/js/pages/overview.js | 6 + .../dashboard/static/js/pages/scheduling.js | 224 ++++ .../dashboard/static/js/pages/tasks.js | 17 +- .../dashboard/static/js/pages/topology.js | 35 +- .../interfaces/dashboard/static/js/router.js | 5 + .../interfaces/dashboard/static/js/state.js | 4 + .../interfaces/dashboard/static/js/utils.js | 4 +- src/tianjun/interfaces/http/server.py | 89 +- src/tianjun/policy/feedback.py | 5 +- src/tianjun/policy/generator.py | 82 +- src/tianjun/scenarios/fixtures.py | 26 + src/tianjun/scheduling/engine.py | 430 ++++++- src/tianjun/tools/schema.py | 12 + src/tianjun/tools/service.py | 51 + tests/test_batch_carbon_scheduling.py | 285 +++++ tests/test_cloudsim_contract.py | 37 + tests/test_dashboard_contract.py | 11 + tests/test_experiment_baselines.py | 168 +++ tests/test_http_routes.py | 56 + 69 files changed, 6444 insertions(+), 120 deletions(-) create mode 100644 configs/batch_experiments.json create mode 100644 configs/cloudsim_core_experiments.json create mode 100644 configs/fragmentation_green_experiments.json create mode 100644 configs/objective_ablation_experiments.json create mode 100644 docs/batch-carbon-scheduling.md create mode 100644 docs/cloudsim-core-experiments.md create mode 100644 docs/fragmentation-green-experiments.md create mode 100644 examples/cloudsimplus/src/main/resources/tianjun-carbon-intensity-trace.csv create mode 100644 examples/cloudsimplus/src/main/resources/tianjun-power-profiles.json create mode 100644 scripts/analyze_cloudsim_core.py create mode 100644 scripts/calibrate_b6_weights.py create mode 100644 scripts/report_green_validation.py create mode 100644 scripts/run_cloudsim_core_experiments.ps1 create mode 100644 src/tianjun/application/batch_scheduling_service.py create mode 100644 src/tianjun/domain/batch.py create mode 100644 src/tianjun/domain/carbon.py create mode 100644 src/tianjun/experiments/__init__.py create mode 100644 src/tianjun/experiments/assignment.py create mode 100644 src/tianjun/experiments/report.py create mode 100644 src/tianjun/experiments/runner.py create mode 100644 src/tianjun/experiments/weights.py create mode 100644 tests/test_batch_carbon_scheduling.py create mode 100644 tests/test_experiment_baselines.py diff --git a/.gitignore b/.gitignore index 308153e..3e06908 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,7 @@ tianjun.toml data/alibaba_microservices_v2022/ runtime_report.json + +# Local experiment outputs and temporary runtime data +exp_out/ +tmp/ diff --git a/README.md b/README.md index 812b54a..9886555 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Tianjun Engine 是一个本地优先的算网调度控制平面原型。它把 本项目用于研究、演示和架构实验,并非生产级云平台。资源清单、定价、拓扑和执行事实必须来自已注册节点、CloudSimPlus 桥接器或真实节点代理;LLM 可以解释和帮助解析意图,但不能在没有明确确认路径的情况下捏造控制平面事实或提交工作。 +当前版本已加入批任务 JSON/CSV 导入、共享快照联合分配、Pareto + Tchebycheff 十维评分、Future-Fit 碎片评估、运行碳核算、显式确认原子预留以及外部 Hermes MCP 审计。接口和核算口径见 [批任务与运行碳调度说明](docs/batch-carbon-scheduling.md)。 + ## 快速开始 ### 0. 安装依赖 diff --git a/configs/batch_experiments.json b/configs/batch_experiments.json new file mode 100644 index 0000000..d1c2462 --- /dev/null +++ b/configs/batch_experiments.json @@ -0,0 +1,12 @@ +{ + "schema_version": "1.0", + "node_counts": [20, 50, 100], + "batch_task_counts": [20, 100, 500], + "load_rates": [0.30, 0.60, 0.85, 0.95], + "workloads": ["cpu", "gpu", "memory", "data", "mixed"], + "seeds": [20260718, 20260719, 20260720, 20260721, 20260722, 20260723, 20260724, 20260725, 20260726, 20260727], + "online_strategies": ["B0-current", "B1-batch-greedy", "B3-batch-local-search", "B4-pareto-tchebycheff", "B6-hierarchical-batch"], + "offline_strategies": ["B2-milp-oracle", "B5-nsga2"], + "carbon_scope": "operational_only", + "normalization_bounds_version": "engineering-v1" +} diff --git a/configs/cloudsim_core_experiments.json b/configs/cloudsim_core_experiments.json new file mode 100644 index 0000000..b808fef --- /dev/null +++ b/configs/cloudsim_core_experiments.json @@ -0,0 +1,19 @@ +{ + "schema_version": "1.0", + "description": "CloudSim Plus execution-level comparison. Every run starts an isolated Tianjun control plane and writes measured batch metrics under exp_out/cloudsim_core.", + "strategies": [ + "B0-current", + "B4-pareto-tchebycheff", + "B6-hierarchical-batch", + "B6-green-single-v1", + "B6-green-sla-85-v1" + ], + "scenarios": [ + "normal", + "fault-active" + ], + "seeds": [20260527, 20260528, 20260529, 20260530, 20260531, 20260532, 20260533, 20260534, 20260535, 20260536], + "cloudlets": 36, + "carbon_scope": "operational_only", + "output_directory": "exp_out/cloudsim_core" +} diff --git a/configs/fragmentation_green_experiments.json b/configs/fragmentation_green_experiments.json new file mode 100644 index 0000000..d2717e6 --- /dev/null +++ b/configs/fragmentation_green_experiments.json @@ -0,0 +1,59 @@ +{ + "schema_version": "3.0", + "description": "High-load heterogeneous-fragmentation and green objective ablation with held-out calibration seeds.", + "node_counts": [20], + "batch_task_counts": [60], + "load_rates": [0.85, 0.95], + "workloads": ["mixed"], + "fragmentation_modes": ["heterogeneous"], + "seeds": [20260718, 20260719, 20260720, 20260721, 20260722], + "training_seeds": [20260718, 20260719, 20260720], + "validation_seeds": [20260721, 20260722], + "online_strategies": [ + "B0-current", + "B4-pareto-tchebycheff", + "B6-hierarchical-batch" + ], + "offline_strategies": [], + "objective_experiments": { + "single_atomic": ["carbon", "fragmentation"], + "dual_atomic": [ + ["carbon", "completion"], + ["carbon", "fragmentation"] + ], + "single_groups": ["green_carbon", "resource_efficiency"], + "dual_groups": [ + ["green_carbon", "sla_quality"], + ["green_carbon", "resource_efficiency"] + ] + }, + "weight_profiles": [ + { + "label": "W0-current-green", + "strategy": "B6-hierarchical-batch", + "group_weights": {"sla_quality": 0.26, "network_coordination": 0.20, "resource_efficiency": 0.22, "economic_cost": 0.12, "green_carbon": 0.20} + }, + { + "label": "W1-carbon-080", + "strategy": "B6-hierarchical-batch", + "group_weights": {"sla_quality": 0.25, "network_coordination": 0.10, "resource_efficiency": 0.20, "economic_cost": 0.05, "green_carbon": 0.40} + }, + { + "label": "W2-carbon-120", + "strategy": "B6-hierarchical-batch", + "group_weights": {"sla_quality": 0.20, "network_coordination": 0.07, "resource_efficiency": 0.20, "economic_cost": 0.03, "green_carbon": 0.50} + }, + { + "label": "W3-carbon-160", + "strategy": "B6-hierarchical-batch", + "group_weights": {"sla_quality": 0.18, "network_coordination": 0.05, "resource_efficiency": 0.15, "economic_cost": 0.02, "green_carbon": 0.60} + }, + { + "label": "W4-carbon-200", + "strategy": "B6-hierarchical-batch", + "group_weights": {"sla_quality": 0.20, "network_coordination": 0.04, "resource_efficiency": 0.25, "economic_cost": 0.01, "green_carbon": 0.50} + } + ], + "carbon_scope": "operational_only", + "normalization_bounds_version": "engineering-v1" +} diff --git a/configs/objective_ablation_experiments.json b/configs/objective_ablation_experiments.json new file mode 100644 index 0000000..ee160ab --- /dev/null +++ b/configs/objective_ablation_experiments.json @@ -0,0 +1,38 @@ +{ + "schema_version": "2.0", + "node_counts": [20, 50], + "batch_task_counts": [20, 100], + "load_rates": [0.60, 0.85], + "workloads": ["mixed"], + "seeds": [20260718, 20260719, 20260720, 20260721, 20260722], + "online_strategies": [ + "B0-current", + "B4-pareto-tchebycheff", + "B6-hierarchical-batch" + ], + "offline_strategies": [], + "objective_experiments": { + "single_atomic": [ + "performance", + "completion", + "cost", + "reliability", + "balance", + "fragmentation", + "locality", + "network", + "carbon" + ], + "dual_atomic": "all", + "single_groups": [ + "sla_quality", + "network_coordination", + "resource_efficiency", + "economic_cost", + "green_carbon" + ], + "dual_groups": "all" + }, + "carbon_scope": "operational_only", + "normalization_bounds_version": "engineering-v1" +} diff --git a/docs/batch-carbon-scheduling.md b/docs/batch-carbon-scheduling.md new file mode 100644 index 0000000..054569b --- /dev/null +++ b/docs/batch-carbon-scheduling.md @@ -0,0 +1,128 @@ +# 批任务、多目标、碎片与运行碳联合调度 + +天钧引擎现已提供 JSON/CSV 批任务导入、联合预演、策略对比、显式确认和原子预留闭环。首期任务相互独立,一个任务只分配到一个节点;碳核算范围固定为 `operational_only`。 + +## 公共接口 + +```text +POST /task-batches/import +GET /task-batches/{batch_id} +POST /task-batches/{batch_id}/preview +POST /task-batches/{batch_id}/compare +POST /task-batches/{batch_id}/commit +``` + +提交必须携带预演返回的 `plan_id`、`resource_snapshot_version` 和 `confirmed_by_user_button=true`。快照发生变化时返回 HTTP 409,并且不会创建任何部分预留。 + +CSV 必填列: + +```text +task_id,task_type,cpu,memory,gpu,storage,estimated_duration,priority +``` + +布尔字段只接受 `true/false`,多值字段使用 `|` 分隔。单批最多 1000 个任务,文件不超过 5 MB,任意一行失败时整批不入队。 + +## 在线策略 + +- `B0-current`:兼容当前逐任务加权评分基线。 +- `B1-batch-greedy`:按紧迫度、优先级、稀缺资源和稳定 ID 排序,分配后立即更新虚拟资源。 +- `B3-batch-local-search`:在 B1 上增加节点重放、局部交换和未分配任务回填。 +- `B4-pareto-tchebycheff`:每个任务先做 Pareto 候选过滤,再使用增强型 Tchebycheff 效用和 B3 联合分配。 +- `B6-hierarchical-batch`:默认研究策略。先在五个业务目标组内融合原子指标,再对目标组做 Pareto 过滤、增强型 Tchebycheff 排序和批方案级局部搜索。 + +十项观测指标不会被无差别地一次性相加,而是分成五个语义目标组: + +| 外层目标组 | 组内原子指标 | 默认组内权重 | +|---|---|---| +| SLA 与服务质量 | performance、completion、reliability | 0.20 / 0.50 / 0.30 | +| 网络与地域协同 | network、locality | 0.65 / 0.35 | +| 资源效率 | balance、fragmentation(并在方案层融合 Future-Fit) | 0.40 / 0.60 | +| 经济成本 | cost | 1.00 | +| 绿色低碳 | carbon | 1.00 | + +表中的组内权重是稳定语义先验,不会覆盖项目原有参数。每个任务先计算十维的 `W_final`,再在各组内重新归一化,并与先验融合: + +```text +W_inner(g) = Normalize(0.35 W_prior(g) + 0.65 Normalize(W_final restricted to group g)) +``` + +因此,原有十维权重仍会影响组内取舍;五个目标组权重负责组间取舍。两层权重都会写入决策快照,便于消融、解释和复现。 + +`security` 不进入可互相补偿的外层效用:容量、地域、数据驻留、最低安全等级、隔离、加密、截止时间和强制碳预算先作为硬约束;候选节点满足硬约束后,再按安全等级扣除残余风险惩罚。这样低成本或低碳不能抵消安全违规。 + +最终权重按以下来源融合: + +```text +W_final = Normalize(0.4 W_intent + 0.4 W_SLA + 0.2 W_data) +``` + +工程归一化使用固定版本边界,不再根据当前候选集合临时 Min-Max。当前 `W_data` 是固定 CRITIC 参考画像;`tianjun.experiments` 已提供离线 CRITIC、熵权、MILP Oracle 和确定性 NSGA-II 基线,但它们不进入在线默认路径。 + +实验策略必须显式提交 `experiment_mode=true`: + +- `B2-milp-oracle`:限制为不超过 20 个任务 × 20 个节点,使用 SciPy/HiGHS 生成小规模 Oracle; +- `B5-nsga2`:离线 Pareto 基线,固定随机种子时结果可重复; +- 安装实验依赖:`pip install -e ".[experiments]"`。 + +统一实验矩阵可直接运行: + +```powershell +python -m tianjun.experiments.runner --quick +python -m tianjun.experiments.runner --config configs/batch_experiments.json --output artifacts/batch_experiments/results.json +``` + +输出固定记录接纳率、Makespan、决策时间、SLA 违反、Future-Fit、能耗、运行碳及相对 B0 的差值;完整矩阵可能耗时较长,先用 `--quick` 验证环境。 + +### 单目标、双目标与完整融合实验 + +专用配置文件 `configs/objective_ablation_experiments.json` 同时定义五类证据: + +```text +S1:单个原子指标(性能、时效、成本、可靠性、均衡、碎片、地域、网络、运行碳) +S2:九个可补偿原子目标的全部两两组合,共 C(9,2)=36 组 +G1:单个目标组 +G2:五个目标组的全部两两组合,共 C(5,2)=10 组 +FULL:B4 十维扁平融合与 B6 五组分层融合 +``` + +运行命令: + +```powershell +python -m tianjun.experiments.runner --quick --config configs/objective_ablation_experiments.json --output exp_out/quick.json +python -m tianjun.experiments.runner --config configs/objective_ablation_experiments.json --output exp_out/results.json +python -m tianjun.experiments.report exp_out/results.json --csv exp_out/summary.csv --markdown exp_out/summary.md +``` + +每行结果除业务指标外,还记录 `experiment_label`、`objective_scope`、`active_objectives`、`objective_hierarchy_version`、五组得分、方案级效用和安全风险惩罚。正式结论应按相同拓扑/任务/随机种子进行配对比较,报告均值、标准差或 95% 置信区间,而不是只选一个最好样例。 + +单目标实验回答“指标方向是否正确”;双目标实验回答“目标之间如何冲突或协同”;B4 与 B6 的对照回答“分层结构是否优于十维直接融合”。三者必须同时保留,不能用完整融合结果替代消融实验。 + +## 运行碳口径 + +```text +E_IT(kWh) = P_incremental(W) × T(seconds) / 3,600,000 +O_compute(g) = E_IT × PUE × CI_site(region, tick) +O_total = O_compute + O_network +``` + +物理 Host 空闲功耗只计算一次,任务只分摊增量功耗。站点 PUE、碳强度轨迹和 Host 功耗画像独立于 `.brite` 网络拓扑保存。允许时间平移且提供 `deferrable_until_tick` 时,调度器可在窗口内选择预测碳强度最低的 tick;禁止跨地域或禁止错峰时始终服从用户硬约束。 + +## Hermes 与 MCP + +MCP 工具包括: + +```text +import_task_batch +get_task_batch +preview_batch_schedule +compare_batch_strategies +commit_batch_schedule +``` + +外部 MCP 请求带调用来源头,控制面记录最近成功工具、批次、方案和时间。Dashboard 只在出现真实成功调用后显示 MCP 已调用;MCP 进程启动本身不计为连接成功。 + +## 研究边界 + +- 暂不实现 DAG、Gang 调度、跨节点 GPU 聚合和硬件隐含碳。 +- 论文中的收益数字不作为本项目结果;必须在同一拓扑、任务、碳轨迹和随机种子上复跑。 +- 合成碳轨迹用于可重复主实验,真实公开碳数据用于复核。 diff --git a/docs/cloudsim-core-experiments.md b/docs/cloudsim-core-experiments.md new file mode 100644 index 0000000..6121f5a --- /dev/null +++ b/docs/cloudsim-core-experiments.md @@ -0,0 +1,98 @@ +# CloudSim Plus 批调度核心实验 + +## 已接通的执行闭环 + +```text +Cloudlet 批次 +→ POST /task-batches/import +→ POST /task-batches/{batch_id}/preview +→ POST /task-batches/{batch_id}/commit +→ 节点领取 lease +→ Cloudlet 在指定 VM 执行 +→ POST /task-runs/result +→ GET /task-batches/{batch_id}/metrics +``` + +Python 控制面和 CloudSim 桥接层统一采用八维资源契约: + +```text +cpu, memory, gpu, storage, mips, gpu_memory, storage_iops, bandwidth +``` + +旧四维场景仍可运行,新增维度缺省为 0。CloudSim Host 使用功耗配置中的瓦特值;Host 遥测总能耗与任务增量能耗分别记账,避免重复计碳。碳强度从独立 CSV 轨迹加载,`.brite` 仅表示网络拓扑。 + +## 正式绿色策略 + +- `B0-current`:现有串行基线。 +- `B6-green-single-v1`:只激活 `green_carbon`,用于绿色单目标消融。 +- `B6-green-sla-85-v1`:`green_carbon=0.85`、`sla_quality=0.15`,用于绿色与 SLA 双目标消融。 + +这些名称是可审计实验配置,不替代通用的 `B6-hierarchical-batch`。静态绿色权重只有在正常与故障场景都通过验证后,才能升级为默认策略。 + +## 预测与实测口径 + +CloudSim 向控制面传入 Cloudlet 的预期 CPU 利用率和估计时长。控制面据此预测任务增量功率、能耗和运行碳。实际结果回传: + +- 接纳率、完成率、成功和失败任务数; +- 平均/P95 JCT、排队等待和 Makespan; +- CPU、内存、带宽和存储平均利用率; +- 任务增量能耗、计算碳、网络碳和总运行碳; +- SLA 违规数、预演值和决策时间。 + +碳核算范围固定为 `operational_only`,不含硬件隐含碳。当前预演 Makespan 按节点累计负载计算,而 CloudSim 会在多核、多 VM 上并行,因此预演 Makespan 是保守值;正式结论使用 CloudSim 实测 JCT 与 Makespan。 + +## 运行命令 + +运行配置中的全部核心实验: + +```powershell +./scripts/run_cloudsim_core_experiments.ps1 +``` + +中断后从已有 `*.metrics.json` 继续: + +```powershell +./scripts/run_cloudsim_core_experiments.ps1 -Resume +``` + +只运行正式绿色验证策略: + +```powershell +./scripts/run_cloudsim_core_experiments.ps1 ` + -StrategyFilter B0-current,B6-green-single-v1,B6-green-sla-85-v1 +python ./scripts/report_green_validation.py +``` + +单种子链路验证: + +```powershell +./scripts/run_cloudsim_core_experiments.ps1 ` + -StrategyFilter B0-current ` + -ScenarioFilter normal ` + -SeedLimit 1 +``` + +配置位于 `configs/cloudsim_core_experiments.json`。每次运行会启动隔离的控制面和 SQLite 数据库,同步桥接源码、执行 Maven、保存 Cloudlet 实际指标,并生成统计结果。 + +## 输出目录 + +```text +exp_out/cloudsim_core/ +├─ {strategy}/{scenario}/seed-{seed}/ +│ ├─ topology-snapshots.jsonl +│ ├─ topology-snapshots.metrics.json +│ ├─ control-plane.sqlite3 +│ ├─ cloudsim-maven.log +│ └─ control-plane.*.log +├─ raw_metrics.csv +├─ summary.csv +├─ paired_effects.csv +└─ summary.md + +exp_out/cloudsim_green_validation/ +├─ summary.csv +├─ paired_effects.csv +└─ summary.md +``` + +`summary.csv` 报告 Student-t 95% 置信区间,`paired_effects.csv` 按相同场景和随机种子计算策略相对 B0 的配对差值与变化率。单种子只用于链路验证,论文结论使用配置中的 10 个随机种子。 diff --git a/docs/fragmentation-green-experiments.md b/docs/fragmentation-green-experiments.md new file mode 100644 index 0000000..e759e9a --- /dev/null +++ b/docs/fragmentation-green-experiments.md @@ -0,0 +1,79 @@ +# 高负载碎片与绿色消融实验 + +## 实验目的 + +该实验不是只比较低碳,而是在相同异构节点、任务和随机种子下回答三个问题: + +1. 高负载时,碎片感知能否保留更多未来任务可调度能力; +2. 绿色单目标、绿色与完成时间双目标、绿色与碎片双目标之间有何取舍; +3. B6 分层目标中的绿色权重能否从训练种子泛化到验证种子和 CloudSim 实际执行。 + +## Future-Fit 口径 + +Future-Fit 使用未来任务与节点的可行对比例: + +```text +Future-Fit = 可行的任务—节点对数 / (未来任务数 × 节点数) +``` + +可行性同时检查八维资源、地域、安全、隔离、网络和强制碳预算。相比“任务只要能放入任意一个节点就算可行”,该指标能识别可替代节点减少和资源形状碎片。 + +同时报告: + +```text +Future-Fit loss = Future-Fit before - Future-Fit after +FF loss / accepted task = Future-Fit loss / 已接纳任务数 +carbon / accepted task = 批次预测运行碳 / 已接纳任务数 +``` + +按任务归一化用于避免接纳任务数不同导致碎片和碳指标不可比;批次总碳仍保留,用于评估整个方案的实际环境影响。 + +## 实验设计 + +- 节点:20 个,八维异构资源; +- 任务:60 个 CPU、GPU、内存和数据密集型混合任务; +- 背景负载:85% 和 95%; +- 背景形状:CPU-heavy、memory-heavy、GPU/IO-heavy、network-heavy; +- 随机种子:5 个,其中前 3 个用于权重选择,后 2 个只用于验证。 + +消融组包括: + +- 十维平面融合与五组分层融合; +- `carbon`、`fragmentation` 原子单目标; +- `carbon+completion`、`carbon+fragmentation` 原子双目标; +- `green_carbon`、`resource_efficiency` 分组单目标; +- `green_carbon+sla_quality`、`green_carbon+resource_efficiency` 分组双目标; +- B6 五组权重校准候选。 + +## B6 决策开销控制 + +B6 只在 `resource_efficiency` 或原子 `fragmentation` 目标处于激活状态时,为候选节点计算 Future-Fit。绿色单目标及绿色+SLA 双目标仍在最终方案阶段完整报告 Future-Fit,但候选排序阶段跳过不会参与效用计算的碎片指标。该优化不改变硬约束,也不删减最终实验输出。 + +## 运行命令 + +```powershell +python -m tianjun.experiments.runner ` + --config configs/fragmentation_green_experiments.json ` + --output exp_out/fragmentation_green/results.json + +python -m tianjun.experiments.report ` + exp_out/fragmentation_green/results.json ` + --csv exp_out/fragmentation_green/summary.csv ` + --markdown exp_out/fragmentation_green/summary.md + +python scripts/calibrate_b6_weights.py +``` + +## 输出与解释边界 + +结果保存到: + +```text +exp_out/fragmentation_green/results.json +exp_out/fragmentation_green/summary.csv +exp_out/fragmentation_green/summary.md +exp_out/fragmentation_green/calibration.json +exp_out/fragmentation_green/calibration.md +``` + +合成实验只用于筛选候选权重。候选权重必须再经过 CloudSim 正常与故障场景验证;如果只在正常场景减碳、在故障场景增碳,就保留为实验配置,不能替换默认 B6。 diff --git a/examples/cloudsimplus/README.md b/examples/cloudsimplus/README.md index 9f671e1..e10f1bc 100644 --- a/examples/cloudsimplus/README.md +++ b/examples/cloudsimplus/README.md @@ -8,6 +8,8 @@ src/main/java/org/cloudsimplus/examples/HuaweiDciTianjunExperiment.java src/main/java/org/cloudsimplus/examples/tianjun/TianjunHttpBridge.java src/main/resources/huawei-dci-reference.brite +src/main/resources/tianjun-power-profiles.json +src/main/resources/tianjun-carbon-intensity-trace.csv ``` 该实验创建了 24 个模拟计算 VM,并将三个 Hermes 部署区域映射到各一个模拟物理接入点: @@ -29,3 +31,11 @@ src/main/resources/huawei-dci-reference.brite 5. Cloudlet 完成后,示例通过 `/task-runs/result` 回传最终执行结果,控制平面再写入执行记录。 因此 Dashboard 拓扑页可以根据 `/report` 中的 `active_runs`、`recent_progress_events`、调度决策和节点 inventory 实时更新 DCI 路径与 Leaf/Cluster/VM 高亮。 + +## 功耗与运行碳口径 + +- `.brite` 仍只保存网络拓扑,不混入碳数据。 +- 物理 Host 使用 `PowerModelHostSimple`;Host 空闲功耗只在站点层计算一次,任务只分摊其增量功耗。 +- 心跳上报瞬时功率、增量能耗、站点碳强度和信号时间戳。 +- 任务结果分别上报计算碳、网络碳和总运行碳,`carbon_scope` 固定为 `operational_only`。 +- 合成碳轨迹用于可重复主实验,不能当作真实电网历史数据。 diff --git a/examples/cloudsimplus/src/main/java/org/cloudsimplus/examples/HuaweiDciTianjunExperiment.java b/examples/cloudsimplus/src/main/java/org/cloudsimplus/examples/HuaweiDciTianjunExperiment.java index 4f5dad8..a3ab9df 100644 --- a/examples/cloudsimplus/src/main/java/org/cloudsimplus/examples/HuaweiDciTianjunExperiment.java +++ b/examples/cloudsimplus/src/main/java/org/cloudsimplus/examples/HuaweiDciTianjunExperiment.java @@ -13,6 +13,7 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.annotations.SerializedName; import org.cloudsimplus.brokers.DatacenterBroker; import org.cloudsimplus.brokers.DatacenterBrokerSimple; import org.cloudsimplus.builders.tables.CloudletsTableBuilder; @@ -23,6 +24,7 @@ import org.cloudsimplus.datacenters.DatacenterSimple; import org.cloudsimplus.examples.tianjun.TianjunHttpBridge; import org.cloudsimplus.examples.tianjun.TianjunHttpBridge.LeaseResult; +import org.cloudsimplus.examples.tianjun.TianjunHttpBridge.BatchPlanResult; import org.cloudsimplus.examples.tianjun.TianjunHttpBridge.NetworkPath; import org.cloudsimplus.examples.tianjun.TianjunHttpBridge.SimNode; import org.cloudsimplus.examples.tianjun.TianjunHttpBridge.SimTask; @@ -32,6 +34,7 @@ import org.cloudsimplus.hosts.HostSimple; import org.cloudsimplus.listeners.EventInfo; import org.cloudsimplus.network.topologies.BriteNetworkTopology; +import org.cloudsimplus.power.models.PowerModelHostSimple; import org.cloudsimplus.resources.Pe; import org.cloudsimplus.resources.PeSimple; import org.cloudsimplus.utilizationmodels.UtilizationModelDynamic; @@ -39,7 +42,10 @@ import org.cloudsimplus.vms.VmSimple; import java.io.BufferedWriter; +import java.io.BufferedReader; import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; @@ -61,6 +67,8 @@ */ public final class HuaweiDciTianjunExperiment { private static final String TOPOLOGY_FILE = "huawei-dci-reference.brite"; + private static final String POWER_PROFILE_FILE = "tianjun-power-profiles.json"; + private static final String CARBON_TRACE_FILE = "tianjun-carbon-intensity-trace.csv"; private static final String DEFAULT_SERVER = "http://127.0.0.1:8024"; private static final String[] REGIONS = {"dc1", "dc2", "dc3"}; private static final String[] LOCATIONS = {"beijing", "hangzhou", "chengdu", "chongqing", "guangzhou", "shenzhen"}; @@ -74,6 +82,14 @@ public final class HuaweiDciTianjunExperiment { private static final double LOCAL_FABRIC_LATENCY_MS = 0.8; private static final double LOCAL_FABRIC_BANDWIDTH_MBPS = 25_000.0; private static final double DCI_BOTTLENECK_BANDWIDTH_MBPS = 10_000.0; + // Effective throughput used only by the control-plane preview contract. + // It is calibrated against the fixed CloudSim workload below; actual JCT + // and Makespan are always measured from completed Cloudlets. + private static final double PREDICTION_EFFECTIVE_MIPS = 4_000.0; + private static final double[] SITE_IDLE_POWER_W = {155.0, 148.0, 142.0}; + private static final double[] SITE_MAX_POWER_W = {430.0, 405.0, 390.0}; + private static final double[] SITE_PUE = {1.38, 1.28, 1.22}; + private static final double[] SITE_CARBON_G_PER_KWH = {560.0, 430.0, 310.0}; private final CloudSimPlus simulation; private final DatacenterBroker broker; @@ -94,8 +110,13 @@ public final class HuaweiDciTianjunExperiment { private final Gson gson; private final String disturbanceScenario; private final String experimentRunId; + private final String batchStrategy; + private final Map powerProfilesBySite; + private final Map carbonProfilesBySite; private final BufferedWriter snapshotWriter; + private final Path metricsOutputPath; private final boolean listenAfterBatch; + private String committedBatchId; private double lastHeartbeatTick = -1.0; public static void main(final String[] args) throws IOException { @@ -105,7 +126,8 @@ public static void main(final String[] args) throws IOException { final long seed = args.length > 3 ? Long.parseLong(args[3]) : DEFAULT_SEED; final Path output = Path.of(args.length > 4 ? args[4] : "output/huawei-dci-topology-snapshots.jsonl"); final boolean listenAfterBatch = args.length <= 5 || !"once".equalsIgnoreCase(args[5]); - new HuaweiDciTianjunExperiment(server, scenario, cloudlets, seed, output, listenAfterBatch).run(); + final String strategy = args.length > 6 ? args[6] : "B6-hierarchical-batch"; + new HuaweiDciTianjunExperiment(server, scenario, cloudlets, seed, output, listenAfterBatch, strategy).run(); } private HuaweiDciTianjunExperiment( @@ -114,15 +136,19 @@ private HuaweiDciTianjunExperiment( final int cloudletCount, final long seed, final Path outputPath, - final boolean listenAfterBatch + final boolean listenAfterBatch, + final String batchStrategy ) throws IOException { this.simulation = new CloudSimPlus(); this.bridge = new TianjunHttpBridge(server); this.listenAfterBatch = listenAfterBatch; this.disturbanceScenario = disturbanceScenario.toLowerCase(Locale.ROOT); this.experimentRunId = "dci-" + this.disturbanceScenario + "-" + seed; + this.batchStrategy = batchStrategy; this.random = new Random(seed); this.gson = new GsonBuilder().disableHtmlEscaping().create(); + this.powerProfilesBySite = loadPowerProfiles(); + this.carbonProfilesBySite = loadCarbonProfiles(); this.vmByNodeId = new LinkedHashMap<>(); this.nodeById = new LinkedHashMap<>(); this.taskByCloudletId = new LinkedHashMap<>(); @@ -141,6 +167,9 @@ private HuaweiDciTianjunExperiment( Files.createDirectories(parent); } this.snapshotWriter = Files.newBufferedWriter(outputPath.toAbsolutePath()); + this.metricsOutputPath = outputPath.toAbsolutePath().resolveSibling( + outputPath.getFileName().toString().replaceFirst("\\.[^.]+$", "") + ".metrics.json" + ); } private void run() throws IOException { @@ -152,7 +181,7 @@ private void run() throws IOException { broker.setDatacenterMapper((lastDatacenter, vm) -> datacenters.get(siteIndexForVm(vm))); broker.submitVmList(vmList); registerTianjunNodes(); - submitTasksToTianjun(); + submitBatchToTianjun(); pollLeasesAndSubmitCloudlets(0.0); simulation.addOnClockTickListener(this::onClockTick); @@ -163,6 +192,7 @@ private void run() throws IOException { sendHeartbeats(simulation.clock()); reportResults(); writeSnapshot(simulation.clock(), "finished"); + writeBatchMetrics(); snapshotWriter.close(); } @@ -190,6 +220,7 @@ private List createDatacenters() { private List createHosts(final int site) { final var hosts = new ArrayList(); + final PowerProfileConfig powerProfile = powerProfileForSite(site); for (int index = 0; index < 3; index++) { final int pes = 32; final long mips = site == 0 ? 2400L : site == 1 ? 2250L : 2200L; @@ -200,7 +231,12 @@ private List createHosts(final int site) { for (int pe = 0; pe < pes; pe++) { peList.add(new PeSimple(mips)); } - hosts.add(new HostSimple(ramMb, bandwidthMbps, storageMb, peList)); + final var host = new HostSimple(ramMb, bandwidthMbps, storageMb, peList); + host.setPowerModel(new PowerModelHostSimple( + powerProfile.maxPowerW, + powerProfile.idlePowerW + )); + hosts.add(host); } return hosts; } @@ -263,15 +299,37 @@ private void registerTianjunNodes() { System.out.printf("Registered physical DCI topology and %d attached compute nodes with topology-derived paths.%n", nodeById.size()); } - private void submitTasksToTianjun() { + private void submitBatchToTianjun() { for (int index = 0; index < cloudletList.size(); index++) { final Cloudlet cloudlet = cloudletList.get(index); final SimTask task = taskForCloudlet(cloudlet, index); taskByCloudletId.put(cloudlet.getId(), task); cloudletIdByTaskId.put(task.taskId(), cloudlet.getId()); - bridge.submitTask(task); } - System.out.printf("Submitted %d DCI tasks to Tianjun pending queue.%n", taskByCloudletId.size()); + final BatchPlanResult batchPlan = bridge.commitTaskBatch( + experimentRunId + "-batch", + "CloudSim DCI " + disturbanceScenario + " " + batchStrategy, + new ArrayList<>(taskByCloudletId.values()), + batchStrategy + ); + committedBatchId = batchPlan.batchId(); + System.out.printf( + "Imported and committed CloudSim batch %s using %s (plan %s, snapshot %d), tasks: %d.%n", + batchPlan.batchId(), + batchPlan.strategy(), + batchPlan.planId(), + batchPlan.resourceSnapshotVersion(), + taskByCloudletId.size() + ); + } + + private void writeBatchMetrics() throws IOException { + if (committedBatchId == null || committedBatchId.isBlank()) { + return; + } + final String metrics = bridge.getBatchActualMetrics(committedBatchId); + Files.writeString(metricsOutputPath, metrics, StandardCharsets.UTF_8); + System.out.printf("Wrote actual CloudSim batch metrics to %s.%n", metricsOutputPath); } private int pollLeasesAndSubmitCloudlets(final double tick) { @@ -362,6 +420,8 @@ private void executeExternalLease(final LeaseResult lease, final SimNode node, f reportExternalProgress(node.nodeId(), lease.taskId(), tick, "leased", 0.05, "Lease acquired by CloudSim listener."); reportExternalProgress(node.nodeId(), lease.taskId(), tick + duration * 0.45, "executing", 0.65, "CloudSim listener executing external task."); sleepQuietly(Math.min(450L, Math.max(120L, Math.round(duration * 120.0)))); + final double energyKwh = incrementalEnergyKwh(node, duration, 0.55); + final double computeCarbonG = energyKwh * node.pue() * node.carbonIntensityAt(tick); reportResultWithRetry(new SimTaskResult( node.nodeId(), lease.taskId(), @@ -370,7 +430,16 @@ private void executeExternalLease(final LeaseResult lease, final SimNode node, f "CloudSim listener completed externally submitted Tianjun task.", "", 0, - Math.min(lease.predictedCost(), duration * node.costPerTick()) + Math.min(lease.predictedCost(), duration * node.costPerTick()), + energyKwh, + computeCarbonG, + 0.0, + 0.0, + duration, + 0.55, + 0.30, + 0.20, + 0.0 )); } @@ -466,6 +535,26 @@ private void reportResults() { } reportProgress(cloudlet, simulation.clock(), "finished", 1.0, "CloudSim cloudlet finished; reporting final result."); final double duration = Math.max(1.0, cloudlet.getFinishTime() - cloudlet.getStartTime()); + final SimNode node = nodeById.get(nodeId); + final double sampleTime = Math.max(0.0, cloudlet.getStartTime() + duration / 2.0); + final double cpuUtilization = clamp( + cloudlet.getUtilizationModelCpu().getUtilization(sampleTime), + 0.0, + 1.0 + ); + final double memoryUtilization = clamp( + cloudlet.getUtilizationModelRam().getUtilization(sampleTime), + 0.0, + 1.0 + ); + final double bandwidthUtilization = clamp( + cloudlet.getUtilizationModelBw().getUtilization(sampleTime), + 0.0, + 1.0 + ); + final double energyKwh = incrementalEnergyKwh(node, duration, cpuUtilization); + final double computeCarbonG = energyKwh * node.pue() * node.carbonIntensityAt(cloudlet.getStartTime()); + final double networkCarbonG = networkCarbonG(task, node); final var result = new SimTaskResult( nodeId, task.taskId(), @@ -474,7 +563,16 @@ private void reportResults() { "Huawei-reference DCI CloudSim cloudlet completed.", "", cloudlet.isFinished() ? 0 : 1, - duration * nodeById.get(nodeId).costPerTick() + duration * node.costPerTick(), + energyKwh, + computeCarbonG, + networkCarbonG, + Math.max(0.0, cloudlet.getStartTime()), + Math.max(duration, cloudlet.getFinishTime()), + cpuUtilization, + memoryUtilization, + bandwidthUtilization, + task.storageGb() / Math.max(1.0, node.storageGb()) ); if (reportResultWithRetry(result)) { reportedResultCloudletIds.add(cloudlet.getId()); @@ -578,6 +676,8 @@ private SimNode simNodeForVm(final Vm vm, final int index) { final int locationIndex = index / VMS_PER_LOCATION; final int site = LOCATION_SITES[locationIndex]; final double gpuCount = gpuCapacityForNode(site, index); + final PowerProfileConfig powerProfile = powerProfileForSite(site); + final CarbonSiteConfig carbonProfile = carbonProfileForSite(site); return new SimNode( vm.getDescription(), REGIONS[site], @@ -585,17 +685,141 @@ private SimNode simNodeForVm(final Vm vm, final int index) { SERVICE_REGIONS[locationIndex], index, vm.getPesNumber(), + vm.getMips(), + vm.getPesNumber() * vm.getMips(), vm.getRam().getCapacity() / 1024.0, gpuCount, + gpuCount * 16.0, vm.getStorage().getCapacity() / 1024.0, + 18_000.0 + site * 2_000.0, vm.getBw().getCapacity(), 1.10 + site * 0.08 + (index % VMS_PER_LOCATION) * 0.04, 0.993 - site * 0.002, site == 0 ? 1.10 : 1.04, - site == 0 ? 0.06 : 0.09 + site == 0 ? 0.06 : 0.09, + "site-" + (site + 1), + powerProfile.profileId, + powerProfile.idlePowerW, + powerProfile.maxPowerW, + powerProfile.gpuIdlePowerW, + powerProfile.gpuMaxPowerW, + carbonProfile.pue, + carbonProfile.trace, + carbonProfile.trace.getOrDefault(0.0, SITE_CARBON_G_PER_KWH[site]) ); } + private Map loadPowerProfiles() throws IOException { + final Map result = new LinkedHashMap<>(); + try (final var stream = HuaweiDciTianjunExperiment.class.getClassLoader().getResourceAsStream(POWER_PROFILE_FILE)) { + if (stream == null) { + return result; + } + final var document = gson.fromJson( + new InputStreamReader(stream, StandardCharsets.UTF_8), + PowerProfileDocument.class + ); + if (document != null && document.profiles != null) { + for (int index = 0; index < document.profiles.size(); index++) { + result.put(index, document.profiles.get(index)); + } + } + } + return result; + } + + private Map loadCarbonProfiles() throws IOException { + final Map result = new LinkedHashMap<>(); + try (final var stream = HuaweiDciTianjunExperiment.class.getClassLoader().getResourceAsStream(CARBON_TRACE_FILE)) { + if (stream == null) { + return result; + } + try (final var reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { + String line; + boolean header = true; + while ((line = reader.readLine()) != null) { + if (header) { + header = false; + continue; + } + final String[] fields = line.trim().split(","); + if (fields.length < 5 || !fields[0].startsWith("site-")) { + continue; + } + final int site = Integer.parseInt(fields[0].substring("site-".length())) - 1; + final double tick = Double.parseDouble(fields[2]); + final double intensity = Double.parseDouble(fields[3]); + final double pue = Double.parseDouble(fields[4]); + result.computeIfAbsent(site, ignored -> new CarbonSiteConfig(pue)).trace.put(tick, intensity); + } + } + } + return result; + } + + private PowerProfileConfig powerProfileForSite(final int site) { + return powerProfilesBySite.getOrDefault( + site, + new PowerProfileConfig( + "host-profile-" + (site + 1), + SITE_IDLE_POWER_W[site], + SITE_MAX_POWER_W[site], + 35.0, + 300.0 + ) + ); + } + + private CarbonSiteConfig carbonProfileForSite(final int site) { + final CarbonSiteConfig configured = carbonProfilesBySite.get(site); + if (configured != null) { + return configured; + } + final CarbonSiteConfig fallback = new CarbonSiteConfig(SITE_PUE[site]); + fallback.trace.put(0.0, SITE_CARBON_G_PER_KWH[site]); + return fallback; + } + + private static final class PowerProfileDocument { + private List profiles; + } + + private static final class PowerProfileConfig { + @SerializedName("profile_id") + private String profileId; + @SerializedName("idle_power_w") + private double idlePowerW; + @SerializedName("max_power_w") + private double maxPowerW; + @SerializedName("gpu_idle_power_w") + private double gpuIdlePowerW; + @SerializedName("gpu_max_power_w") + private double gpuMaxPowerW; + + private PowerProfileConfig( + final String profileId, + final double idlePowerW, + final double maxPowerW, + final double gpuIdlePowerW, + final double gpuMaxPowerW + ) { + this.profileId = profileId; + this.idlePowerW = idlePowerW; + this.maxPowerW = maxPowerW; + this.gpuIdlePowerW = gpuIdlePowerW; + this.gpuMaxPowerW = gpuMaxPowerW; + } + } + + private static final class CarbonSiteConfig { + private final double pue; + private final Map trace = new LinkedHashMap<>(); + + private CarbonSiteConfig(final double pue) { + this.pue = pue; + } + } + private double gpuCapacityForNode(final int site, final int index) { final double siteBonus = site == 0 ? 1.0 : 0.0; final double anchorBonus = index % VMS_PER_LOCATION == 0 ? 1.0 : 0.0; @@ -609,10 +833,20 @@ private SimTask taskForCloudlet(final Cloudlet cloudlet, final int index) { experimentRunId + "-task-" + cloudlet.getId(), gpuAccelerated ? "inference" : index % 3 == 1 ? "analytics" : "batch_cpu", Math.max(1.0, cloudlet.getPesNumber()), + Math.max(1.0, cloudlet.getPesNumber()) * 2_000.0, 2.0 + index % 4 * 2.0, gpuAccelerated ? 1.0 : 0.0, + gpuAccelerated ? 8.0 : 0.0, Math.max(1.0, cloudlet.getFileSize() / 1024.0), - Math.max(1, (int) Math.ceil(cloudlet.getLength() / 8_000.0)), + 1_500.0 + index % 4 * 500.0, + Math.max( + 1, + (int) Math.ceil( + cloudlet.getLength() + / (PREDICTION_EFFECTIVE_MIPS + * Math.max(0.05, cloudlet.getUtilizationModelCpu().getUtilization(0.0))) + ) + ), 5 + index % 5, 220.0, 180, @@ -620,10 +854,30 @@ private SimTask taskForCloudlet(final Cloudlet cloudlet, final int index) { 0.5 + index % 4 * 0.4, sourceRegion.equals("dc1") ? 25.0 : sourceRegion.equals("dc2") ? 28.0 : 30.0, 500.0, - 0.55 + index % 4 * 0.10 + 0.55 + index % 4 * 0.10, + 12.0, + 0.65, + cloudlet.getUtilizationModelCpu().getUtilization(0.0), + true, + false, + 0, + experimentRunId + "-batch" ); } + private double incrementalEnergyKwh(final SimNode node, final double durationSeconds, final double utilization) { + final double incrementalPowerW = Math.max(0.0, node.maxPowerW() - node.idlePowerW()) * clamp(utilization, 0.0, 1.0); + return incrementalPowerW * durationSeconds / 3_600_000.0; + } + + private double networkCarbonG(final SimTask task, final SimNode node) { + if (task.sourceRegion().equals(node.region())) { + return 0.0; + } + final double networkEnergyKwh = task.inputSizeGb() * 0.00008; + return networkEnergyKwh * node.carbonIntensityAt(simulation.clock()); + } + private Map observedPaths(final SimNode node, final double tick, final double bandwidthUtilization) { final var paths = new LinkedHashMap(); for (final String sourceRegion : REGIONS) { diff --git a/examples/cloudsimplus/src/main/java/org/cloudsimplus/examples/tianjun/TianjunHttpBridge.java b/examples/cloudsimplus/src/main/java/org/cloudsimplus/examples/tianjun/TianjunHttpBridge.java index b295e99..f1258a8 100644 --- a/examples/cloudsimplus/src/main/java/org/cloudsimplus/examples/tianjun/TianjunHttpBridge.java +++ b/examples/cloudsimplus/src/main/java/org/cloudsimplus/examples/tianjun/TianjunHttpBridge.java @@ -8,8 +8,10 @@ import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Comparator; +import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -24,9 +26,13 @@ public class TianjunHttpBridge { private static final Pattern LEASE_TASK_PATTERN = Pattern.compile("\"lease\"\\s*:\\s*\\{\\s*\"task_id\"\\s*:\\s*\"([^\"]+)\""); private static final Pattern ESTIMATED_DURATION_PATTERN = Pattern.compile("\"estimated_duration\"\\s*:\\s*([0-9]+)"); private static final Pattern PREDICTED_COST_PATTERN = Pattern.compile("\"predicted_cost\"\\s*:\\s*([0-9.]+)"); + private static final Pattern BATCH_ID_PATTERN = Pattern.compile("\"batch_id\"\\s*:\\s*\"([^\"]+)\""); + private static final Pattern PLAN_ID_PATTERN = Pattern.compile("\"plan_id\"\\s*:\\s*\"([^\"]+)\""); + private static final Pattern SNAPSHOT_VERSION_PATTERN = Pattern.compile("\"resource_snapshot_version\"\\s*:\\s*([0-9]+)"); private final HttpClient client; private final String server; + private final Map lastHeartbeatTickByNode = new ConcurrentHashMap<>(); public TianjunHttpBridge(final String server) { this.server = stripTrailingSlash(server); @@ -137,10 +143,68 @@ public SchedulingResult commitSchedule(final SimTask task) { return new SchedulingResult(status, nodeId, leaseTaskId, score, response); } + public String getBatchActualMetrics(final String batchId) { + try { + return get("/task-batches/" + batchId + "/metrics"); + } catch (IOException | InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Unable to read Tianjun batch metrics", e); + } + } + public void submitTask(final SimTask task) { post("/tasks", taskJson(task)); } + /** Imports, jointly previews and atomically commits one CloudSim batch. */ + public BatchPlanResult commitTaskBatch( + final String clientBatchId, + final String batchName, + final List tasks, + final String strategy + ) { + final String imported = post("/task-batches/import", batchJson(clientBatchId, batchName, tasks)); + final String batchId = matchString(BATCH_ID_PATTERN, imported, ""); + if (batchId.isBlank()) { + throw new IllegalStateException("Batch import did not return batch_id: " + imported); + } + final boolean calibratedGreen = "B6-green-calibrated-v1".equalsIgnoreCase(strategy); + final boolean greenSingleObjective = "B6-green-single-v1".equalsIgnoreCase(strategy); + final boolean greenSlaDualObjective = "B6-green-sla-dual-v1".equalsIgnoreCase(strategy); + final boolean greenSla85DualObjective = "B6-green-sla-85-v1".equalsIgnoreCase(strategy); + final boolean greenSla90DualObjective = "B6-green-sla-90-v1".equalsIgnoreCase(strategy); + final String schedulerStrategy = calibratedGreen || greenSingleObjective || greenSlaDualObjective || greenSla85DualObjective || greenSla90DualObjective + ? "B6-hierarchical-batch" + : strategy; + final String groupWeights = calibratedGreen + ? ", \"group_weights\": {\"sla_quality\": 0.18, \"network_coordination\": 0.05, " + + "\"resource_efficiency\": 0.15, \"economic_cost\": 0.02, \"green_carbon\": 0.60}" + : greenSingleObjective + ? ", \"active_groups\": [\"green_carbon\"], \"group_weights\": {\"green_carbon\": 1.0}" + : greenSlaDualObjective + ? ", \"active_groups\": [\"green_carbon\", \"sla_quality\"], " + + "\"group_weights\": {\"green_carbon\": 0.70, \"sla_quality\": 0.30}" + : greenSla85DualObjective + ? ", \"active_groups\": [\"green_carbon\", \"sla_quality\"], " + + "\"group_weights\": {\"green_carbon\": 0.85, \"sla_quality\": 0.15}" + : greenSla90DualObjective + ? ", \"active_groups\": [\"green_carbon\", \"sla_quality\"], " + + "\"group_weights\": {\"green_carbon\": 0.90, \"sla_quality\": 0.10}" + : ""; + final String preview = post("/task-batches/" + batchId + "/preview", """ + {"strategy": "%s", "simulation_tick": 0%s} + """.formatted(escapeJson(schedulerStrategy), groupWeights)); + final String planId = matchString(PLAN_ID_PATTERN, preview, ""); + final int snapshotVersion = matchInt(SNAPSHOT_VERSION_PATTERN, preview, -1); + if (planId.isBlank() || snapshotVersion < 0) { + throw new IllegalStateException("Batch preview did not return a committable plan: " + preview); + } + final String committed = post("/task-batches/" + batchId + "/commit", """ + {"plan_id": "%s", "resource_snapshot_version": %d, "confirmed_by_user_button": true} + """.formatted(escapeJson(planId), snapshotVersion)); + return new BatchPlanResult(batchId, planId, snapshotVersion, strategy, committed); + } + public LeaseResult requestLease(final String nodeId) { final String response = post("/leases/next", """ {"node_id": "%s"} @@ -210,10 +274,13 @@ private String nodeRegistrationJson(final SimNode node, final String networkPath "region": "%s", "location": "%s", "service_region": "%s", + "site_id": "%s", "labels": ["cloudsim", "cpu", "gpu", "%s", "latency-sensitive"], - "capacity": {"cpu": %.4f, "memory": %.4f, "gpu": %.4f, "storage": %.4f}, + "capacity": {"cpu": %.4f, "memory": %.4f, "gpu": %.4f, "storage": %.4f, "mips": %.4f, "gpu_memory": %.4f, "storage_iops": %.4f, "bandwidth": %.4f}, "cost_per_tick": %.4f, "base_reliability": %.4f, + "power_profile": {"profile_id": "%s", "idle_power_w": %.4f, "max_power_w": %.4f, "gpu_idle_power_w": %.4f, "gpu_max_power_w": %.4f}, + "carbon_profile": {"site_id": "%s", "region": "%s", "pue": %.4f, "carbon_intensity_g_per_kwh": %.4f, "carbon_intensity_trace": %s, "carbon_signal_type": "synthetic_average", "timezone": "Asia/Shanghai", "source_version": "cloudsim-v1"}, "performance_factors": {"inference": %.4f, "batch_cpu": %.4f, "analytics": %.4f, "streaming": %.4f}, "network_paths": %s } @@ -222,13 +289,28 @@ private String nodeRegistrationJson(final SimNode node, final String networkPath node.region(), node.location(), node.serviceRegion(), + node.siteId(), node.region(), node.cpu(), node.memoryGb(), node.gpu(), node.storageGb(), + node.totalMips(), + node.gpuMemoryGb(), + node.storageIops(), + node.bandwidthMbps(), node.costPerTick(), node.reliability(), + node.powerProfileId(), + node.idlePowerW(), + node.maxPowerW(), + node.gpuIdlePowerW(), + node.gpuMaxPowerW(), + node.siteId(), + node.region(), + node.pue(), + node.carbonIntensityAt(0.0), + carbonIntensityTraceJson(node), node.performanceFactor(), node.performanceFactor(), node.performanceFactor(), @@ -266,6 +348,11 @@ private String heartbeatJson( final double loadPressure = clamp(cpuUtilization * 0.55 + ramUtilization * 0.25 + bandwidthUtilization * 0.20, 0.0, 1.0); final double health = clamp(0.96 - loadWave * 0.08 - loadPressure * 0.22 - node.risk() * 0.08, 0.45, 0.99); final double reliability = clamp(node.reliability() - node.risk() * 0.035 - loadPressure * 0.025, 0.45, 0.999); + final double powerW = node.idlePowerW() + (node.maxPowerW() - node.idlePowerW()) * loadPressure; + final double carbonIntensity = node.carbonIntensityAt(tick); + final Double previousTick = lastHeartbeatTickByNode.put(node.nodeId(), tick); + final double intervalSeconds = previousTick == null ? 0.0 : Math.max(0.0, tick - previousTick); + final double energyKwhDelta = powerW * intervalSeconds / 3_600_000.0; return """ { "node_id": "%s", @@ -280,8 +367,12 @@ private String heartbeatJson( "network_paths": %s, "sim_tick": %.4f, "simulated": true, - "telemetry": {"cpu_utilization": %.6f, "ram_utilization": %.6f, "bandwidth_utilization": %.6f}, - "reliability_score": %.4f + "telemetry": {"cpu_utilization": %.6f, "ram_utilization": %.6f, "bandwidth_utilization": %.6f, "heartbeat_interval_seconds": %.6f}, + "reliability_score": %.4f, + "power_w": %.4f, + "energy_kwh_delta": %.8f, + "carbon_intensity_g_per_kwh": %.4f, + "carbon_signal_timestamp": %.4f } """.formatted( node.nodeId(), @@ -300,7 +391,12 @@ private String heartbeatJson( clamp(cpuUtilization, 0.0, 1.0), clamp(ramUtilization, 0.0, 1.0), clamp(bandwidthUtilization, 0.0, 1.0), - reliability + intervalSeconds, + reliability, + powerW, + energyKwhDelta, + carbonIntensity, + tick ); } @@ -309,7 +405,7 @@ private String taskJson(final SimTask task) { { "task_id": "%s", "task_type": "%s", - "demand": {"cpu": %.4f, "memory": %.4f, "gpu": %.4f, "storage": %.4f}, + "demand": {"cpu": %.4f, "memory": %.4f, "gpu": %.4f, "storage": %.4f, "mips": %.4f, "gpu_memory": %.4f, "storage_iops": %.4f, "bandwidth": %.4f}, "estimated_duration": %d, "priority": %d, "budget": %.4f, @@ -320,6 +416,13 @@ private String taskJson(final SimTask task) { "max_latency_ms": %.4f, "min_bandwidth_mbps": %.4f, "network_sensitivity": %.4f, + "carbon_budget_g": %.6f, + "carbon_priority": %.4f, + "expected_cpu_utilization": %.6f, + "allow_region_shift": %s, + "allow_time_shift": %s, + "deferrable_until_tick": %d, + "batch_id": "%s", "preferred_labels": ["cloudsim"] } """.formatted( @@ -329,6 +432,10 @@ private String taskJson(final SimTask task) { task.memoryGb(), task.gpu(), task.storageGb(), + task.requiredMips(), + task.gpuMemoryGb(), + task.storageIops(), + task.minBandwidthMbps(), task.estimatedDuration(), task.priority(), task.budget(), @@ -338,7 +445,14 @@ private String taskJson(final SimTask task) { task.inputSizeGb(), task.maxLatencyMs(), task.minBandwidthMbps(), - task.networkSensitivity() + task.networkSensitivity(), + task.carbonBudgetG(), + task.carbonPriority(), + task.expectedCpuUtilization(), + task.allowRegionShift() ? "true" : "false", + task.allowTimeShift() ? "true" : "false", + task.deferrableUntilTick(), + escapeJson(task.batchId()) ); } @@ -352,7 +466,20 @@ private String resultJson(final SimTaskResult result) { "stdout": "%s", "stderr": "%s", "returncode": %d, - "cost": %.4f + "cost": %.4f, + "energy_kwh": %.8f, + "compute_carbon_g": %.6f, + "network_carbon_g": %.6f, + "operational_carbon_g": %.6f, + "carbon_scope": "operational_only", + "metadata": { + "queue_wait_seconds": %.6f, + "jct_seconds": %.6f, + "cpu_utilization": %.6f, + "memory_utilization": %.6f, + "bandwidth_utilization": %.6f, + "storage_utilization": %.6f + } } """.formatted( result.nodeId(), @@ -362,10 +489,48 @@ private String resultJson(final SimTaskResult result) { escapeJson(result.stdout()), escapeJson(result.stderr()), result.returnCode(), - result.cost() + result.cost(), + result.energyKwh(), + result.computeCarbonG(), + result.networkCarbonG(), + result.computeCarbonG() + result.networkCarbonG(), + result.queueWaitSeconds(), + result.jctSeconds(), + result.cpuUtilization(), + result.memoryUtilization(), + result.bandwidthUtilization(), + result.storageUtilization() ); } + private String carbonIntensityTraceJson(final SimNode node) { + final StringBuilder builder = new StringBuilder("{"); + node.carbonIntensityTrace().entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(entry -> { + if (builder.length() > 1) { + builder.append(','); + } + builder.append('\"') + .append((int) Math.round(entry.getKey())) + .append("\":") + .append(String.format(Locale.ROOT, "%.4f", entry.getValue())); + }); + return builder.append('}').toString(); + } + + private String batchJson(final String clientBatchId, final String batchName, final List tasks) { + final String taskPayloads = tasks.stream().map(this::taskJson).collect(java.util.stream.Collectors.joining(",")); + return """ + { + "client_batch_id": "%s", + "batch_name": "%s", + "batch_preferences": {"optimization_profile": "cloudsim_validation"}, + "tasks": [%s] + } + """.formatted(escapeJson(clientBatchId), escapeJson(batchName), taskPayloads); + } + private String progressJson(final SimTaskProgress progress) { return """ { @@ -501,24 +666,90 @@ public record SimNode( String serviceRegion, int index, double cpu, + double mipsPerPe, + double totalMips, double memoryGb, double gpu, + double gpuMemoryGb, double storageGb, + double storageIops, double bandwidthMbps, double costPerTick, double reliability, double performanceFactor, - double risk + double risk, + String siteId, + String powerProfileId, + double idlePowerW, + double maxPowerW, + double gpuIdlePowerW, + double gpuMaxPowerW, + double pue, + Map carbonIntensityTrace, + double baseCarbonIntensityGPerKwh ) { + /** Backward-compatible constructor for the original four-resource example. */ + public SimNode( + final String nodeId, + final String region, + final String location, + final String serviceRegion, + final int index, + final double cpu, + final double memoryGb, + final double gpu, + final double storageGb, + final double bandwidthMbps, + final double costPerTick, + final double reliability, + final double performanceFactor, + final double risk + ) { + this( + nodeId, region, location, serviceRegion, index, cpu, 0.0, 0.0, + memoryGb, gpu, gpu * 16.0, storageGb, 0.0, bandwidthMbps, + costPerTick, reliability, performanceFactor, risk, + region + "-site", "legacy-power-profile", 0.0, 0.0, 0.0, 0.0, + 1.0, Map.of(), 0.0 + ); + } + + public double carbonIntensityAt(final double tick) { + if (carbonIntensityTrace != null && !carbonIntensityTrace.isEmpty()) { + final double dailyTick = Math.max(0.0, tick) % 24.0; + double selectedTick = -1.0; + double selectedValue = baseCarbonIntensityGPerKwh; + for (final var entry : carbonIntensityTrace.entrySet()) { + if (entry.getKey() <= dailyTick && entry.getKey() >= selectedTick) { + selectedTick = entry.getKey(); + selectedValue = entry.getValue(); + } + } + if (selectedTick < 0.0) { + for (final var entry : carbonIntensityTrace.entrySet()) { + if (entry.getKey() > selectedTick) { + selectedTick = entry.getKey(); + selectedValue = entry.getValue(); + } + } + } + return Math.max(0.0, selectedValue); + } + final double diurnal = 1.0 + 0.18 * Math.sin((tick + index * 3.0) * Math.PI / 12.0); + return Math.max(40.0, baseCarbonIntensityGPerKwh * diurnal); + } } public record SimTask( String taskId, String taskType, double cpu, + double requiredMips, double memoryGb, double gpu, + double gpuMemoryGb, double storageGb, + double storageIops, int estimatedDuration, int priority, double budget, @@ -527,8 +758,40 @@ public record SimTask( double inputSizeGb, double maxLatencyMs, double minBandwidthMbps, - double networkSensitivity + double networkSensitivity, + double carbonBudgetG, + double carbonPriority, + double expectedCpuUtilization, + boolean allowRegionShift, + boolean allowTimeShift, + int deferrableUntilTick, + String batchId ) { + /** Backward-compatible constructor for pre-batch CloudSim tasks. */ + public SimTask( + final String taskId, + final String taskType, + final double cpu, + final double memoryGb, + final double gpu, + final double storageGb, + final int estimatedDuration, + final int priority, + final double budget, + final int deadline, + final String sourceRegion, + final double inputSizeGb, + final double maxLatencyMs, + final double minBandwidthMbps, + final double networkSensitivity + ) { + this( + taskId, taskType, cpu, 0.0, memoryGb, gpu, gpu * 16.0, + storageGb, 0.0, estimatedDuration, priority, budget, deadline, + sourceRegion, inputSizeGb, maxLatencyMs, minBandwidthMbps, + networkSensitivity, 1_000_000.0, 0.5, 0.5, true, false, 0, "" + ); + } } public record NetworkPath( @@ -554,6 +817,15 @@ public boolean hasDecision() { public record LeaseResult(String taskId, String nodeId, String rawJson, int estimatedDuration, double predictedCost) { } + public record BatchPlanResult( + String batchId, + String planId, + int resourceSnapshotVersion, + String strategy, + String rawJson + ) { + } + public record SimTaskProgress( String nodeId, String taskId, @@ -577,7 +849,32 @@ public record SimTaskResult( String stdout, String stderr, int returnCode, - double cost + double cost, + double energyKwh, + double computeCarbonG, + double networkCarbonG, + double queueWaitSeconds, + double jctSeconds, + double cpuUtilization, + double memoryUtilization, + double bandwidthUtilization, + double storageUtilization ) { + /** Backward-compatible constructor when execution telemetry is unavailable. */ + public SimTaskResult( + final String nodeId, + final String taskId, + final boolean success, + final double durationSeconds, + final String stdout, + final String stderr, + final int returnCode, + final double cost + ) { + this( + nodeId, taskId, success, durationSeconds, stdout, stderr, returnCode, cost, + 0.0, 0.0, 0.0, 0.0, durationSeconds, 0.0, 0.0, 0.0, 0.0 + ); + } } } diff --git a/examples/cloudsimplus/src/main/resources/tianjun-carbon-intensity-trace.csv b/examples/cloudsimplus/src/main/resources/tianjun-carbon-intensity-trace.csv new file mode 100644 index 0000000..6fd5da7 --- /dev/null +++ b/examples/cloudsimplus/src/main/resources/tianjun-carbon-intensity-trace.csv @@ -0,0 +1,13 @@ +site_id,region,tick,carbon_intensity_g_per_kwh,pue,signal_type,timezone,source_version +site-1,dc1,0,560.0,1.38,synthetic_average,Asia/Shanghai,cloudsim-v1 +site-1,dc1,6,650.0,1.38,synthetic_average,Asia/Shanghai,cloudsim-v1 +site-1,dc1,12,520.0,1.38,synthetic_average,Asia/Shanghai,cloudsim-v1 +site-1,dc1,18,465.0,1.38,synthetic_average,Asia/Shanghai,cloudsim-v1 +site-2,dc2,0,430.0,1.28,synthetic_average,Asia/Shanghai,cloudsim-v1 +site-2,dc2,6,490.0,1.28,synthetic_average,Asia/Shanghai,cloudsim-v1 +site-2,dc2,12,400.0,1.28,synthetic_average,Asia/Shanghai,cloudsim-v1 +site-2,dc2,18,365.0,1.28,synthetic_average,Asia/Shanghai,cloudsim-v1 +site-3,dc3,0,310.0,1.22,synthetic_average,Asia/Shanghai,cloudsim-v1 +site-3,dc3,6,355.0,1.22,synthetic_average,Asia/Shanghai,cloudsim-v1 +site-3,dc3,12,280.0,1.22,synthetic_average,Asia/Shanghai,cloudsim-v1 +site-3,dc3,18,250.0,1.22,synthetic_average,Asia/Shanghai,cloudsim-v1 diff --git a/examples/cloudsimplus/src/main/resources/tianjun-power-profiles.json b/examples/cloudsimplus/src/main/resources/tianjun-power-profiles.json new file mode 100644 index 0000000..ef03deb --- /dev/null +++ b/examples/cloudsimplus/src/main/resources/tianjun-power-profiles.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1.0", + "profiles": [ + {"profile_id": "host-profile-1", "idle_power_w": 155.0, "max_power_w": 430.0, "gpu_idle_power_w": 35.0, "gpu_max_power_w": 300.0, "power_curve": "linear_incremental"}, + {"profile_id": "host-profile-2", "idle_power_w": 148.0, "max_power_w": 405.0, "gpu_idle_power_w": 35.0, "gpu_max_power_w": 300.0, "power_curve": "linear_incremental"}, + {"profile_id": "host-profile-3", "idle_power_w": 142.0, "max_power_w": 390.0, "gpu_idle_power_w": 35.0, "gpu_max_power_w": 300.0, "power_curve": "linear_incremental"} + ], + "accounting": "Host idle power is counted once; tasks receive incremental power only." +} diff --git a/pyproject.toml b/pyproject.toml index 9ab28cd..e71d1dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ ml-train = [ "numpy>=1.26,<3", "torch>=2.1,<3", ] +experiments = ["numpy>=1.26,<3", "scipy>=1.12,<2"] [project.scripts] tianjun = "tianjun.cli:main" diff --git a/scripts/analyze_cloudsim_core.py b/scripts/analyze_cloudsim_core.py new file mode 100644 index 0000000..ee163b1 --- /dev/null +++ b/scripts/analyze_cloudsim_core.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import argparse +import csv +import json +import math +from collections import defaultdict +from pathlib import Path +from statistics import mean, stdev +from typing import Any + + +METRICS = ( + "actual_acceptance_rate", + "actual_completion_rate", + "average_jct_seconds", + "p95_jct_seconds", + "makespan_seconds", + "average_cpu_utilization", + "average_memory_utilization", + "average_bandwidth_utilization", + "total_energy_kwh", + "total_operational_carbon_g", + "sla_violation_count", + "predicted_operational_carbon_g", + "carbon_prediction_error_g", + "carbon_prediction_error_percent", +) + +T_CRITICAL_95 = { + 1: 12.706, 2: 4.303, 3: 3.182, 4: 2.776, 5: 2.571, + 6: 2.447, 7: 2.365, 8: 2.306, 9: 2.262, 10: 2.228, + 11: 2.201, 12: 2.179, 13: 2.160, 14: 2.145, 15: 2.131, + 16: 2.120, 17: 2.110, 18: 2.101, 19: 2.093, 20: 2.086, + 21: 2.080, 22: 2.074, 23: 2.069, 24: 2.064, 25: 2.060, + 26: 2.056, 27: 2.052, 28: 2.048, 29: 2.045, 30: 2.042, +} + + +def confidence_interval(values: list[float]) -> tuple[float, float, float]: + center = mean(values) if values else 0.0 + if len(values) < 2: + return center, center, center + critical = T_CRITICAL_95.get(len(values) - 1, 1.96) + margin = critical * stdev(values) / math.sqrt(len(values)) + return center, center - margin, center + margin + + +def load_rows(root: Path) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for path in sorted(root.rglob("*.metrics.json")): + payload = json.loads(path.read_text(encoding="utf-8")) + relative = path.relative_to(root) + parts = relative.parts + if len(parts) < 4: + continue + prediction = payload.get("prediction") or {} + predicted_carbon = float(prediction.get("operational_carbon_g", 0.0)) + actual_carbon = float(payload.get("total_operational_carbon_g", 0.0)) + rows.append({ + "strategy": parts[0], + "scenario": parts[1], + "seed": parts[2].removeprefix("seed-"), + "source": str(relative), + **{ + metric: float(payload.get(metric, 0.0)) + for metric in METRICS + if metric not in { + "predicted_operational_carbon_g", + "carbon_prediction_error_g", + "carbon_prediction_error_percent", + } + }, + "predicted_operational_carbon_g": predicted_carbon, + "carbon_prediction_error_g": actual_carbon - predicted_carbon, + "carbon_prediction_error_percent": ( + (actual_carbon - predicted_carbon) / predicted_carbon * 100.0 + if predicted_carbon + else 0.0 + ), + "decision_time_ms": float(prediction.get("decision_time_ms", 0.0)), + "future_fit_before": float(prediction.get("future_fit_before", 0.0)), + "future_fit_after": float(prediction.get("future_fit_after", 0.0)), + }) + return rows + + +def write_raw(rows: list[dict[str, Any]], root: Path) -> None: + fields = list(rows[0]) if rows else ["strategy", "scenario", "seed"] + with (root / "raw_metrics.csv").open("w", newline="", encoding="utf-8-sig") as handle: + writer = csv.DictWriter(handle, fieldnames=fields) + writer.writeheader() + writer.writerows(rows) + + +def summarize(rows: list[dict[str, Any]], root: Path) -> list[dict[str, Any]]: + groups: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list) + for row in rows: + groups[(row["strategy"], row["scenario"])].append(row) + summary: list[dict[str, Any]] = [] + for (strategy, scenario), items in sorted(groups.items()): + result: dict[str, Any] = {"strategy": strategy, "scenario": scenario, "n": len(items)} + for metric in (*METRICS, "decision_time_ms", "future_fit_before", "future_fit_after"): + center, lower, upper = confidence_interval([float(item[metric]) for item in items]) + result[f"{metric}_mean"] = center + result[f"{metric}_ci95_low"] = lower + result[f"{metric}_ci95_high"] = upper + summary.append(result) + fields = list(summary[0]) if summary else ["strategy", "scenario", "n"] + with (root / "summary.csv").open("w", newline="", encoding="utf-8-sig") as handle: + writer = csv.DictWriter(handle, fieldnames=fields) + writer.writeheader() + writer.writerows(summary) + return summary + + +def paired_effects(rows: list[dict[str, Any]], root: Path) -> list[dict[str, Any]]: + indexed = {(row["strategy"], row["scenario"], row["seed"]): row for row in rows} + effects: list[dict[str, Any]] = [] + strategies = sorted({row["strategy"] for row in rows if row["strategy"] != "B0-current"}) + scenarios = sorted({row["scenario"] for row in rows}) + for strategy in strategies: + for scenario in scenarios: + seeds = sorted({row["seed"] for row in rows if row["strategy"] == strategy and row["scenario"] == scenario}) + for metric in ("average_jct_seconds", "makespan_seconds", "total_energy_kwh", "total_operational_carbon_g", "actual_acceptance_rate"): + deltas: list[float] = [] + relative: list[float] = [] + for seed in seeds: + baseline = indexed.get(("B0-current", scenario, seed)) + candidate = indexed.get((strategy, scenario, seed)) + if baseline is None or candidate is None: + continue + delta = float(candidate[metric]) - float(baseline[metric]) + deltas.append(delta) + if float(baseline[metric]) != 0.0: + relative.append(delta / float(baseline[metric]) * 100.0) + center, low, high = confidence_interval(deltas) + effects.append({ + "strategy": strategy, + "scenario": scenario, + "metric": metric, + "paired_n": len(deltas), + "mean_delta": center, + "delta_ci95_low": low, + "delta_ci95_high": high, + "mean_relative_change_percent": mean(relative) if relative else 0.0, + }) + fields = list(effects[0]) if effects else ["strategy", "scenario", "metric", "paired_n"] + with (root / "paired_effects.csv").open("w", newline="", encoding="utf-8-sig") as handle: + writer = csv.DictWriter(handle, fieldnames=fields) + writer.writeheader() + writer.writerows(effects) + return effects + + +def write_markdown(summary: list[dict[str, Any]], effects: list[dict[str, Any]], root: Path) -> None: + lines = [ + "# CloudSim 核心策略实验统计", + "", + "> 数据来自 Cloudlet 实际执行回传;区间为按随机种子计算的 Student-t 95% 置信区间。碳口径为 operational_only。", + "", + "| 策略 | 场景 | n | 接纳率 | 平均 JCT(s) | Makespan(s) | 预测碳(g) | 实际运行碳(g) | 碳预测误差 | 决策时间(ms) |", + "|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|", + ] + for row in summary: + lines.append( + "| {strategy} | {scenario} | {n} | {acceptance:.4f} | {jct:.4f} | {makespan:.4f} | {predicted_carbon:.4f} | {carbon:.4f} | {carbon_error:.2f}% | {decision:.3f} |".format( + strategy=row["strategy"], scenario=row["scenario"], n=row["n"], + acceptance=row["actual_acceptance_rate_mean"], jct=row["average_jct_seconds_mean"], + makespan=row["makespan_seconds_mean"], + predicted_carbon=row["predicted_operational_carbon_g_mean"], + carbon=row["total_operational_carbon_g_mean"], decision=row["decision_time_ms_mean"], + carbon_error=row["carbon_prediction_error_percent_mean"], + ) + ) + lines.extend(["", "## 相对 B0 的配对效应", "", "| 策略 | 场景 | 指标 | 配对数 | 平均差值 | 95% CI | 相对变化 |", "|---|---|---|---:|---:|---:|---:|"]) + for row in effects: + lines.append( + f"| {row['strategy']} | {row['scenario']} | {row['metric']} | {row['paired_n']} | " + f"{row['mean_delta']:.6f} | [{row['delta_ci95_low']:.6f}, {row['delta_ci95_high']:.6f}] | " + f"{row['mean_relative_change_percent']:.2f}% |" + ) + (root / "summary.md").write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--input", type=Path, default=Path("exp_out/cloudsim_core")) + args = parser.parse_args() + root = args.input.resolve() + root.mkdir(parents=True, exist_ok=True) + rows = load_rows(root) + if not rows: + raise SystemExit(f"No *.metrics.json files found under {root}") + write_raw(rows, root) + summary = summarize(rows, root) + effects = paired_effects(rows, root) + write_markdown(summary, effects, root) + print(f"analyzed {len(rows)} CloudSim runs; wrote statistics to {root}") + + +if __name__ == "__main__": + main() diff --git a/scripts/calibrate_b6_weights.py b/scripts/calibrate_b6_weights.py new file mode 100644 index 0000000..b6ffbed --- /dev/null +++ b/scripts/calibrate_b6_weights.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import argparse +import json +from collections import defaultdict +from pathlib import Path +from statistics import fmean +from typing import Any + + +def case_key(row: dict[str, Any]) -> tuple[Any, ...]: + return ( + int(row["seed"]), + int(row["node_count"]), + int(row["batch_task_count"]), + float(row["load_rate"]), + str(row["workload"]), + str(row.get("fragmentation_mode", "uniform")), + ) + + +def safe_relative(candidate: float, baseline: float) -> float: + return (candidate - baseline) / baseline if baseline else 0.0 + + +def profile_samples(rows: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: + baselines = { + case_key(row): row + for row in rows + if row.get("experiment_label") == "B0-current" + } + grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in rows: + if row.get("objective_scope") != "weight_calibration": + continue + baseline = baselines.get(case_key(row)) + if baseline is None: + continue + candidate_carbon_per_task = float( + row.get("predicted_carbon_g_per_assignment") + or float(row["predicted_carbon_g"]) / max(1, int(row["assigned_tasks"])) + ) + baseline_carbon_per_task = float( + baseline.get("predicted_carbon_g_per_assignment") + or float(baseline["predicted_carbon_g"]) / max(1, int(baseline["assigned_tasks"])) + ) + grouped[str(row["experiment_label"])].append({ + "seed": int(row["seed"]), + "carbon_reduction": -safe_relative( + candidate_carbon_per_task, baseline_carbon_per_task + ), + "acceptance_delta": float(row["acceptance_rate"]) - float(baseline["acceptance_rate"]), + "future_fit_delta": float(row["future_fit_after"]) - float(baseline["future_fit_after"]), + "makespan_change": safe_relative( + float(row["predicted_makespan"]), float(baseline["predicted_makespan"]) + ), + "decision_overhead": safe_relative( + float(row["decision_time_ms"]), float(baseline["decision_time_ms"]) + ), + "sla_delta": float(row["predicted_sla_violations"]) + - float(baseline["predicted_sla_violations"]), + "group_weights": dict(row.get("group_weights") or {}), + }) + return grouped + + +def aggregate(samples: list[dict[str, Any]]) -> dict[str, Any]: + if not samples: + return {} + result = { + key: fmean(float(item[key]) for item in samples) + for key in ( + "carbon_reduction", + "acceptance_delta", + "future_fit_delta", + "makespan_change", + "decision_overhead", + "sla_delta", + ) + } + result["sample_count"] = len(samples) + result["worst_acceptance_delta"] = min(float(item["acceptance_delta"]) for item in samples) + result["group_weights"] = dict(samples[0]["group_weights"]) + result["feasible"] = ( + result["acceptance_delta"] >= -0.02 + and result["worst_acceptance_delta"] >= -0.05 + and result["sla_delta"] <= 0.0 + ) + # All terms are dimensionless paired changes. Positive is better. + result["calibration_score"] = ( + 0.50 * result["carbon_reduction"] + + 0.25 * result["acceptance_delta"] + + 0.15 * result["future_fit_delta"] + - 0.07 * max(0.0, result["makespan_change"]) + - 0.03 * max(0.0, result["decision_overhead"]) + ) + return result + + +def calibrate( + rows: list[dict[str, Any]], + training_seeds: set[int], + validation_seeds: set[int], +) -> dict[str, Any]: + grouped = profile_samples(rows) + profiles: list[dict[str, Any]] = [] + for label, samples in sorted(grouped.items()): + training = aggregate([item for item in samples if item["seed"] in training_seeds]) + validation = aggregate([item for item in samples if item["seed"] in validation_seeds]) + profiles.append({"label": label, "training": training, "validation": validation}) + feasible = [item for item in profiles if item["training"].get("feasible")] + ranked = sorted( + feasible or profiles, + key=lambda item: float(item["training"].get("calibration_score", float("-inf"))), + reverse=True, + ) + selected = ranked[0] if ranked else None + return { + "selection_rule": { + "training_objective": "0.50*carbon_reduction_per_accepted_task + 0.25*acceptance_delta + 0.15*future_fit_delta - 0.07*makespan_regression - 0.03*decision_overhead", + "constraints": { + "mean_acceptance_delta_min": -0.02, + "worst_acceptance_delta_min": -0.05, + "mean_sla_delta_max": 0.0, + }, + "training_seeds": sorted(training_seeds), + "validation_seeds": sorted(validation_seeds), + }, + "selected_profile": selected, + "profiles": profiles, + } + + +def write_markdown(result: dict[str, Any], path: Path) -> None: + lines = [ + "# B6 绿色权重校准", + "", + "> 权重仅由训练随机种子选择,验证随机种子只用于报告泛化结果。正的碳变化表示减排。", + "", + "| 配置 | 训练可行 | 训练减碳 | 训练接纳差 | 训练Future-Fit差 | 验证减碳 | 验证接纳差 | 验证Future-Fit差 |", + "|---|---:|---:|---:|---:|---:|---:|---:|", + ] + for item in result.get("profiles", []): + train = item.get("training") or {} + valid = item.get("validation") or {} + lines.append( + f"| {item['label']} | {str(bool(train.get('feasible'))).lower()} | " + f"{float(train.get('carbon_reduction', 0.0)) * 100:.2f}% | " + f"{float(train.get('acceptance_delta', 0.0)) * 100:.2f}pp | " + f"{float(train.get('future_fit_delta', 0.0)):.4f} | " + f"{float(valid.get('carbon_reduction', 0.0)) * 100:.2f}% | " + f"{float(valid.get('acceptance_delta', 0.0)) * 100:.2f}pp | " + f"{float(valid.get('future_fit_delta', 0.0)):.4f} |" + ) + selected = result.get("selected_profile") + if selected: + lines.extend([ + "", + "## 推荐实验配置", + "", + f"`{selected['label']}`:`{json.dumps(selected['training'].get('group_weights') or {}, ensure_ascii=False)}`", + "", + "该配置仍属于实验配置;只有验证集同时满足接纳率、SLA和减碳要求后,才应进入 CloudSim 实际执行复核。", + ]) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Select B6 green intent weights on training seeds and report held-out validation.") + parser.add_argument("input", type=Path) + parser.add_argument("--config", type=Path, default=Path("configs/fragmentation_green_experiments.json")) + parser.add_argument("--output", type=Path, default=Path("exp_out/fragmentation_green/calibration.json")) + args = parser.parse_args() + rows = json.loads(args.input.read_text(encoding="utf-8")) + config = json.loads(args.config.read_text(encoding="utf-8")) + result = calibrate( + rows, + {int(item) for item in config.get("training_seeds") or []}, + {int(item) for item in config.get("validation_seeds") or []}, + ) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + write_markdown(result, args.output.with_suffix(".md")) + selected = result.get("selected_profile") or {} + print(f"selected {selected.get('label', 'none')}; wrote {args.output}") + + +if __name__ == "__main__": + main() diff --git a/scripts/report_green_validation.py b/scripts/report_green_validation.py new file mode 100644 index 0000000..670aaf1 --- /dev/null +++ b/scripts/report_green_validation.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import argparse +import csv +import math +from collections import defaultdict +from pathlib import Path +from statistics import fmean, stdev +from typing import Any + + +FORMAL_STRATEGIES = ( + "B0-current", + "B6-green-single-v1", + "B6-green-sla-85-v1", +) +METRICS = ( + "actual_acceptance_rate", + "average_jct_seconds", + "p95_jct_seconds", + "makespan_seconds", + "total_energy_kwh", + "total_operational_carbon_g", + "sla_violation_count", + "decision_time_ms", + "carbon_prediction_error_percent", +) +T_CRITICAL_95 = { + 1: 12.706, 2: 4.303, 3: 3.182, 4: 2.776, 5: 2.571, + 6: 2.447, 7: 2.365, 8: 2.306, 9: 2.262, 10: 2.228, +} + + +def interval(values: list[float]) -> tuple[float, float, float]: + center = fmean(values) if values else 0.0 + if len(values) < 2: + return center, center, center + margin = T_CRITICAL_95.get(len(values) - 1, 1.96) * stdev(values) / math.sqrt(len(values)) + return center, center - margin, center + margin + + +def read_rows(path: Path) -> list[dict[str, Any]]: + with path.open(encoding="utf-8-sig", newline="") as handle: + return [dict(row) for row in csv.DictReader(handle)] + + +def write_csv(rows: list[dict[str, Any]], path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8-sig", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=list(rows[0]) if rows else []) + writer.writeheader() + writer.writerows(rows) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Create the formal B0 vs green single/dual CloudSim report.") + parser.add_argument("--input", type=Path, default=Path("exp_out/cloudsim_core/raw_metrics.csv")) + parser.add_argument("--output", type=Path, default=Path("exp_out/cloudsim_green_validation")) + args = parser.parse_args() + rows = [row for row in read_rows(args.input) if row["strategy"] in FORMAL_STRATEGIES] + grouped: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list) + for row in rows: + grouped[(row["strategy"], row["scenario"])].append(row) + + summary: list[dict[str, Any]] = [] + for (strategy, scenario), samples in sorted(grouped.items()): + item: dict[str, Any] = {"strategy": strategy, "scenario": scenario, "n": len(samples)} + for metric in METRICS: + center, low, high = interval([float(sample[metric]) for sample in samples]) + item[f"{metric}_mean"] = center + item[f"{metric}_ci95_low"] = low + item[f"{metric}_ci95_high"] = high + summary.append(item) + + indexed = {(row["strategy"], row["scenario"], row["seed"]): row for row in rows} + effects: list[dict[str, Any]] = [] + for strategy in FORMAL_STRATEGIES[1:]: + for scenario in sorted({row["scenario"] for row in rows}): + seeds = sorted(row["seed"] for row in rows if row["strategy"] == strategy and row["scenario"] == scenario) + for metric in ("average_jct_seconds", "p95_jct_seconds", "makespan_seconds", "total_operational_carbon_g"): + deltas: list[float] = [] + relatives: list[float] = [] + for seed in seeds: + baseline = indexed.get(("B0-current", scenario, seed)) + candidate = indexed.get((strategy, scenario, seed)) + if baseline is None or candidate is None: + continue + base = float(baseline[metric]) + delta = float(candidate[metric]) - base + deltas.append(delta) + relatives.append(delta / base * 100.0 if base else 0.0) + center, low, high = interval(deltas) + effects.append({ + "strategy": strategy, + "scenario": scenario, + "metric": metric, + "paired_n": len(deltas), + "mean_delta": center, + "delta_ci95_low": low, + "delta_ci95_high": high, + "mean_relative_change_percent": fmean(relatives) if relatives else 0.0, + }) + + args.output.mkdir(parents=True, exist_ok=True) + write_csv(summary, args.output / "summary.csv") + write_csv(effects, args.output / "paired_effects.csv") + lines = [ + "# CloudSim 绿色单目标与双目标正式验证", + "", + "> 每个场景 10 个相同随机种子;指标来自 Cloudlet 实际执行,区间使用 Student-t 95% 置信区间。", + "", + "| 策略 | 场景 | n | 平均JCT(s) | P95 JCT(s) | Makespan(s) | 运行碳(g) | SLA违规 | 碳预测误差 | 决策时间(ms) |", + "|---|---|---:|---:|---:|---:|---:|---:|---:|---:|", + ] + for row in summary: + lines.append( + f"| {row['strategy']} | {row['scenario']} | {row['n']} | " + f"{row['average_jct_seconds_mean']:.4f} | {row['p95_jct_seconds_mean']:.4f} | " + f"{row['makespan_seconds_mean']:.4f} | {row['total_operational_carbon_g_mean']:.4f} | " + f"{row['sla_violation_count_mean']:.2f} | " + f"{row['carbon_prediction_error_percent_mean']:.2f}% | {row['decision_time_ms_mean']:.2f} |" + ) + lines.extend([ + "", + "## 相对 B0 的配对效应", + "", + "| 策略 | 场景 | 指标 | n | 相对变化 | 差值95% CI |", + "|---|---|---|---:|---:|---:|", + ]) + for row in effects: + lines.append( + f"| {row['strategy']} | {row['scenario']} | {row['metric']} | {row['paired_n']} | " + f"{row['mean_relative_change_percent']:.2f}% | " + f"[{row['delta_ci95_low']:.6f}, {row['delta_ci95_high']:.6f}] |" + ) + (args.output / "summary.md").write_text("\n".join(lines) + "\n", encoding="utf-8") + print(f"wrote formal green validation report for {len(rows)} runs to {args.output.resolve()}") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_cloudsim_core_experiments.ps1 b/scripts/run_cloudsim_core_experiments.ps1 new file mode 100644 index 0000000..c7af21b --- /dev/null +++ b/scripts/run_cloudsim_core_experiments.ps1 @@ -0,0 +1,137 @@ +param( + [string]$CloudSimProject = "D:\Download from Github\cloudsimplus-examples", + [string]$Config = "configs\cloudsim_core_experiments.json", + [string]$Python = "python", + [int]$BasePort = 8124, + [int]$MaxRuns = 0, + [string[]]$StrategyFilter = @(), + [string[]]$ScenarioFilter = @(), + [int]$SeedLimit = 0, + [switch]$Resume, + [switch]$SkipSourceSync +) + +$ErrorActionPreference = "Stop" +$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$configPath = (Resolve-Path (Join-Path $projectRoot $Config)).Path +$settings = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json +$outputRoot = Join-Path $projectRoot $settings.output_directory +New-Item -ItemType Directory -Path $outputRoot -Force | Out-Null + +if (-not (Test-Path -LiteralPath (Join-Path $CloudSimProject "pom.xml"))) { + throw "CloudSim Plus Examples project not found: $CloudSimProject" +} + +if (-not $SkipSourceSync) { + $archiveRoot = Join-Path $projectRoot "examples\cloudsimplus\src\main" + $javaTargets = @( + "java\org\cloudsimplus\examples\HuaweiDciTianjunExperiment.java", + "java\org\cloudsimplus\examples\tianjun\TianjunHttpBridge.java" + ) + $resourceTargets = @( + "resources\huawei-dci-reference.brite", + "resources\tianjun-power-profiles.json", + "resources\tianjun-carbon-intensity-trace.csv" + ) + foreach ($relative in @($javaTargets + $resourceTargets)) { + $source = Join-Path $archiveRoot $relative + $target = Join-Path (Join-Path $CloudSimProject "src\main") $relative + $targetParent = Split-Path -Parent $target + New-Item -ItemType Directory -Path $targetParent -Force | Out-Null + Copy-Item -LiteralPath $source -Destination $target -Force + } +} + +$runIndex = 0 +$strategies = @($settings.strategies) +if ($StrategyFilter.Count -gt 0) { + $strategies = @($strategies | Where-Object { $StrategyFilter -contains $_ }) +} +$seeds = @($settings.seeds) +if ($SeedLimit -gt 0) { + $seeds = @($seeds | Select-Object -First $SeedLimit) +} +$scenarios = @($settings.scenarios) +if ($ScenarioFilter.Count -gt 0) { + $scenarios = @($scenarios | Where-Object { $ScenarioFilter -contains $_ }) +} +foreach ($strategy in $strategies) { + foreach ($scenario in $scenarios) { + foreach ($seed in $seeds) { + if ($MaxRuns -gt 0 -and $runIndex -ge $MaxRuns) { break } + $port = $BasePort + $runIndex + $runDirectory = Join-Path $outputRoot (Join-Path $strategy (Join-Path $scenario ("seed-" + $seed))) + New-Item -ItemType Directory -Path $runDirectory -Force | Out-Null + $snapshot = Join-Path $runDirectory "topology-snapshots.jsonl" + $metricsOutput = [System.IO.Path]::ChangeExtension($snapshot, ".metrics.json") + if ($Resume -and (Test-Path -LiteralPath $metricsOutput)) { + $runIndex++ + Write-Host "resumed $runIndex : $strategy / $scenario / seed $seed" + continue + } + $stateDb = Join-Path $runDirectory "control-plane.sqlite3" + foreach ($stateArtifact in @($stateDb, "$stateDb-shm", "$stateDb-wal")) { + if (Test-Path -LiteralPath $stateArtifact) { + Remove-Item -LiteralPath $stateArtifact -Force + } + } + $serverOut = Join-Path $runDirectory "control-plane.stdout.log" + $serverErr = Join-Path $runDirectory "control-plane.stderr.log" + $mavenLog = Join-Path $runDirectory "cloudsim-maven.log" + $serverArgs = @( + "main.py", "serve", "--host", "127.0.0.1", "--port", "$port", + "--offline", "--state-db", $stateDb, "--heartbeat-timeout-seconds", "120" + ) + $serverProcess = Start-Process -FilePath $Python -ArgumentList $serverArgs ` + -WorkingDirectory $projectRoot -PassThru -WindowStyle Hidden ` + -RedirectStandardOutput $serverOut -RedirectStandardError $serverErr + try { + $healthy = $false + for ($attempt = 0; $attempt -lt 40; $attempt++) { + try { + $health = Invoke-RestMethod -Uri "http://127.0.0.1:$port/health" -TimeoutSec 1 + if ($health.status -eq "ok") { $healthy = $true; break } + } catch { + Start-Sleep -Milliseconds 250 + } + } + if (-not $healthy) { throw "Tianjun control plane did not become healthy on port $port" } + $execArgs = "http://127.0.0.1:$port $scenario $($settings.cloudlets) $seed `"$snapshot`" once $strategy" + Push-Location $CloudSimProject + try { + # CloudSim writes recoverable bridge warnings to stderr. Windows PowerShell + # converts redirected native stderr into ErrorRecord objects when Stop is active. + $previousErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + & mvn -q -DskipTests compile org.codehaus.mojo:exec-maven-plugin:3.5.0:java ` + "-Dexec.mainClass=org.cloudsimplus.examples.HuaweiDciTianjunExperiment" ` + "-Dexec.args=$execArgs" *> $mavenLog + $mavenExitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorActionPreference + } + if ($mavenExitCode -ne 0) { + Get-Content -LiteralPath $mavenLog -Tail 80 | Write-Host + throw "CloudSim Maven execution failed with exit code $mavenExitCode" + } + } finally { + Pop-Location + } + } finally { + if ($serverProcess -and -not $serverProcess.HasExited) { + Stop-Process -Id $serverProcess.Id -Force + $serverProcess.WaitForExit() + } + } + $runIndex++ + Write-Host "completed $runIndex : $strategy / $scenario / seed $seed" + } + if ($MaxRuns -gt 0 -and $runIndex -ge $MaxRuns) { break } + } + if ($MaxRuns -gt 0 -and $runIndex -ge $MaxRuns) { break } +} + +& $Python (Join-Path $projectRoot "scripts\analyze_cloudsim_core.py") --input $outputRoot +if ($LASTEXITCODE -ne 0) { throw "CloudSim statistical analysis failed" } +Write-Host "CloudSim core experiment outputs: $outputRoot" diff --git a/src/tianjun/application/batch_scheduling_service.py b/src/tianjun/application/batch_scheduling_service.py new file mode 100644 index 0000000..ce47886 --- /dev/null +++ b/src/tianjun/application/batch_scheduling_service.py @@ -0,0 +1,1078 @@ +from __future__ import annotations + +import copy +import csv +import hashlib +import io +import json +import time +from dataclasses import dataclass +from statistics import mean +from typing import TYPE_CHECKING, Any + +from ..domain import ( + BatchAssignment, + BatchSchedulingPlan, + BatchStatus, + BatchValidationIssue, + BatchValidationReport, + GROUP_KEYS, + METRIC_KEYS, + ReservationLedger, + ResourceSnapshot, + ResourceVector, + RunningTask, + Task, + TaskBatch, + TaskStatus, + UnassignedTask, + clamp, +) +from ..scenarios import task_from_dict +from ..experiments import AssignmentCandidate, milp_oracle, nsga2_assignments + +if TYPE_CHECKING: + from .control_plane import CentralControlPlane + + +MAX_BATCH_TASKS = 1000 +MAX_BATCH_BYTES = 5 * 1024 * 1024 +B6_LOCAL_SEARCH_TASK_LIMIT = 24 +B6_LOCAL_SEARCH_NODE_LIMIT = 4 +B6_FUTURE_FIT_SAMPLE_LIMIT = 32 +DEFAULT_STRATEGIES = [ + "B0-current", + "B1-batch-greedy", + "B3-batch-local-search", + "B4-pareto-tchebycheff", + "B6-hierarchical-batch", +] +NAMED_BATCH_PROFILES: dict[str, dict[str, Any]] = { + "B6-green-single-v1": { + "strategy": "B6-hierarchical-batch", + "active_groups": ["green_carbon"], + "group_weights": {"green_carbon": 1.0}, + }, + "B6-green-sla-85-v1": { + "strategy": "B6-hierarchical-batch", + "active_groups": ["green_carbon", "sla_quality"], + "group_weights": {"green_carbon": 0.85, "sla_quality": 0.15}, + }, +} + + +def _percentile(values: list[float], percentile: float) -> float: + if not values: + return 0.0 + ordered = sorted(float(value) for value in values) + if len(ordered) == 1: + return ordered[0] + position = max(0.0, min(1.0, percentile)) * (len(ordered) - 1) + lower = int(position) + upper = min(lower + 1, len(ordered) - 1) + fraction = position - lower + return ordered[lower] + fraction * (ordered[upper] - ordered[lower]) + + +class BatchRequestError(ValueError): + def __init__(self, status_code: int, payload: dict[str, Any]) -> None: + super().__init__(str(payload.get("error") or payload)) + self.status_code = status_code + self.payload = payload + + +@dataclass(slots=True) +class BatchSchedulingService: + control_plane: CentralControlPlane + + def import_json(self, payload: dict[str, Any]) -> dict[str, Any]: + raw_tasks = payload.get("tasks") + if not isinstance(raw_tasks, list): + raise BatchRequestError(400, {"error": "tasks must be a JSON array"}) + return self._import( + raw_tasks, + client_batch_id=str(payload.get("client_batch_id") or ""), + batch_name=str(payload.get("batch_name") or "未命名批次"), + defaults=dict(payload.get("defaults") or {}), + batch_preferences=dict(payload.get("batch_preferences") or {}), + ) + + def import_csv(self, text: str, *, batch_name: str = "CSV批次") -> dict[str, Any]: + if len(text.encode("utf-8")) > MAX_BATCH_BYTES: + raise BatchRequestError(413, {"error": "batch file exceeds 5MB"}) + try: + reader = csv.DictReader(io.StringIO(text.lstrip("\ufeff"))) + rows = list(reader) + except csv.Error as exc: + raise BatchRequestError(400, {"error": f"invalid CSV: {exc}"}) from exc + required = {"task_id", "task_type", "cpu", "memory", "gpu", "storage", "estimated_duration", "priority"} + missing = sorted(required - set(reader.fieldnames or [])) + if missing: + raise BatchRequestError(400, {"error": f"CSV missing required columns: {', '.join(missing)}"}) + raw_tasks: list[dict[str, Any]] = [] + csv_issues: list[BatchValidationIssue] = [] + for index, row in enumerate(rows, start=2): + try: + raw_tasks.append(self._csv_row(row, index)) + except BatchRequestError as exc: + for item in exc.payload.get("validation", {}).get("errors", []): + csv_issues.append(BatchValidationIssue( + int(item.get("row", index)), + str(item.get("field", "row")), + str(item.get("code", "INVALID_VALUE")), + str(item.get("message", "invalid CSV value")), + )) + if csv_issues: + raise BatchRequestError(422, {"error": "batch validation failed", "validation": BatchValidationReport(max(0, len(rows) - len({item.row for item in csv_issues})), errors=csv_issues).to_dict()}) + return self._import(raw_tasks, client_batch_id="", batch_name=batch_name, defaults={}, batch_preferences={}) + + def _import( + self, + raw_tasks: list[Any], + *, + client_batch_id: str, + batch_name: str, + defaults: dict[str, Any], + batch_preferences: dict[str, Any], + ) -> dict[str, Any]: + control = self.control_plane + with control.lock: + if not raw_tasks: + raise BatchRequestError(422, {"error": "batch must contain at least one task", "validation": BatchValidationReport(0).to_dict()}) + if len(raw_tasks) > MAX_BATCH_TASKS: + raise BatchRequestError(413, {"error": f"batch exceeds {MAX_BATCH_TASKS} tasks"}) + if client_batch_id and client_batch_id in control.batch_idempotency: + existing_id = control.batch_idempotency[client_batch_id] + existing = control.task_batches[existing_id] + return {**existing.to_dict(include_tasks=False), "idempotent_replay": True, "validation": BatchValidationReport(len(existing.tasks)).to_dict()} + + issues: list[BatchValidationIssue] = [] + normalized: list[dict[str, Any]] = [] + seen: set[str] = set() + existing_batch_task_ids = { + task.task_id + for existing_batch in control.task_batches.values() + for task in existing_batch.tasks + } + batch_intent = dict(batch_preferences.get("intent_weights") or {}) + for index, item in enumerate(raw_tasks, start=1): + if not isinstance(item, dict): + issues.append(BatchValidationIssue(index, "task", "INVALID_ROW", "task row must be an object")) + continue + task_payload = {**defaults, **item} + task_payload["batch_id"] = "pending" + task_payload["intent_weights"] = {**batch_intent, **dict(task_payload.get("intent_weights") or {})} + task_id = str(task_payload.get("task_id") or "").strip() + if not task_id: + issues.append(BatchValidationIssue(index, "task_id", "REQUIRED", "task_id is required")) + elif task_id in seen or task_id in control.tasks or task_id in existing_batch_task_ids: + issues.append(BatchValidationIssue(index, "task_id", "DUPLICATE_TASK_ID", f"task_id {task_id} already exists")) + seen.add(task_id) + for key in ("cpu", "memory", "gpu", "storage"): + value = dict(task_payload.get("demand") or {}).get(key, 0) + try: + if float(value) < 0: + raise ValueError + except (TypeError, ValueError): + issues.append(BatchValidationIssue(index, f"demand.{key}", "INVALID_RESOURCE", f"{key} must be non-negative")) + try: + if int(task_payload.get("estimated_duration", 0)) <= 0: + raise ValueError + except (TypeError, ValueError): + issues.append(BatchValidationIssue(index, "estimated_duration", "INVALID_DURATION", "estimated_duration must be positive")) + for key in ("carbon_priority",): + try: + value = float(task_payload.get(key, 0.0)) + if not 0.0 <= value <= 1.0: + raise ValueError + except (TypeError, ValueError): + issues.append(BatchValidationIssue(index, key, "OUT_OF_RANGE", f"{key} must be between 0 and 1")) + if str(task_payload.get("security_level", "medium")) not in {"low", "medium", "high"}: + issues.append(BatchValidationIssue(index, "security_level", "INVALID_SECURITY_LEVEL", "security_level must be low, medium or high")) + if task_payload.get("carbon_budget_g") is not None: + try: + if float(task_payload["carbon_budget_g"]) < 0: + raise ValueError + except (TypeError, ValueError): + issues.append(BatchValidationIssue(index, "carbon_budget_g", "INVALID_CARBON_BUDGET", "carbon_budget_g must be non-negative")) + normalized.append(task_payload) + + report = BatchValidationReport(max(0, len(raw_tasks) - len({item.row for item in issues})), errors=issues) + if issues: + raise BatchRequestError(422, {"error": "batch validation failed", "validation": report.to_dict()}) + + digest = hashlib.sha256(json.dumps(normalized, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest() + batch_id = f"batch-{int(time.time() * 1000)}-{digest[:8]}" + tasks: list[Task] = [] + created_tick = control.current_tick() + for payload in normalized: + payload["batch_id"] = batch_id + task = task_from_dict(payload) + task.submit_tick = created_tick + if task.deadline is not None and task.deadline <= created_tick: + task.deadline = created_tick + task.deadline + tasks.append(task) + client_id = client_batch_id or f"client-{int(time.time() * 1000)}-{digest[:8]}" + batch = TaskBatch( + batch_id=batch_id, + client_batch_id=client_id, + batch_name=batch_name, + tasks=tasks, + defaults=defaults, + batch_preferences=batch_preferences, + status=BatchStatus.VALIDATED, + content_hash=digest, + created_tick=created_tick, + ) + control.task_batches[batch_id] = batch + control.batch_idempotency[client_id] = batch_id + return {**batch.to_dict(include_tasks=False), "validation": BatchValidationReport(len(tasks)).to_dict()} + + def get_batch(self, batch_id: str) -> dict[str, Any]: + batch = self._batch(batch_id) + payload = batch.to_dict() + if batch.latest_plan_id and batch.latest_plan_id in self.control_plane.batch_plans: + payload["latest_plan"] = self.control_plane.batch_plans[batch.latest_plan_id].to_dict() + return payload + + def actual_metrics(self, batch_id: str) -> dict[str, Any]: + """Return measured execution outcomes, distinct from preview predictions.""" + batch = self._batch(batch_id) + committed_plans = [ + plan + for plan in self.control_plane.batch_plans.values() + if plan.batch_id == batch_id and plan.status == "committed" + ] + plan = committed_plans[-1] if committed_plans else None + assigned_ids = {assignment.task_id for assignment in plan.assignments} if plan else set() + latest_records: dict[str, Any] = {} + for record in self.control_plane.execution_history: + if record.batch_id == batch_id: + latest_records[record.task_id] = record + records = list(latest_records.values()) + jct = [record.jct_seconds for record in records if record.jct_seconds > 0.0] + waits = [record.queue_wait_seconds for record in records] + succeeded = sum(1 for record in records if record.success) + failed = len(records) - succeeded + unassigned_count = len(plan.unassigned_tasks) if plan else 0 + if assigned_ids and len(records) >= len(assigned_ids): + if failed >= len(assigned_ids): + batch.status = BatchStatus.FAILED + elif failed or unassigned_count: + batch.status = BatchStatus.PARTIAL_FAILED + else: + batch.status = BatchStatus.COMPLETED + return { + "batch_id": batch_id, + "status": batch.status.value, + "strategy": plan.strategy if plan else None, + "task_count": len(batch.tasks), + "assigned_count": len(assigned_ids), + "unassigned_count": unassigned_count, + "completed_count": len(records), + "succeeded_count": succeeded, + "failed_count": failed, + "actual_acceptance_rate": round(len(assigned_ids) / max(1, len(batch.tasks)), 6), + "actual_completion_rate": round(len(records) / max(1, len(batch.tasks)), 6), + "average_jct_seconds": round(mean(jct), 6) if jct else 0.0, + "p95_jct_seconds": round(_percentile(jct, 0.95), 6), + "makespan_seconds": round(max(jct), 6) if jct else 0.0, + "average_queue_wait_seconds": round(mean(waits), 6) if waits else 0.0, + "average_cpu_utilization": round(mean(record.cpu_utilization for record in records), 6) if records else 0.0, + "average_memory_utilization": round(mean(record.memory_utilization for record in records), 6) if records else 0.0, + "average_bandwidth_utilization": round(mean(record.bandwidth_utilization for record in records), 6) if records else 0.0, + "average_storage_utilization": round(mean(record.storage_utilization for record in records), 6) if records else 0.0, + "total_energy_kwh": round(sum(record.energy_kwh for record in records), 8), + "total_operational_carbon_g": round(sum(record.operational_carbon_g for record in records), 6), + "total_cost": round(sum(record.cost for record in records), 6), + "sla_violation_count": sum(1 for record in records if not record.sla_met), + "prediction": None if plan is None else { + "decision_time_ms": round(plan.decision_time_ms, 6), + "makespan": plan.predicted_makespan, + "energy_kwh": round(plan.predicted_energy_kwh, 8), + "operational_carbon_g": round(plan.predicted_carbon_g, 6), + "sla_violations": plan.predicted_sla_violations, + "future_fit_before": round(plan.future_fit_before, 6), + "future_fit_after": round(plan.future_fit_after, 6), + }, + } + + def preview(self, batch_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + options = dict(payload or {}) + requested_strategy = str(options.get("strategy") or "B6-hierarchical-batch") + profile = NAMED_BATCH_PROFILES.get(requested_strategy) + if profile: + options = {**profile, **options, "strategy": profile["strategy"]} + options["active_groups"] = profile["active_groups"] + options["group_weights"] = profile["group_weights"] + strategy = str(options.get("strategy") or "B6-hierarchical-batch") + experiment_mode = bool(options.get("experiment_mode")) + if strategy in {"B2-milp-oracle", "B5-nsga2"} and not experiment_mode: + raise BatchRequestError(403, {"error": f"{strategy} is available only when experiment_mode=true"}) + control = self.control_plane + with control.lock: + control._expire_stale_nodes() + batch = self._batch(batch_id) + active_metrics = self._validated_objectives(options.get("active_metrics"), METRIC_KEYS, "active_metrics") + active_groups = self._validated_objectives(options.get("active_groups"), GROUP_KEYS, "active_groups") + group_weight_overrides = self._validated_weights( + options.get("group_weights"), GROUP_KEYS, "group_weights" + ) + plan = self._build_plan( + batch, + strategy=strategy, + active_metrics=active_metrics, + active_groups=active_groups, + group_weight_overrides=group_weight_overrides, + ) + plan.strategy = requested_strategy + control.batch_plans[plan.plan_id] = plan + batch.latest_plan_id = plan.plan_id + batch.status = BatchStatus.PREVIEWED + return plan.to_dict() + + def compare(self, batch_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + options = dict(payload or {}) + strategies = [str(item) for item in options.get("strategies", DEFAULT_STRATEGIES)] + if not strategies: + strategies = list(DEFAULT_STRATEGIES) + experiment_mode = bool(options.get("experiment_mode")) + plans = [self.preview(batch_id, { + "strategy": strategy, + "experiment_mode": experiment_mode, + "active_metrics": options.get("active_metrics"), + "active_groups": options.get("active_groups"), + "group_weights": options.get("group_weights"), + }) for strategy in strategies] + recommended = max( + plans, + key=lambda item: ( + len(item["task_node_assignments"]), + -item["predicted_sla_violations"], + -item["predicted_carbon_g"], + -item["predicted_makespan"], + ), + ) + return {"batch_id": batch_id, "strategies": plans, "recommended_plan_id": recommended["plan_id"]} + + def commit(self, batch_id: str, payload: dict[str, Any]) -> dict[str, Any]: + if not bool(payload.get("confirmed_by_user_button") or payload.get("confirmed")): + raise BatchRequestError(403, {"error": "batch commit requires explicit confirmation"}) + control = self.control_plane + with control.lock: + batch = self._batch(batch_id) + plan_id = str(payload.get("plan_id") or batch.latest_plan_id or "") + plan = control.batch_plans.get(plan_id) + if plan is None or plan.batch_id != batch_id: + raise BatchRequestError(404, {"error": "batch plan not found"}) + requested_version = int(payload.get("resource_snapshot_version", -1)) + if requested_version != plan.resource_snapshot_version or requested_version != control.resource_snapshot_version: + raise BatchRequestError(409, {"error": "SNAPSHOT_CONFLICT", "expected_version": control.resource_snapshot_version, "plan_version": plan.resource_snapshot_version}) + task_by_id = {task.task_id: task for task in batch.tasks} + demand_by_node: dict[str, ResourceVector] = {} + for assignment in plan.assignments: + task = task_by_id[assignment.task_id] + demand_by_node[assignment.node_id] = demand_by_node.get(assignment.node_id, ResourceVector()) + task.demand + for node_id, demand in demand_by_node.items(): + node = control.nodes.get(node_id) + if node is None or not demand.fits_in(node.available()): + raise BatchRequestError(409, {"error": "SNAPSHOT_CONFLICT", "node_id": node_id}) + + ledger = ReservationLedger(plan_id=plan.plan_id, resource_snapshot_version=plan.resource_snapshot_version) + for node_id, demand in demand_by_node.items(): + ledger.reserve(node_id, demand) + control.reservation_ledgers[plan.plan_id] = ledger + tick = control.current_tick() + for task in batch.tasks: + if task.task_id not in control.tasks: + task.submit_tick = tick + control.tasks[task.task_id] = task + control.pending_queue.append(task.task_id) + control._persist_task(task) + leases = [] + for assignment in plan.assignments: + task = task_by_id[assignment.task_id] + task.status = TaskStatus.RESERVED + lease = control.task_lease_service.activate_task_lease( + task=task, + node=control.nodes[assignment.node_id], + decision=assignment.decision, + tick=tick, + remove_from_pending=True, + ) + leases.append(lease.to_dict()) + control.resource_snapshot_version += 1 + batch.status = BatchStatus.RUNNING if leases else BatchStatus.COMMITTED + plan.status = "committed" + return { + "status": "committed", + "batch_id": batch_id, + "plan_id": plan.plan_id, + "resource_snapshot_version": control.resource_snapshot_version, + "reservation_ledger": ledger.to_dict(), + "leases": leases, + "unassigned_tasks": [item.to_dict() for item in plan.unassigned_tasks], + } + + def report(self) -> dict[str, Any]: + control = self.control_plane + batches = list(control.task_batches.values()) + assigned = sum(len(plan.assignments) for plan in control.batch_plans.values() if plan.status == "committed") + total = sum(len(batch.tasks) for batch in batches) + return { + "total_batches": len(batches), + "total_batch_tasks": total, + "committed_assignments": assigned, + "batch_acceptance_rate": round(assigned / total, 4) if total else 0.0, + "recent_batches": [batch.to_dict(include_tasks=False) for batch in batches[-8:]], + } + + def _build_plan( + self, + batch: TaskBatch, + *, + strategy: str, + active_metrics: tuple[str, ...] | None = None, + active_groups: tuple[str, ...] | None = None, + group_weight_overrides: dict[str, float] | None = None, + ) -> BatchSchedulingPlan: + control = self.control_plane + started = time.perf_counter() + shadow_nodes = {node_id: copy.deepcopy(node) for node_id, node in control.nodes.items()} + snapshot = ResourceSnapshot( + version=control.resource_snapshot_version, + tick=control.current_tick(), + available_by_node={node_id: node.available() for node_id, node in shadow_nodes.items()}, + ) + # B0 preserves the legacy submission order. Joint strategies use the + # deterministic urgency/priority/scarcity ordering defined for batches. + ordered = list(batch.tasks) if strategy == "B0-current" else sorted(batch.tasks, key=self._task_sort_key) + assignments: list[BatchAssignment] = [] + unassigned: list[UnassignedTask] = [] + if strategy == "B6-hierarchical-batch": + scoring = "hierarchical_tchebycheff" + elif strategy in {"B4-pareto-tchebycheff", "pareto_tchebycheff"}: + scoring = "pareto_tchebycheff" + else: + scoring = "weighted_sum" + if strategy in {"B2-milp-oracle", "B5-nsga2"}: + assignments, unassigned = self._experimental_assign( + batch=batch, + ordered=ordered, + shadow_nodes=shadow_nodes, + tick=snapshot.tick, + strategy=strategy, + active_metrics=active_metrics, + active_groups=active_groups, + group_weight_overrides=group_weight_overrides, + ) + else: + future_tasks_for_scoring = ( + ordered + if self._needs_future_fit(active_metrics, active_groups) + else () + ) + for task in ordered: + decision = control.scheduler.select_node( + task, + shadow_nodes.values(), + current_tick=snapshot.tick, + topology_nodes=shadow_nodes.values(), + scoring_strategy=scoring, + active_metrics=active_metrics, + active_groups=active_groups, + group_weight_overrides=group_weight_overrides, + future_tasks=future_tasks_for_scoring, + ) + if decision is None: + unassigned.append(UnassignedTask(task.task_id, self._rejection_reason(task, shadow_nodes.values()))) + continue + node = shadow_nodes[decision.node_id] + carbon = dict(decision.network_snapshot.get("carbon") or {}) + assignments.append(BatchAssignment( + task_id=task.task_id, + node_id=node.node_id, + decision=decision, + predicted_energy_kwh=float(carbon.get("facility_energy_kwh", 0.0)) + float(carbon.get("network_energy_kwh", 0.0)), + predicted_carbon_g=float(carbon.get("operational_carbon_g", 0.0)), + )) + node.running_tasks[f"__batch__{task.task_id}"] = self._shadow_running_task(task, decision, snapshot.tick) + + if strategy in {"B3-batch-local-search", "B4-pareto-tchebycheff"}: + assignments, unassigned = self._local_improve( + batch=batch, + assignments=assignments, + unassigned=unassigned, + shadow_nodes=shadow_nodes, + tick=snapshot.tick, + scoring=scoring, + active_metrics=active_metrics, + active_groups=active_groups, + ) + elif strategy == "B6-hierarchical-batch": + assignments, unassigned = self._local_improve_hierarchical( + batch=batch, + assignments=assignments, + unassigned=unassigned, + shadow_nodes=shadow_nodes, + tick=snapshot.tick, + active_metrics=active_metrics, + active_groups=active_groups, + group_weight_overrides=group_weight_overrides, + ) + + task_samples = batch.tasks[: min(64, len(batch.tasks))] + future_before = self._future_fit(control.nodes.values(), task_samples) + summary = self._plan_hierarchical_summary( + batch, + assignments, + shadow_nodes.values(), + snapshot.tick, + active_groups=active_groups, + group_weight_overrides=group_weight_overrides, + ) + plan_id = f"plan-{batch.batch_id}-{strategy}-{int(time.time() * 1000)}" + return BatchSchedulingPlan( + plan_id=plan_id, + batch_id=batch.batch_id, + strategy=strategy, + resource_snapshot_version=snapshot.version, + assignments=assignments, + unassigned_tasks=unassigned, + objective_breakdown=summary["atomic_scores"], + group_objective_breakdown=summary["group_scores"], + group_weights=summary["group_weights"], + plan_utility=summary["plan_utility"], + security_risk_penalty=summary["security_risk_penalty"], + objective_hierarchy_version="five-groups-v1" if strategy == "B6-hierarchical-batch" else "flat-ten-v1", + active_objectives=list(active_groups or GROUP_KEYS) if strategy == "B6-hierarchical-batch" else list(active_metrics or METRIC_KEYS), + predicted_makespan=summary["predicted_makespan"], + predicted_cost=sum(item.decision.predicted_cost for item in assignments), + predicted_energy_kwh=sum(item.predicted_energy_kwh for item in assignments), + predicted_carbon_g=sum(item.predicted_carbon_g for item in assignments), + predicted_sla_violations=summary["predicted_sla_violations"], + future_fit_before=future_before, + future_fit_after=summary["future_fit_after"], + decision_time_ms=(time.perf_counter() - started) * 1000.0, + ) + + def _experimental_assign( + self, + *, + batch: TaskBatch, + ordered: list[Task], + shadow_nodes: dict[str, Any], + tick: int, + strategy: str, + active_metrics: tuple[str, ...] | None, + active_groups: tuple[str, ...] | None, + group_weight_overrides: dict[str, float] | None, + ) -> tuple[list[BatchAssignment], list[UnassignedTask]]: + if strategy == "B2-milp-oracle" and (len(ordered) > 20 or len(shadow_nodes) > 20): + raise BatchRequestError(422, {"error": "B2-milp-oracle is limited to 20 tasks x 20 nodes"}) + candidates: list[AssignmentCandidate] = [] + nodes = list(shadow_nodes.values()) + task_by_id = {task.task_id: task for task in ordered} + future_tasks_for_scoring = ( + ordered + if self._needs_future_fit(active_metrics, active_groups) + else () + ) + for task in ordered: + for node in nodes: + decision = self.control_plane.scheduler.select_node( + task, + [node], + current_tick=tick, + topology_nodes=nodes, + scoring_strategy="pareto_tchebycheff" if strategy == "B5-nsga2" else "weighted_sum", + active_metrics=active_metrics, + active_groups=active_groups, + group_weight_overrides=group_weight_overrides, + future_tasks=future_tasks_for_scoring, + ) + if decision is None: + continue + candidates.append(AssignmentCandidate( + task_id=task.task_id, + node_id=node.node_id, + utility=decision.total_score, + demand=task.demand.to_dict(), + objectives=dict(decision.metric_scores), + payload=decision, + )) + capacities = {node.node_id: node.available().to_dict() for node in nodes} + if strategy == "B2-milp-oracle": + solution = milp_oracle(candidates, capacities) + else: + front = nsga2_assignments(candidates, capacities) + solution = max(front, key=lambda item: (item.assigned_count, item.utility), default=None) + if solution is None: + selected_ids: set[str] = set() + return [], [UnassignedTask(task.task_id, self._rejection_reason(task, nodes)) for task in ordered if task.task_id not in selected_ids] + assignments: list[BatchAssignment] = [] + selected_ids = {item.task_id for item in solution.selected} + for item in solution.selected: + task = task_by_id[item.task_id] + decision = item.payload + decision.network_snapshot["experiment_solver_status"] = solution.status + carbon = dict(decision.network_snapshot.get("carbon") or {}) + assignment = BatchAssignment( + task_id=item.task_id, + node_id=item.node_id, + decision=decision, + predicted_energy_kwh=float(carbon.get("facility_energy_kwh", 0.0)) + float(carbon.get("network_energy_kwh", 0.0)), + predicted_carbon_g=float(carbon.get("operational_carbon_g", 0.0)), + ) + assignments.append(assignment) + shadow_nodes[item.node_id].running_tasks[f"__batch__{item.task_id}"] = self._shadow_running_task(task, decision, tick) + unassigned = [ + UnassignedTask(task.task_id, self._rejection_reason(task, nodes)) + for task in ordered + if task.task_id not in selected_ids + ] + return assignments, unassigned + + def _local_improve( + self, + *, + batch: TaskBatch, + assignments: list[BatchAssignment], + unassigned: list[UnassignedTask], + shadow_nodes: dict[str, Any], + tick: int, + scoring: str, + active_metrics: tuple[str, ...] | None, + active_groups: tuple[str, ...] | None, + ) -> tuple[list[BatchAssignment], list[UnassignedTask]]: + """One bounded exchange/backfill pass suitable for the online B3/B4 path.""" + task_by_id = {task.task_id: task for task in batch.tasks} + future_tasks_for_scoring = ( + task_by_id.values() + if self._needs_future_fit(active_metrics, active_groups) + else () + ) + improved: list[BatchAssignment] = [] + for assignment in assignments: + task = task_by_id[assignment.task_id] + old_node = shadow_nodes[assignment.node_id] + old_node.running_tasks.pop(f"__batch__{task.task_id}", None) + alternative = self.control_plane.scheduler.select_node( + task, + shadow_nodes.values(), + current_tick=tick, + topology_nodes=shadow_nodes.values(), + scoring_strategy=scoring, + active_metrics=active_metrics, + active_groups=active_groups, + future_tasks=future_tasks_for_scoring, + ) + replacement = assignment + if alternative is not None: + carbon = dict(alternative.network_snapshot.get("carbon") or {}) + alternative_assignment = BatchAssignment( + task_id=task.task_id, + node_id=alternative.node_id, + decision=alternative, + predicted_energy_kwh=float(carbon.get("facility_energy_kwh", 0.0)) + float(carbon.get("network_energy_kwh", 0.0)), + predicted_carbon_g=float(carbon.get("operational_carbon_g", 0.0)), + ) + better_utility = alternative.total_score > assignment.decision.total_score + 1e-9 + greener_tie = ( + alternative_assignment.predicted_carbon_g + 1e-9 < assignment.predicted_carbon_g + and alternative.total_score >= assignment.decision.total_score * 0.98 + ) + if better_utility or greener_tie: + replacement = alternative_assignment + selected_node = shadow_nodes[replacement.node_id] + selected_node.running_tasks[f"__batch__{task.task_id}"] = self._shadow_running_task(task, replacement.decision, tick) + improved.append(replacement) + + remaining: list[UnassignedTask] = [] + for item in unassigned: + task = task_by_id[item.task_id] + decision = self.control_plane.scheduler.select_node( + task, + shadow_nodes.values(), + current_tick=tick, + topology_nodes=shadow_nodes.values(), + scoring_strategy=scoring, + active_metrics=active_metrics, + active_groups=active_groups, + future_tasks=future_tasks_for_scoring, + ) + if decision is None: + remaining.append(item) + continue + carbon = dict(decision.network_snapshot.get("carbon") or {}) + assignment = BatchAssignment( + task_id=task.task_id, + node_id=decision.node_id, + decision=decision, + predicted_energy_kwh=float(carbon.get("facility_energy_kwh", 0.0)) + float(carbon.get("network_energy_kwh", 0.0)), + predicted_carbon_g=float(carbon.get("operational_carbon_g", 0.0)), + ) + shadow_nodes[decision.node_id].running_tasks[f"__batch__{task.task_id}"] = self._shadow_running_task(task, decision, tick) + improved.append(assignment) + return improved, remaining + + def _local_improve_hierarchical( + self, + *, + batch: TaskBatch, + assignments: list[BatchAssignment], + unassigned: list[UnassignedTask], + shadow_nodes: dict[str, Any], + tick: int, + active_metrics: tuple[str, ...] | None, + active_groups: tuple[str, ...] | None, + group_weight_overrides: dict[str, float] | None, + ) -> tuple[list[BatchAssignment], list[UnassignedTask]]: + """Bounded plan-level search; replacements are accepted by delta J(X).""" + task_by_id = {task.task_id: task for task in batch.tasks} + future_samples = list(task_by_id.values())[:B6_FUTURE_FIT_SAMPLE_LIMIT] + scoring_future_samples = ( + future_samples + if self._needs_future_fit(active_metrics, active_groups) + else () + ) + improved = list(assignments) + for index, current in enumerate(list(improved)[:B6_LOCAL_SEARCH_TASK_LIMIT]): + task = task_by_id[current.task_id] + shadow_nodes[current.node_id].running_tasks.pop(f"__batch__{task.task_id}", None) + trials: list[tuple[float, float, BatchAssignment]] = [] + current_node = shadow_nodes[current.node_id] + alternatives = sorted( + ( + node + for node in shadow_nodes.values() + if node.node_id != current.node_id and node.can_host_now(task) + ), + key=lambda node: ( + -node.dominant_utilization_after(task.demand), + node.carbon_profile.intensity_at(tick), + node.node_id, + ), + )[: max(0, B6_LOCAL_SEARCH_NODE_LIMIT - 1)] + candidate_nodes = [current_node, *alternatives] + for node in candidate_nodes: + decision = self.control_plane.scheduler.select_node( + task, + [node], + current_tick=tick, + topology_nodes=shadow_nodes.values(), + scoring_strategy="hierarchical_tchebycheff", + active_metrics=active_metrics, + active_groups=active_groups, + group_weight_overrides=group_weight_overrides, + future_tasks=scoring_future_samples, + ) + if decision is None: + continue + candidate = self._assignment_from_decision(task, decision) + node.running_tasks[f"__batch__{task.task_id}"] = self._shadow_running_task(task, decision, tick) + proposal = list(improved) + proposal[index] = candidate + summary = self._plan_hierarchical_summary( + batch, + proposal, + shadow_nodes.values(), + tick, + active_groups=active_groups, + group_weight_overrides=group_weight_overrides, + calculate_future_fit=False, + ) + trials.append((summary["plan_utility"], -candidate.predicted_carbon_g, candidate)) + node.running_tasks.pop(f"__batch__{task.task_id}", None) + + replacement = max(trials, key=lambda item: (item[0], item[1], item[2].node_id))[2] if trials else current + improved[index] = replacement + shadow_nodes[replacement.node_id].running_tasks[f"__batch__{task.task_id}"] = self._shadow_running_task( + task, replacement.decision, tick + ) + + remaining: list[UnassignedTask] = [] + for item in unassigned: + task = task_by_id[item.task_id] + decision = self.control_plane.scheduler.select_node( + task, + shadow_nodes.values(), + current_tick=tick, + topology_nodes=shadow_nodes.values(), + scoring_strategy="hierarchical_tchebycheff", + active_metrics=active_metrics, + active_groups=active_groups, + group_weight_overrides=group_weight_overrides, + future_tasks=scoring_future_samples, + ) + if decision is None: + remaining.append(item) + continue + candidate = self._assignment_from_decision(task, decision) + shadow_nodes[decision.node_id].running_tasks[f"__batch__{task.task_id}"] = self._shadow_running_task( + task, decision, tick + ) + improved.append(candidate) + return improved, remaining + + @staticmethod + def _assignment_from_decision(task: Task, decision: Any) -> BatchAssignment: + carbon = dict(decision.network_snapshot.get("carbon") or {}) + return BatchAssignment( + task_id=task.task_id, + node_id=decision.node_id, + decision=decision, + predicted_energy_kwh=float(carbon.get("facility_energy_kwh", 0.0)) + + float(carbon.get("network_energy_kwh", 0.0)), + predicted_carbon_g=float(carbon.get("operational_carbon_g", 0.0)), + ) + + def _plan_hierarchical_summary( + self, + batch: TaskBatch, + assignments: list[BatchAssignment], + nodes: Any, + tick: int, + *, + active_groups: tuple[str, ...] | None, + group_weight_overrides: dict[str, float] | None, + calculate_future_fit: bool = True, + ) -> dict[str, Any]: + selected_groups = active_groups or GROUP_KEYS + task_by_id = {task.task_id: task for task in batch.tasks} + count = len(assignments) + atomic_totals = {key: 0.0 for key in METRIC_KEYS} + group_totals = {key: 0.0 for key in GROUP_KEYS} + weight_totals = {key: 0.0 for key in selected_groups} + security_penalty = 0.0 + placement_penalty = 0.0 + for assignment in assignments: + decision = assignment.decision + for key in METRIC_KEYS: + atomic_totals[key] += float(decision.metric_scores.get(key, 0.0)) + decision_groups = dict(decision.network_snapshot.get("objective_groups") or {}) + if not decision_groups: + decision_groups = self.control_plane.scheduler.objective_group_scores(decision.metric_scores) + for key in GROUP_KEYS: + group_totals[key] += float(decision_groups.get(key, 0.0)) + decision_weights = dict(decision.network_snapshot.get("objective_group_weights") or {}) + for key in selected_groups: + weight_totals[key] += float(decision_weights.get(key, 0.0)) + security_penalty += float(decision.network_snapshot.get("security_risk_penalty", 0.0)) + placement_penalty += sum( + float(value) + for value in dict(decision.network_snapshot.get("placement_penalties") or {}).values() + ) + + denominator = max(1, count) + atomic_scores = {key: value / denominator for key, value in atomic_totals.items()} + group_scores = {key: value / denominator for key, value in group_totals.items()} + group_weights = self.control_plane.scheduler._masked_weights( + group_weight_overrides + or ( + {key: value / denominator for key, value in weight_totals.items()} + if count + else self.control_plane.policy_state.current_group_weights() + ), + selected_groups, + ) + should_calculate_future_fit = calculate_future_fit or "resource_efficiency" in selected_groups + task_samples = batch.tasks[: min(64, len(batch.tasks))] + future_fit_after = self._future_fit(nodes, task_samples) if should_calculate_future_fit else 0.0 + if count and should_calculate_future_fit: + group_scores["resource_efficiency"] = clamp( + 0.60 * group_scores["resource_efficiency"] + 0.40 * future_fit_after + - placement_penalty / denominator + ) + predicted_sla = sum( + 1 + for item in assignments + if task_by_id[item.task_id].effective_deadline_tick() is not None + and item.decision.predicted_finish_tick + > int(task_by_id[item.task_id].effective_deadline_tick() or 0) + ) + if count: + group_scores["sla_quality"] *= 1.0 - (predicted_sla / count) + average_security_penalty = security_penalty / denominator + scalar = ( + self.control_plane.scheduler.tchebycheff_utility( + group_scores, group_weights, selected_groups + ) + if count + else 0.0 + ) + acceptance_rate = count / max(1, len(batch.tasks)) + plan_utility = 0.65 * acceptance_rate + 0.35 * max(0.0, scalar - average_security_penalty) + makespan = max((item.decision.predicted_finish_tick for item in assignments), default=tick) - tick + return { + "atomic_scores": atomic_scores, + "group_scores": group_scores, + "group_weights": group_weights, + "plan_utility": plan_utility, + "security_risk_penalty": average_security_penalty, + "future_fit_after": future_fit_after, + "predicted_sla_violations": predicted_sla, + "predicted_makespan": max(0, makespan), + } + + @staticmethod + def _needs_future_fit( + active_metrics: tuple[str, ...] | None, + active_groups: tuple[str, ...] | None, + ) -> bool: + if active_groups is not None: + return "resource_efficiency" in active_groups + if active_metrics is not None: + return "fragmentation" in active_metrics + return True + + @staticmethod + def _shadow_running_task(task: Task, decision: Any, tick: int) -> RunningTask: + return RunningTask( + task_id=task.task_id, + node_id=decision.node_id, + allocation=task.demand, + start_tick=tick, + predicted_duration=max(1, decision.predicted_finish_tick - tick), + actual_duration=0, + finish_tick=decision.predicted_finish_tick, + success_probability=1.0, + ) + + @staticmethod + def _validated_objectives(value: Any, allowed: tuple[str, ...], field: str) -> tuple[str, ...] | None: + if value is None: + return None + if not isinstance(value, (list, tuple)): + raise BatchRequestError(422, {"error": f"{field} must be an array"}) + unknown = [str(item) for item in value if str(item) not in allowed] + if unknown: + raise BatchRequestError(422, {"error": f"unknown {field}: {', '.join(unknown)}"}) + unique = tuple(dict.fromkeys(str(item) for item in value)) + if not unique: + raise BatchRequestError(422, {"error": f"{field} cannot be empty"}) + return unique + + @staticmethod + def _validated_weights( + value: Any, + allowed: tuple[str, ...], + field: str, + ) -> dict[str, float] | None: + if value is None: + return None + if not isinstance(value, dict): + raise BatchRequestError(422, {"error": f"{field} must be an object"}) + unknown = [str(key) for key in value if str(key) not in allowed] + if unknown: + raise BatchRequestError(422, {"error": f"unknown {field}: {', '.join(unknown)}"}) + weights = {str(key): float(weight) for key, weight in value.items()} + if any(weight < 0.0 for weight in weights.values()) or sum(weights.values()) <= 0.0: + raise BatchRequestError(422, {"error": f"{field} values must be non-negative with a positive sum"}) + return weights + + def _batch(self, batch_id: str) -> TaskBatch: + batch = self.control_plane.task_batches.get(batch_id) + if batch is None: + raise BatchRequestError(404, {"error": f"unknown batch {batch_id}"}) + return batch + + def _task_sort_key(self, task: Task) -> tuple[float, int, float, int, str]: + tick = self.control_plane.current_tick() + fleet = ResourceVector() + for node in self.control_plane.nodes.values(): + fleet = fleet + node.capacity + scarcity = task.demand.dominant_share_against(fleet) + deadline = task.effective_deadline_tick() if task.effective_deadline_tick() is not None else 10**12 + return (float(deadline - tick - task.estimated_duration), -task.priority, -scarcity, task.submit_tick, task.task_id) + + def _future_fit(self, nodes: Any, tasks: list[Task]) -> float: + nodes_list = list(nodes) + if not nodes_list or not tasks: + return 0.0 + # Average the per-node Future-Fit values instead of counting a future + # task as feasible when just one node can host it. The former is the + # task-node feasible-pair ratio and therefore drops when placement + # consumes interchangeable capacity or creates resource-shape holes. + feasible_pairs = sum( + 1 + for node in nodes_list + for task in tasks + if self._future_task_fits(node, task) + ) + return feasible_pairs / (len(nodes_list) * len(tasks)) + + def _future_task_fits(self, node: Any, task: Task) -> bool: + if not node.can_host_now(task): + return False + path = node.path_profile_for(task.network_source()) + if task.max_latency_ms is not None and path.robust_latency_ms() > task.max_latency_ms: + return False + if task.min_bandwidth_mbps is not None and path.guaranteed_bandwidth_mbps() < task.min_bandwidth_mbps: + return False + if task.carbon_budget_g is not None: + predicted = node.predict_operational_carbon(task, node.predict_duration(task), self.control_plane.current_tick()) + if float(predicted["operational_carbon_g"]) > task.carbon_budget_g: + return False + return True + + @staticmethod + def _rejection_reason(task: Task, nodes: Any) -> str: + nodes_list = list(nodes) + if task.demand.gpu > 0 and all(node.available().gpu + 1e-9 < task.demand.gpu for node in nodes_list): + return "INSUFFICIENT_GPU" + if task.allowed_regions and all(not any(node.matches_deployment_region(region) for region in task.allowed_regions) for node in nodes_list): + return "REGION_FORBIDDEN" + if task.carbon_budget_g is not None: + return "CARBON_BUDGET_EXCEEDED" + if task.deadline is not None: + return "DEADLINE_INFEASIBLE" + return "NO_FEASIBLE_NODE" + + @staticmethod + def _csv_row(row: dict[str, str], row_number: int) -> dict[str, Any]: + def number(name: str, default: float = 0.0) -> float: + text = str(row.get(name, "")).strip() + if text == "": + return default + try: + return float(text) + except ValueError as exc: + raise BatchRequestError(422, {"error": "batch validation failed", "validation": BatchValidationReport(0, errors=[BatchValidationIssue(row_number, name, "INVALID_NUMBER", f"{name} must be numeric")]).to_dict()}) from exc + + def boolean(name: str, default: bool) -> bool: + text = str(row.get(name, "")).strip().lower() + if text == "": + return default + if text not in {"true", "false"}: + raise BatchRequestError(422, {"error": "batch validation failed", "validation": BatchValidationReport(0, errors=[BatchValidationIssue(row_number, name, "INVALID_BOOLEAN", f"{name} must be true or false")]).to_dict()}) + return text == "true" + + payload: dict[str, Any] = { + "task_id": str(row.get("task_id", "")).strip(), + "task_type": str(row.get("task_type", "batch")).strip() or "batch", + "demand": {key: number(key) for key in ("cpu", "memory", "gpu", "storage")}, + "estimated_duration": int(number("estimated_duration", 0)), + "priority": int(number("priority", 5)), + "security_level": str(row.get("security_level", "medium")).strip() or "medium", + "isolation_level": str(row.get("isolation_level", "process")).strip() or "process", + "allowed_regions": [item for item in str(row.get("allowed_regions", "")).split("|") if item], + "forbidden_nodes": [item for item in str(row.get("forbidden_nodes", "")).split("|") if item], + "require_encrypted_transport": boolean("require_encrypted_transport", True), + "allow_region_shift": boolean("allow_region_shift", True), + "allow_time_shift": boolean("allow_time_shift", False), + "carbon_priority": number("carbon_priority", 0.0), + } + region = str(row.get("region", "")).strip() + if region and not payload["allowed_regions"]: + payload["allowed_regions"] = [region] + optional_numbers = ("budget", "deadline", "input_size_gb", "max_latency_ms", "min_bandwidth_mbps", "carbon_budget_g", "deferrable_until_tick") + for key in optional_numbers: + text = str(row.get(key, "")).strip() + if text: + payload[key] = float(text) if key not in {"deadline", "deferrable_until_tick"} else int(float(text)) + for key in ("data_region", "source_region"): + text = str(row.get(key, "")).strip() + if text: + payload[key] = text + return payload diff --git a/src/tianjun/application/control_plane.py b/src/tianjun/application/control_plane.py index 6a36db6..ecb45c8 100644 --- a/src/tianjun/application/control_plane.py +++ b/src/tianjun/application/control_plane.py @@ -7,7 +7,7 @@ from typing import Any from ..core import ComputeNetworkPolicy, UserFeedback, UserRequirement -from ..domain import ExecutionRecord, Node, PhysicalTopology, PolicyAdjustment, PolicyState, SchedulingDecision, Task, TaskStatus, clamp, normalize_weights +from ..domain import BatchStatus, ExecutionRecord, Node, PhysicalTopology, PolicyAdjustment, PolicyState, ResourceVector, SchedulingDecision, Task, TaskStatus, clamp, normalize_weights from ..policy.optimizer import PolicyOptimizer from ..policy.clarifier import RequirementSession from ..policy.generator import ComputeNetworkPolicyGenerator @@ -19,6 +19,7 @@ from .policy_workflow import PolicyWorkflowService from .requirement_dialogue import RequirementDialogueService from .task_lease_service import TaskLease, TaskLeaseService +from .batch_scheduling_service import BatchSchedulingService def _truncate(text: str, limit: int = 400) -> str: @@ -27,6 +28,20 @@ def _truncate(text: str, limit: int = 400) -> str: return text[: limit - 3] + "..." +def _percentile(values: list[float], percentile: float) -> float: + """Return a linearly interpolated percentile without a NumPy dependency.""" + if not values: + return 0.0 + ordered = sorted(float(value) for value in values) + if len(ordered) == 1: + return ordered[0] + position = max(0.0, min(1.0, percentile)) * (len(ordered) - 1) + lower = int(position) + upper = min(lower + 1, len(ordered) - 1) + fraction = position - lower + return ordered[lower] + fraction * (ordered[upper] - ordered[lower]) + + class CentralControlPlane: def __init__( self, @@ -64,14 +79,22 @@ def __init__( self.user_feedback: list[UserFeedback] = [] self.requirement_sessions: dict[str, RequirementSession] = {} self.physical_topology: PhysicalTopology | None = None + self.resource_snapshot_version = 0 + self.task_batches: dict[str, Any] = {} + self.batch_plans: dict[str, Any] = {} + self.batch_idempotency: dict[str, str] = {} + self.reservation_ledgers: dict[str, Any] = {} + self.tool_audit_log: list[dict[str, Any]] = [] self.node_registry = NodeRegistry(self) self.task_lease_service = TaskLeaseService(self) + self.batch_scheduling_service = BatchSchedulingService(self) self.policy_workflow = PolicyWorkflowService(self) self.requirement_dialogue = RequirementDialogueService(self) if self.state_store is not None: self._restore_from_store() self.state_store.set_control_value("policy_weights", self.policy_state.current_weights()) + self.state_store.set_control_value("policy_group_weights", self.policy_state.current_group_weights()) def register_node(self, node: Node) -> dict[str, Any]: return self.node_registry.register_node(node) @@ -94,6 +117,51 @@ def preview_task(self, task: Task) -> dict[str, Any] | None: def schedule_pending_task(self, task_id: str) -> dict[str, Any]: return self.task_lease_service.schedule_pending_task(task_id) + def import_task_batch(self, payload: dict[str, Any]) -> dict[str, Any]: + return self.batch_scheduling_service.import_json(payload) + + def import_task_batch_csv(self, text: str, *, batch_name: str = "CSV批次") -> dict[str, Any]: + return self.batch_scheduling_service.import_csv(text, batch_name=batch_name) + + def get_task_batch(self, batch_id: str) -> dict[str, Any]: + return self.batch_scheduling_service.get_batch(batch_id) + + def get_task_batch_actual_metrics(self, batch_id: str) -> dict[str, Any]: + return self.batch_scheduling_service.actual_metrics(batch_id) + + def preview_batch_schedule(self, batch_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + return self.batch_scheduling_service.preview(batch_id, payload) + + def compare_batch_strategies(self, batch_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + return self.batch_scheduling_service.compare(batch_id, payload) + + def commit_batch_schedule(self, batch_id: str, payload: dict[str, Any]) -> dict[str, Any]: + return self.batch_scheduling_service.commit(batch_id, payload) + + def record_tool_call( + self, + *, + tool_name: str, + actor: str, + result_status: str, + batch_id: str | None = None, + plan_id: str | None = None, + session_id: str | None = None, + request_id: str | None = None, + ) -> None: + with self.lock: + self.tool_audit_log.append({ + "request_id": request_id or f"req-{int(time.time() * 1000)}", + "session_id": session_id, + "batch_id": batch_id, + "plan_id": plan_id, + "actor": actor, + "tool_name": tool_name, + "timestamp": round(time.time(), 4), + "result_status": result_status, + }) + self.tool_audit_log = self.tool_audit_log[-200:] + def parse_requirement( self, message: str, @@ -178,11 +246,18 @@ def simulate_policy(self, policy_id: str) -> dict[str, Any]: def commit_policy(self, policy_id: str) -> dict[str, Any]: return self.policy_workflow.commit_policy(policy_id) - def update_policy_weights(self, weights: dict[str, Any], *, reason: str = "用户手动提交多维策略权重。") -> dict[str, Any]: + def update_policy_weights( + self, + weights: dict[str, Any], + *, + group_weights: dict[str, Any] | None = None, + reason: str = "用户手动提交多维策略权重。", + ) -> dict[str, Any]: with self.lock: - normalized = normalize_weights({str(key): float(value) for key, value in dict(weights or {}).items()}) - if not normalized: - raise ValueError("weights are required") + submitted = {str(key): float(value) for key, value in dict(weights or {}).items()} + if not submitted and group_weights is None: + raise ValueError("weights or group_weights are required") + normalized = normalize_weights(submitted) if submitted else self.policy_state.current_weights() self.policy_state.update( tick=self.current_tick(), new_weights=normalized, @@ -190,13 +265,19 @@ def update_policy_weights(self, weights: dict[str, Any], *, reason: str = "用 affected_records=0, metrics={}, ) + if group_weights is not None: + self.policy_state.update_group_weights({ + str(key): float(value) for key, value in dict(group_weights).items() + }) if self.state_store is not None: latest_adjustment = self.policy_state.adjustment_history[-1] self.state_store.append_policy_adjustment(latest_adjustment.to_dict()) self.state_store.set_control_value("policy_weights", self.policy_state.current_weights()) + self.state_store.set_control_value("policy_group_weights", self.policy_state.current_group_weights()) return { "status": "updated", "policy_weights": {key: round(value, 4) for key, value in self.policy_state.current_weights().items()}, + "policy_group_weights": {key: round(value, 4) for key, value in self.policy_state.current_group_weights().items()}, "adjustment": self.policy_state.adjustment_history[-1].to_dict(), } @@ -223,6 +304,11 @@ def record_heartbeat( labels: set[str] | None = None, performance_factors: dict[str, float] | None = None, network_paths: dict[str, dict[str, float]] | None = None, + current_power_w: float | None = None, + energy_kwh_delta: float | None = None, + operational_carbon_g_delta: float | None = None, + carbon_intensity_g_per_kwh: float | None = None, + carbon_signal_timestamp: float | None = None, ) -> dict[str, Any]: return self.node_registry.record_heartbeat( node_id, @@ -236,6 +322,11 @@ def record_heartbeat( labels=labels, performance_factors=performance_factors, network_paths=network_paths, + current_power_w=current_power_w, + energy_kwh_delta=energy_kwh_delta, + operational_carbon_g_delta=operational_carbon_g_delta, + carbon_intensity_g_per_kwh=carbon_intensity_g_per_kwh, + carbon_signal_timestamp=carbon_signal_timestamp, ) def request_lease(self, node_id: str) -> dict[str, Any] | None: @@ -318,6 +409,12 @@ def report_task_result( within_budget = None if task.budget is None else actual_cost <= task.budget deadline_tick = task.effective_deadline_tick() sla_met = deadline_tick is None or tick <= deadline_tick + result_metadata = dict(metadata or {}) + predicted_carbon = node.predict_operational_carbon(task, actual_duration, tick) + energy_kwh = float(result_metadata.get("energy_kwh", predicted_carbon.get("facility_energy_kwh", 0.0))) + compute_carbon_g = float(result_metadata.get("compute_carbon_g", predicted_carbon.get("compute_carbon_g", 0.0))) + network_carbon_g = float(result_metadata.get("network_carbon_g", predicted_carbon.get("network_carbon_g", 0.0))) + operational_carbon_g = float(result_metadata.get("operational_carbon_g", compute_carbon_g + network_carbon_g)) record = ExecutionRecord( task_id=task.task_id, task_type=task.task_type, @@ -347,11 +444,27 @@ def report_task_result( sla_met=sla_met, within_budget=within_budget, ), - metadata=dict(metadata or {}), + metadata=result_metadata, + energy_kwh=energy_kwh, + compute_carbon_g=compute_carbon_g, + network_carbon_g=network_carbon_g, + operational_carbon_g=operational_carbon_g, + carbon_scope=str(result_metadata.get("carbon_scope", "operational_only")), + batch_id=task.batch_id, + queue_wait_seconds=max(0.0, float(result_metadata.get("queue_wait_seconds", 0.0))), + jct_seconds=max(0.0, float(result_metadata.get("jct_seconds", duration_seconds))), + cpu_utilization=clamp(float(result_metadata.get("cpu_utilization", 0.0))), + memory_utilization=clamp(float(result_metadata.get("memory_utilization", 0.0))), + bandwidth_utilization=clamp(float(result_metadata.get("bandwidth_utilization", 0.0))), + storage_utilization=clamp(float(result_metadata.get("storage_utilization", 0.0))), ) self.execution_history.append(record) self.task_progress.pop(task_id, None) node.update_after_record(task, record) + node.task_energy_kwh_total += energy_kwh + node.task_operational_carbon_g_total += operational_carbon_g + node.resource_version += 1 + self.resource_snapshot_version += 1 if success: task.status = TaskStatus.SUCCEEDED @@ -361,6 +474,14 @@ def report_task_result( else: task.status = TaskStatus.FAILED + if task.batch_id and task.batch_id in self.task_batches: + batch = self.task_batches[task.batch_id] + statuses = [self.tasks[item.task_id].status for item in batch.tasks if item.task_id in self.tasks] + if statuses and all(status == TaskStatus.SUCCEEDED for status in statuses): + batch.status = BatchStatus.COMPLETED + elif statuses and all(status in {TaskStatus.SUCCEEDED, TaskStatus.FAILED, TaskStatus.CANCELLED} for status in statuses): + batch.status = BatchStatus.PARTIAL_FAILED + if self.state_store is not None: self.state_store.delete_lease(task_id) self.state_store.append_execution_record(record.to_dict()) @@ -464,6 +585,23 @@ def build_report(self) -> dict[str, Any]: if self.execution_history else 0.0 ) + total_energy_kwh = sum(record.energy_kwh for record in self.execution_history) + total_operational_carbon_g = sum(record.operational_carbon_g for record in self.execution_history) + actual_jct_seconds = [ + record.jct_seconds for record in self.execution_history if record.jct_seconds > 0.0 + ] + queue_wait_seconds = [record.queue_wait_seconds for record in self.execution_history] + cpu_utilization = [record.cpu_utilization for record in self.execution_history] + memory_utilization = [record.memory_utilization for record in self.execution_history] + bandwidth_utilization = [record.bandwidth_utilization for record in self.execution_history] + storage_utilization = [record.storage_utilization for record in self.execution_history] + batch_makespans: dict[str, float] = {} + for record in self.execution_history: + if record.batch_id and record.jct_seconds > 0.0: + batch_makespans[record.batch_id] = max( + batch_makespans.get(record.batch_id, 0.0), + record.jct_seconds, + ) stable_latencies = [ float(decision.network_snapshot.get("stable_latency_ms", decision.network_snapshot.get("robust_latency_ms", 0.0))) for decision in self.decision_log @@ -489,6 +627,24 @@ def build_report(self) -> dict[str, Any]: active_model_features.append("lstm_latency_prediction") if "gnn" in loaded_models: active_model_features.append("graphsage_topology_score") + reference_weight_sources = self.scheduler.weight_components( + Task( + task_id="__weight_reference__", + task_type="batch_cpu", + demand=ResourceVector(cpu=1, memory=1, gpu=0, storage=1), + estimated_duration=10, + ), + self.current_tick(), + ) + reference_group_weight_sources = self.scheduler.group_weight_components( + Task( + task_id="__group_weight_reference__", + task_type="batch_cpu", + demand=ResourceVector(cpu=1, memory=1, gpu=0, storage=1), + estimated_duration=10, + ), + self.current_tick(), + ) return { "tick": self.current_tick(), "totals": { @@ -513,6 +669,22 @@ def build_report(self) -> dict[str, Any]: "average_cost": round(avg_cost, 4), "average_network_delay_ticks": round(avg_network_delay, 4), "average_network_risk": round(avg_network_risk, 4), + "total_energy_kwh": round(total_energy_kwh, 8), + "total_operational_carbon_g": round(total_operational_carbon_g, 6), + "average_operational_carbon_g_per_task": round(total_operational_carbon_g / len(self.execution_history), 6) if self.execution_history else 0.0, + "average_actual_jct_seconds": round(mean(actual_jct_seconds), 6) if actual_jct_seconds else 0.0, + "p95_actual_jct_seconds": round(_percentile(actual_jct_seconds, 0.95), 6), + "average_queue_wait_seconds": round(mean(queue_wait_seconds), 6) if queue_wait_seconds else 0.0, + "p95_queue_wait_seconds": round(_percentile(queue_wait_seconds, 0.95), 6), + "actual_makespan_seconds": round(max(actual_jct_seconds), 6) if actual_jct_seconds else 0.0, + "average_cpu_utilization": round(mean(cpu_utilization), 6) if cpu_utilization else 0.0, + "average_memory_utilization": round(mean(memory_utilization), 6) if memory_utilization else 0.0, + "average_bandwidth_utilization": round(mean(bandwidth_utilization), 6) if bandwidth_utilization else 0.0, + "average_storage_utilization": round(mean(storage_utilization), 6) if storage_utilization else 0.0, + "completed_batch_count": len(batch_makespans), + "batch_makespan_seconds": { + batch_id: round(value, 6) for batch_id, value in sorted(batch_makespans.items()) + }, "average_stable_latency_ms": round(mean(stable_latencies) if stable_latencies else 0.0, 4), "average_fusion_score": round(mean(fusion_scores) if fusion_scores else 0.0, 4), "average_deterministic_confidence": round( @@ -527,6 +699,31 @@ def build_report(self) -> dict[str, Any]: ), }, "policy_weights": {key: round(value, 4) for key, value in self.policy_state.current_weights().items()}, + "policy_group_weights": {key: round(value, 4) for key, value in self.policy_state.current_group_weights().items()}, + "weight_sources": { + **{ + source: {key: round(value, 4) for key, value in weights.items()} + for source, weights in reference_weight_sources.items() + }, + "fusion_coefficients": {"intent": 0.4, "sla": 0.4, "data": 0.2}, + "data_method": "fixed_critic_reference_profile", + "scope": "reference_batch_cpu_task; actual SLA/final weights are recomputed per task", + }, + "group_weight_sources": { + **{ + source: {key: round(value, 4) for key, value in weights.items()} + for source, weights in reference_group_weight_sources.items() + }, + "fusion_coefficients": {"intent": 0.4, "sla": 0.4, "data": 0.2}, + "hierarchy_version": "five-groups-v1", + "security_policy": "hard constraints plus non-compensable residual risk penalty", + }, + "batch_scheduling": self.batch_scheduling_service.report(), + "toolchain_runtime": { + "external_mcp_last_success": next((item for item in reversed(self.tool_audit_log) if item["actor"] == "external_mcp" and item["result_status"] == "success"), None), + "recent_calls": list(self.tool_audit_log[-20:]), + }, + "resource_snapshot_version": self.resource_snapshot_version, "policy_history": [entry.to_dict() for entry in self.policy_state.adjustment_history], "nodes": [ self._node_report_payload(node) @@ -565,6 +762,13 @@ def build_report(self) -> dict[str, Any]: "node_load", "bandwidth_utilization", "security_policy", + "operational_carbon", + "batch_joint_allocation", + "pareto_tchebycheff", + "future_fit_fragmentation", + "atomic_snapshot_reservation", + "hierarchical_objective_fusion", + "plan_level_delta_search", *active_model_features, ], "model_status": model_runtime["status"], @@ -821,6 +1025,9 @@ def _restore_from_store(self) -> None: restored_weights = snapshot["control_state"].get("policy_weights") if restored_weights: self.policy_state.weights = restored_weights + restored_group_weights = snapshot["control_state"].get("policy_group_weights") + if restored_group_weights: + self.policy_state.group_weights = restored_group_weights restored_topology = snapshot["control_state"].get("physical_topology") if restored_topology: @@ -846,7 +1053,7 @@ def _restore_from_store(self) -> None: for payload in snapshot["tasks"]: task = task_from_dict(payload) - if task.status == TaskStatus.RUNNING: + if task.status in {TaskStatus.RUNNING, TaskStatus.RESERVED, TaskStatus.LEASED}: task.status = TaskStatus.PENDING self.tasks[task.task_id] = task if task.status == TaskStatus.PENDING and task.task_id not in self.pending_queue: diff --git a/src/tianjun/application/node_registry.py b/src/tianjun/application/node_registry.py index 86391d2..33afc41 100644 --- a/src/tianjun/application/node_registry.py +++ b/src/tianjun/application/node_registry.py @@ -32,6 +32,8 @@ def register_node(self, node: Node) -> dict[str, Any]: node.online = True node.telemetry_tick = control.current_tick() control.nodes[node.node_id] = node + node.resource_version += 1 + control.resource_snapshot_version += 1 control.last_heartbeat_at[node.node_id] = time.monotonic() control._persist_node(node) return node.to_dict() @@ -50,6 +52,11 @@ def record_heartbeat( labels: set[str] | None = None, performance_factors: dict[str, float] | None = None, network_paths: dict[str, dict[str, float]] | None = None, + current_power_w: float | None = None, + energy_kwh_delta: float | None = None, + operational_carbon_g_delta: float | None = None, + carbon_intensity_g_per_kwh: float | None = None, + carbon_signal_timestamp: float | None = None, ) -> dict[str, Any]: control = self.control_plane with control.lock: @@ -82,6 +89,21 @@ def record_heartbeat( for key, value in profile_updates.items(): if hasattr(profile, key): setattr(profile, key, float(value)) + if current_power_w is not None: + node.current_power_w = max(0.0, float(current_power_w)) + if energy_kwh_delta is not None: + node.energy_kwh_total += max(0.0, float(energy_kwh_delta)) + if operational_carbon_g_delta is not None: + node.operational_carbon_g_total += max(0.0, float(operational_carbon_g_delta)) + if carbon_intensity_g_per_kwh is not None: + node.carbon_profile.carbon_intensity_g_per_kwh = max(0.0, float(carbon_intensity_g_per_kwh)) + if operational_carbon_g_delta is None and energy_kwh_delta is not None: + intensity = node.carbon_profile.carbon_intensity_g_per_kwh + node.operational_carbon_g_total += max(0.0, float(energy_kwh_delta)) * node.carbon_profile.pue * intensity + if carbon_signal_timestamp is not None: + node.carbon_signal_timestamp = float(carbon_signal_timestamp) + node.resource_version += 1 + control.resource_snapshot_version += 1 control.last_heartbeat_at[node_id] = time.monotonic() heartbeat_payload = { "node_id": node_id, @@ -89,6 +111,12 @@ def record_heartbeat( "running_tasks": sorted(node.running_tasks.keys()), "pending_tasks": len(control.pending_queue), "online": node.online, + "resource_version": node.resource_version, + "resource_snapshot_version": control.resource_snapshot_version, + "current_power_w": node.current_power_w, + "energy_kwh_total": node.energy_kwh_total, + "operational_carbon_g_total": node.operational_carbon_g_total, + "carbon_signal_timestamp": node.carbon_signal_timestamp, "network_paths": { region: profile.to_dict() for region, profile in sorted(node.network_paths.items(), key=lambda item: item[0]) diff --git a/src/tianjun/application/policy_workflow.py b/src/tianjun/application/policy_workflow.py index 379919d..5517434 100644 --- a/src/tianjun/application/policy_workflow.py +++ b/src/tianjun/application/policy_workflow.py @@ -229,6 +229,7 @@ def optimize_policy_from_feedback(self, feedback_payload: dict[str, Any]) -> dic "fragmentation", "locality", "network", + "carbon", } and len(feedback.instruction) < 80: requirement = control.policy_generator.apply_feedback(requirement, feedback) base_task = control.policy_tasks.get(feedback.policy_id) diff --git a/src/tianjun/application/task_lease_service.py b/src/tianjun/application/task_lease_service.py index cb8216b..19b3ca8 100644 --- a/src/tianjun/application/task_lease_service.py +++ b/src/tianjun/application/task_lease_service.py @@ -203,6 +203,8 @@ def activate_task_lease( task.status = TaskStatus.RUNNING task.last_scheduled_node = node.node_id task.attempts += 1 + node.resource_version += 1 + control.resource_snapshot_version += 1 if remove_from_pending and task.task_id in control.pending_queue: control.pending_queue.remove(task.task_id) control.decision_log.append(decision) diff --git a/src/tianjun/chat/runtime.py b/src/tianjun/chat/runtime.py index 51b0112..a312b30 100644 --- a/src/tianjun/chat/runtime.py +++ b/src/tianjun/chat/runtime.py @@ -497,7 +497,13 @@ def _llm_dialogue_state_overrides( "bandwidth_mbps": "number|null", "budget_limit": "number|null", "security_level": "low|medium|high|null", - "priority": "latency|cost|quality|balanced|security|null", + "priority": "latency|cost|quality|balanced|security|green|null", + "batch_id": "string|null", + "carbon_budget_g": "number|null", + "carbon_priority": "0..1|null", + "allow_region_shift": "boolean|null", + "allow_time_shift": "boolean|null", + "deferrable_until_tick": "integer|null", "priority_vector": { "latency": "0..1", "cost": "0..1", @@ -507,6 +513,7 @@ def _llm_dialogue_state_overrides( "fragmentation": "0..1", "locality": "0..1", "network": "0..1", + "carbon": "0..1", }, }, "confirmed_slots": ["slot names confirmed by the latest user message"], @@ -571,7 +578,7 @@ def _validated_state_overrides(self, payload: dict[str, Any], *, known_regions: return None allowed_workloads = {"inference", "training", "streaming", "analytics", "batch"} allowed_security = {"low", "medium", "high"} - allowed_priority = {"latency", "cost", "quality", "balanced", "security"} + allowed_priority = {"latency", "cost", "quality", "balanced", "security", "green"} safe: dict[str, Any] = {} workload = updates.get("workload_type") if isinstance(workload, str) and workload in allowed_workloads: @@ -587,7 +594,7 @@ def _validated_state_overrides(self, payload: dict[str, Any], *, known_regions: safe_vector = { str(key): value for key, raw in vector.items() - if str(key) in {"latency", "cost", "quality", "security", "balance", "fragmentation", "locality", "network"} + if str(key) in {"latency", "cost", "quality", "security", "balance", "fragmentation", "locality", "network", "carbon"} and (value := _safe_float(raw)) is not None and 0.0 <= value <= 1.0 } @@ -607,13 +614,22 @@ def _validated_state_overrides(self, payload: dict[str, Any], *, known_regions: normalized.append(canonical) if normalized: safe["region_preference"] = normalized - for key in ("cpu_cores", "memory_gb", "latency_target_ms", "bandwidth_mbps", "budget_limit"): + for key in ("cpu_cores", "memory_gb", "latency_target_ms", "bandwidth_mbps", "budget_limit", "carbon_budget_g", "carbon_priority"): value = _safe_float(updates.get(key)) if value is not None and value >= 0: safe[key] = value gpu = _safe_float(updates.get("gpu_count")) if gpu is not None and gpu >= 0: safe["gpu_count"] = int(gpu) + batch_id = updates.get("batch_id") + if isinstance(batch_id, str) and batch_id.strip(): + safe["batch_id"] = batch_id.strip() + for key in ("allow_region_shift", "allow_time_shift"): + if isinstance(updates.get(key), bool): + safe[key] = updates[key] + deferrable = _safe_float(updates.get("deferrable_until_tick")) + if deferrable is not None and deferrable >= 0: + safe["deferrable_until_tick"] = int(deferrable) if bool(payload.get("clear_prior_constraints")): safe["__clear_prior_constraints"] = True return safe or None diff --git a/src/tianjun/cli/commands/serve.py b/src/tianjun/cli/commands/serve.py index 22e5cc9..cf01640 100644 --- a/src/tianjun/cli/commands/serve.py +++ b/src/tianjun/cli/commands/serve.py @@ -4,10 +4,10 @@ from tianjun.application.bootstrap import build_control_plane from tianjun.chat import ChatRuntime -from tianjun.config import TianjunConfig, config_path, first_present +from tianjun.config import TianjunConfig, first_present from tianjun.domain import ExecutionMode from tianjun.interfaces.http.server import build_http_server -from tianjun.scenarios import load_scenario_payload, node_from_dict, task_from_dict +from tianjun.scenarios import load_scenario_payload, node_from_dict, scenario_nodes, scenario_tasks, task_from_dict from tianjun.storage.sqlite_state_store import SQLiteStateStore from tianjun.cli import require_model, resolved_llm_settings, resolved_model_dir, resolved_path_setting @@ -16,8 +16,7 @@ def handle(args: Namespace, app_config: TianjunConfig) -> None: host = str(first_present(args.host, app_config.get("server.host"), app_config.get("control_plane.host"), default="127.0.0.1")) port = int(first_present(args.port, app_config.get("server.port"), app_config.get("control_plane.port"), default=8024)) scenario = resolved_path_setting(args.scenario, app_config, "server.scenario", "control_plane.scenario") - if args.demo and scenario is None: - scenario = config_path("examples/runtime_scenario.json") + use_builtin_demo = bool(args.demo and scenario is None) state_db = resolved_path_setting(args.state_db, app_config, "server.state_db", "control_plane.state_db") heartbeat_timeout = float(first_present( args.heartbeat_timeout_seconds, @@ -48,6 +47,11 @@ def handle(args: Namespace, app_config: TianjunConfig) -> None: control_plane.register_node(node_from_dict(node_data)) for task_data in payload.get("tasks", []): control_plane.submit_task(task_from_dict(task_data)) + elif use_builtin_demo and not control_plane.tasks: + for node in scenario_nodes(): + control_plane.register_node(node) + for task in scenario_tasks(): + control_plane.submit_task(task) chat_runtime = ChatRuntime.with_llm_settings(control_plane, resolved_llm_settings(args, app_config)) server = build_http_server(control_plane, host, port, chat_runtime=chat_runtime) print(f"Control plane listening on http://{host}:{port}") diff --git a/src/tianjun/core/policy.py b/src/tianjun/core/policy.py index 2073024..d188599 100644 --- a/src/tianjun/core/policy.py +++ b/src/tianjun/core/policy.py @@ -7,7 +7,7 @@ WorkloadType = Literal["inference", "training", "streaming", "analytics", "batch"] SecurityLevel = Literal["low", "medium", "high"] -RequirementPriority = Literal["latency", "cost", "quality", "balanced", "security"] +RequirementPriority = Literal["latency", "cost", "quality", "balanced", "security", "green"] PriorityVector = dict[str, float] PolicyStatus = Literal["draft", "simulated", "approved", "committed", "failed"] FeedbackTarget = Literal[ @@ -20,6 +20,7 @@ "fragmentation", "locality", "network", + "carbon", "module", "workflow", ] @@ -75,6 +76,12 @@ class UserRequirement: confidence: float = 0.0 slot_confidence: dict[str, float] = field(default_factory=dict) deployment: dict[str, Any] = field(default_factory=dict) + batch_id: str | None = None + carbon_budget_g: float | None = None + carbon_priority: float = 0.0 + allow_region_shift: bool = True + allow_time_shift: bool = False + deferrable_until_tick: int | None = None @classmethod def from_dict(cls, data: dict[str, Any]) -> "UserRequirement": @@ -96,6 +103,12 @@ def from_dict(cls, data: dict[str, Any]) -> "UserRequirement": confidence=float(data.get("confidence", 0.0)), slot_confidence=_float_dict(data.get("slot_confidence")), deployment=dict(data.get("deployment", {})), + batch_id=None if data.get("batch_id") in {None, ""} else str(data.get("batch_id")), + carbon_budget_g=_optional_float(data.get("carbon_budget_g")), + carbon_priority=float(data.get("carbon_priority", 0.0)), + allow_region_shift=bool(data.get("allow_region_shift", True)), + allow_time_shift=bool(data.get("allow_time_shift", False)), + deferrable_until_tick=_optional_int(data.get("deferrable_until_tick")), ) def to_dict(self) -> dict[str, Any]: @@ -118,6 +131,12 @@ def to_dict(self) -> dict[str, Any]: "confidence": self.confidence, "slot_confidence": dict(self.slot_confidence), "deployment": dict(self.deployment), + "batch_id": self.batch_id, + "carbon_budget_g": self.carbon_budget_g, + "carbon_priority": self.carbon_priority, + "allow_region_shift": self.allow_region_shift, + "allow_time_shift": self.allow_time_shift, + "deferrable_until_tick": self.deferrable_until_tick, } ) @@ -484,7 +503,7 @@ def _security_level(value: Any) -> SecurityLevel: def _priority(value: Any) -> RequirementPriority: text = str(value or "balanced") - allowed = {"latency", "cost", "quality", "balanced", "security"} + allowed = {"latency", "cost", "quality", "balanced", "security", "green"} return text if text in allowed else "balanced" # type: ignore[return-value] @@ -500,6 +519,7 @@ def _feedback_target(value: Any) -> FeedbackTarget: "fragmentation", "locality", "network", + "carbon", "module", "workflow", } diff --git a/src/tianjun/domain/__init__.py b/src/tianjun/domain/__init__.py index df16c48..522c6e3 100644 --- a/src/tianjun/domain/__init__.py +++ b/src/tianjun/domain/__init__.py @@ -4,7 +4,29 @@ Import implementation files directly when a narrower dependency is preferred. """ -from .common import METRIC_KEYS, RESOURCE_FIELDS, clamp, normalize_weights, round_payload +from .common import ( + GROUP_INNER_WEIGHTS, + GROUP_KEYS, + METRIC_KEYS, + METRIC_TO_GROUP, + OBJECTIVE_GROUPS, + RESOURCE_FIELDS, + clamp, + normalize_weights, + round_payload, +) +from .batch import ( + BatchAssignment, + BatchSchedulingPlan, + BatchStatus, + BatchValidationIssue, + BatchValidationReport, + ReservationLedger, + ResourceSnapshot, + TaskBatch, + UnassignedTask, +) +from .carbon import CarbonSiteProfile, PowerProfile, operational_carbon from .decision import SchedulingDecision from .execution import ExecutionMode, ExecutionRecord, TaskExecutionSpec from .network import NetworkPathProfile, PhysicalTopology, TopologyEdge @@ -15,7 +37,17 @@ __all__ = [ "METRIC_KEYS", + "GROUP_KEYS", + "GROUP_INNER_WEIGHTS", + "METRIC_TO_GROUP", + "OBJECTIVE_GROUPS", "RESOURCE_FIELDS", + "BatchAssignment", + "BatchSchedulingPlan", + "BatchStatus", + "BatchValidationIssue", + "BatchValidationReport", + "CarbonSiteProfile", "ExecutionMode", "ExecutionRecord", "NetworkPathProfile", @@ -23,14 +55,20 @@ "Node", "PolicyAdjustment", "PolicyState", + "PowerProfile", + "ReservationLedger", + "ResourceSnapshot", "ResourceVector", "RunningTask", "SchedulingDecision", "Task", "TaskExecutionSpec", "TaskStatus", + "TaskBatch", "TopologyEdge", + "UnassignedTask", "clamp", "normalize_weights", + "operational_carbon", "round_payload", ] diff --git a/src/tianjun/domain/batch.py b/src/tianjun/domain/batch.py new file mode 100644 index 0000000..60b7afb --- /dev/null +++ b/src/tianjun/domain/batch.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +from .decision import SchedulingDecision +from .resource import ResourceVector +from .task import Task + + +class BatchStatus(str, Enum): + IMPORTED = "imported" + VALIDATED = "validated" + PREVIEWED = "previewed" + COMMITTED = "committed" + RUNNING = "running" + COMPLETED = "completed" + PARTIAL_FAILED = "partial_failed" + FAILED = "failed" + + +@dataclass(slots=True) +class BatchValidationIssue: + row: int + field: str + code: str + message: str + + def to_dict(self) -> dict[str, Any]: + return { + "row": self.row, + "field": self.field, + "code": self.code, + "message": self.message, + } + + +@dataclass(slots=True) +class BatchValidationReport: + valid_count: int + errors: list[BatchValidationIssue] = field(default_factory=list) + warnings: list[BatchValidationIssue] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return { + "valid_count": self.valid_count, + "error_count": len(self.errors), + "warning_count": len(self.warnings), + "errors": [item.to_dict() for item in self.errors], + "warnings": [item.to_dict() for item in self.warnings], + } + + +@dataclass(slots=True) +class TaskBatch: + batch_id: str + client_batch_id: str + batch_name: str + tasks: list[Task] + defaults: dict[str, Any] = field(default_factory=dict) + batch_preferences: dict[str, Any] = field(default_factory=dict) + status: BatchStatus = BatchStatus.VALIDATED + content_hash: str = "" + created_tick: int = 0 + latest_plan_id: str | None = None + + def to_dict(self, *, include_tasks: bool = True) -> dict[str, Any]: + return { + "batch_id": self.batch_id, + "client_batch_id": self.client_batch_id, + "batch_name": self.batch_name, + "status": self.status.value, + "task_count": len(self.tasks), + "content_hash": self.content_hash, + "created_tick": self.created_tick, + "latest_plan_id": self.latest_plan_id, + "defaults": dict(self.defaults), + "batch_preferences": dict(self.batch_preferences), + "tasks": [task.to_dict() for task in self.tasks] if include_tasks else [], + } + + +@dataclass(slots=True) +class ResourceSnapshot: + version: int + tick: int + available_by_node: dict[str, ResourceVector] + + def to_dict(self) -> dict[str, Any]: + return { + "version": self.version, + "tick": self.tick, + "available_by_node": { + node_id: available.to_dict() + for node_id, available in sorted(self.available_by_node.items()) + }, + } + + +@dataclass(slots=True) +class BatchAssignment: + task_id: str + node_id: str + decision: SchedulingDecision + predicted_energy_kwh: float = 0.0 + predicted_carbon_g: float = 0.0 + + def to_dict(self) -> dict[str, Any]: + return { + "task_id": self.task_id, + "node_id": self.node_id, + "predicted_energy_kwh": round(self.predicted_energy_kwh, 8), + "predicted_carbon_g": round(self.predicted_carbon_g, 6), + "decision": self.decision.to_dict(), + } + + +@dataclass(slots=True) +class UnassignedTask: + task_id: str + reason: str + detail: str = "" + + def to_dict(self) -> dict[str, str]: + return {"task_id": self.task_id, "reason": self.reason, "detail": self.detail} + + +@dataclass(slots=True) +class BatchSchedulingPlan: + plan_id: str + batch_id: str + strategy: str + resource_snapshot_version: int + assignments: list[BatchAssignment] + unassigned_tasks: list[UnassignedTask] + objective_breakdown: dict[str, float] + predicted_makespan: int + predicted_cost: float + predicted_energy_kwh: float + predicted_carbon_g: float + predicted_sla_violations: int + future_fit_before: float + future_fit_after: float + decision_time_ms: float + group_objective_breakdown: dict[str, float] = field(default_factory=dict) + group_weights: dict[str, float] = field(default_factory=dict) + plan_utility: float = 0.0 + security_risk_penalty: float = 0.0 + objective_hierarchy_version: str = "flat-ten-v1" + active_objectives: list[str] = field(default_factory=list) + status: str = "previewed" + + def to_dict(self) -> dict[str, Any]: + return { + "plan_id": self.plan_id, + "batch_id": self.batch_id, + "strategy": self.strategy, + "status": self.status, + "resource_snapshot_version": self.resource_snapshot_version, + "task_node_assignments": [item.to_dict() for item in self.assignments], + "unassigned_tasks": [item.to_dict() for item in self.unassigned_tasks], + "objective_breakdown": { + key: round(value, 6) for key, value in self.objective_breakdown.items() + }, + "group_objective_breakdown": { + key: round(value, 6) for key, value in self.group_objective_breakdown.items() + }, + "group_weights": { + key: round(value, 6) for key, value in self.group_weights.items() + }, + "plan_utility": round(self.plan_utility, 6), + "security_risk_penalty": round(self.security_risk_penalty, 6), + "objective_hierarchy_version": self.objective_hierarchy_version, + "active_objectives": list(self.active_objectives), + "predicted_makespan": self.predicted_makespan, + "predicted_cost": round(self.predicted_cost, 6), + "predicted_energy_kwh": round(self.predicted_energy_kwh, 8), + "predicted_carbon_g": round(self.predicted_carbon_g, 6), + "predicted_sla_violations": self.predicted_sla_violations, + "future_fit_before": round(self.future_fit_before, 6), + "future_fit_after": round(self.future_fit_after, 6), + "decision_time_ms": round(self.decision_time_ms, 3), + } + + +@dataclass(slots=True) +class ReservationLedger: + plan_id: str + resource_snapshot_version: int + reservations: dict[str, ResourceVector] = field(default_factory=dict) + + def reserve(self, node_id: str, demand: ResourceVector) -> None: + self.reservations[node_id] = self.reservations.get(node_id, ResourceVector()) + demand + + def to_dict(self) -> dict[str, Any]: + return { + "plan_id": self.plan_id, + "resource_snapshot_version": self.resource_snapshot_version, + "reservations": { + node_id: demand.to_dict() + for node_id, demand in sorted(self.reservations.items()) + }, + } diff --git a/src/tianjun/domain/carbon.py b/src/tianjun/domain/carbon.py new file mode 100644 index 0000000..87a8ea1 --- /dev/null +++ b/src/tianjun/domain/carbon.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from .common import clamp + + +@dataclass(slots=True) +class PowerProfile: + """Incremental IT power model used by schedulers and simulators.""" + + profile_id: str = "default" + idle_power_w: float = 80.0 + max_power_w: float = 260.0 + gpu_idle_power_w: float = 15.0 + gpu_max_power_w: float = 300.0 + network_kwh_per_gb: float = 0.00006 + curve: str = "linear" + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> "PowerProfile": + payload = dict(data or {}) + return cls( + profile_id=str(payload.get("profile_id", "default")), + idle_power_w=max(0.0, float(payload.get("idle_power_w", 80.0))), + max_power_w=max(0.0, float(payload.get("max_power_w", 260.0))), + gpu_idle_power_w=max(0.0, float(payload.get("gpu_idle_power_w", 15.0))), + gpu_max_power_w=max(0.0, float(payload.get("gpu_max_power_w", 300.0))), + network_kwh_per_gb=max(0.0, float(payload.get("network_kwh_per_gb", 0.00006))), + curve=str(payload.get("curve", "linear")), + ) + + def incremental_power_w(self, cpu_share: float, gpu_share: float) -> float: + cpu = clamp(cpu_share) + gpu = clamp(gpu_share) + cpu_dynamic = max(0.0, self.max_power_w - self.idle_power_w) * cpu + gpu_dynamic = max(0.0, self.gpu_max_power_w - self.gpu_idle_power_w) * gpu + if self.curve == "quadratic": + cpu_dynamic *= cpu + gpu_dynamic *= gpu + return cpu_dynamic + gpu_dynamic + + def to_dict(self) -> dict[str, Any]: + return { + "profile_id": self.profile_id, + "idle_power_w": self.idle_power_w, + "max_power_w": self.max_power_w, + "gpu_idle_power_w": self.gpu_idle_power_w, + "gpu_max_power_w": self.gpu_max_power_w, + "network_kwh_per_gb": self.network_kwh_per_gb, + "curve": self.curve, + } + + +@dataclass(slots=True) +class CarbonSiteProfile: + """Site-level PUE and time-varying electricity carbon signal.""" + + site_id: str = "default-site" + region: str = "default" + pue: float = 1.4 + carbon_intensity_g_per_kwh: float = 520.0 + carbon_intensity_trace: dict[int, float] = field(default_factory=dict) + carbon_signal_type: str = "average" + timezone: str = "Asia/Shanghai" + source_version: str = "synthetic-v1" + + @classmethod + def from_dict(cls, data: dict[str, Any] | None, *, region: str = "default") -> "CarbonSiteProfile": + payload = dict(data or {}) + trace = { + int(tick): max(0.0, float(value)) + for tick, value in dict(payload.get("carbon_intensity_trace") or {}).items() + } + return cls( + site_id=str(payload.get("site_id", f"{region}-site")), + region=str(payload.get("region", region)), + pue=max(1.0, float(payload.get("pue", 1.4))), + carbon_intensity_g_per_kwh=max( + 0.0, float(payload.get("carbon_intensity_g_per_kwh", 520.0)) + ), + carbon_intensity_trace=trace, + carbon_signal_type=str(payload.get("carbon_signal_type", "average")), + timezone=str(payload.get("timezone", "Asia/Shanghai")), + source_version=str(payload.get("source_version", "synthetic-v1")), + ) + + def intensity_at(self, tick: int) -> float: + if not self.carbon_intensity_trace: + return self.carbon_intensity_g_per_kwh + eligible = [key for key in self.carbon_intensity_trace if key <= tick] + key = max(eligible) if eligible else min(self.carbon_intensity_trace) + return self.carbon_intensity_trace[key] + + def to_dict(self, *, tick: int | None = None) -> dict[str, Any]: + payload = { + "site_id": self.site_id, + "region": self.region, + "pue": self.pue, + "carbon_intensity_g_per_kwh": self.carbon_intensity_g_per_kwh, + "carbon_intensity_trace": { + str(key): value for key, value in sorted(self.carbon_intensity_trace.items()) + }, + "carbon_signal_type": self.carbon_signal_type, + "timezone": self.timezone, + "source_version": self.source_version, + } + if tick is not None: + payload["current_carbon_intensity_g_per_kwh"] = self.intensity_at(tick) + return payload + + +def operational_carbon( + *, + power_w: float, + duration_seconds: float, + pue: float, + carbon_intensity_g_per_kwh: float, + network_energy_kwh: float = 0.0, +) -> dict[str, float | str]: + energy_it_kwh = max(0.0, power_w) * max(0.0, duration_seconds) / 3_600_000.0 + facility_energy_kwh = energy_it_kwh * max(1.0, pue) + compute_carbon_g = facility_energy_kwh * max(0.0, carbon_intensity_g_per_kwh) + network_carbon_g = max(0.0, network_energy_kwh) * max(0.0, carbon_intensity_g_per_kwh) + return { + "energy_it_kwh": energy_it_kwh, + "facility_energy_kwh": facility_energy_kwh, + "network_energy_kwh": max(0.0, network_energy_kwh), + "compute_carbon_g": compute_carbon_g, + "network_carbon_g": network_carbon_g, + "operational_carbon_g": compute_carbon_g + network_carbon_g, + "carbon_scope": "operational_only", + } diff --git a/src/tianjun/domain/common.py b/src/tianjun/domain/common.py index fbd9b84..69136a6 100644 --- a/src/tianjun/domain/common.py +++ b/src/tianjun/domain/common.py @@ -2,7 +2,16 @@ from typing import Any -RESOURCE_FIELDS = ("cpu", "memory", "gpu", "storage") +RESOURCE_FIELDS = ( + "cpu", + "memory", + "gpu", + "storage", + "mips", + "gpu_memory", + "storage_iops", + "bandwidth", +) METRIC_KEYS = ( "performance", "completion", @@ -13,8 +22,34 @@ "locality", "network", "security", + "carbon", ) +# The ten atomic metrics remain available for explanation and ablation. Only +# the five semantic groups participate in the hierarchical preference layer. +# Security is intentionally excluded: mandatory security requirements are hard +# constraints and the remaining risk is applied as a non-compensable penalty. +OBJECTIVE_GROUPS: dict[str, tuple[str, ...]] = { + "sla_quality": ("performance", "completion", "reliability"), + "network_coordination": ("network", "locality"), + "resource_efficiency": ("balance", "fragmentation"), + "economic_cost": ("cost",), + "green_carbon": ("carbon",), +} +GROUP_KEYS = tuple(OBJECTIVE_GROUPS) +GROUP_INNER_WEIGHTS: dict[str, dict[str, float]] = { + "sla_quality": {"performance": 0.20, "completion": 0.50, "reliability": 0.30}, + "network_coordination": {"network": 0.65, "locality": 0.35}, + "resource_efficiency": {"balance": 0.40, "fragmentation": 0.60}, + "economic_cost": {"cost": 1.0}, + "green_carbon": {"carbon": 1.0}, +} +METRIC_TO_GROUP = { + metric: group + for group, metrics in OBJECTIVE_GROUPS.items() + for metric in metrics +} + def clamp(value: float, lower: float = 0.0, upper: float = 1.0) -> float: return max(lower, min(upper, value)) diff --git a/src/tianjun/domain/execution.py b/src/tianjun/domain/execution.py index dc64168..199cc9a 100644 --- a/src/tianjun/domain/execution.py +++ b/src/tianjun/domain/execution.py @@ -77,6 +77,18 @@ class ExecutionRecord: delivery_probability: float = 1.0 sla_reason: str | None = None metadata: dict[str, Any] = field(default_factory=dict) + energy_kwh: float = 0.0 + compute_carbon_g: float = 0.0 + network_carbon_g: float = 0.0 + operational_carbon_g: float = 0.0 + carbon_scope: str = "operational_only" + batch_id: str | None = None + queue_wait_seconds: float = 0.0 + jct_seconds: float = 0.0 + cpu_utilization: float = 0.0 + memory_utilization: float = 0.0 + bandwidth_utilization: float = 0.0 + storage_utilization: float = 0.0 def to_dict(self) -> dict[str, Any]: return { @@ -101,4 +113,16 @@ def to_dict(self) -> dict[str, Any]: "delivery_probability": round(self.delivery_probability, 4), "sla_reason": self.sla_reason, "metadata": dict(self.metadata), + "energy_kwh": round(self.energy_kwh, 8), + "compute_carbon_g": round(self.compute_carbon_g, 6), + "network_carbon_g": round(self.network_carbon_g, 6), + "operational_carbon_g": round(self.operational_carbon_g, 6), + "carbon_scope": self.carbon_scope, + "batch_id": self.batch_id, + "queue_wait_seconds": round(self.queue_wait_seconds, 6), + "jct_seconds": round(self.jct_seconds, 6), + "cpu_utilization": round(self.cpu_utilization, 6), + "memory_utilization": round(self.memory_utilization, 6), + "bandwidth_utilization": round(self.bandwidth_utilization, 6), + "storage_utilization": round(self.storage_utilization, 6), } diff --git a/src/tianjun/domain/node.py b/src/tianjun/domain/node.py index d3cac49..f2feef5 100644 --- a/src/tianjun/domain/node.py +++ b/src/tianjun/domain/node.py @@ -5,6 +5,7 @@ from typing import Any from .common import clamp +from .carbon import CarbonSiteProfile, PowerProfile, operational_carbon from .execution import ExecutionRecord from .network import NetworkPathProfile from .resource import ResourceVector @@ -62,6 +63,21 @@ class Node: running_tasks: dict[str, RunningTask] = field(default_factory=dict) telemetry_tick: int = 0 network_paths: dict[str, NetworkPathProfile] = field(default_factory=dict) + site_id: str | None = None + power_profile: PowerProfile = field(default_factory=PowerProfile) + carbon_profile: CarbonSiteProfile = field(default_factory=CarbonSiteProfile) + trust_level: str = "high" + isolation_levels: set[str] = field(default_factory=lambda: {"none", "process", "container", "namespace"}) + encrypted_transport: bool = True + resource_version: int = 0 + current_power_w: float = 0.0 + # Host-level totals come only from heartbeat telemetry. Task-attributed totals + # are kept separately to avoid charging the same energy twice. + energy_kwh_total: float = 0.0 + operational_carbon_g_total: float = 0.0 + task_energy_kwh_total: float = 0.0 + task_operational_carbon_g_total: float = 0.0 + carbon_signal_timestamp: float | None = None def __post_init__(self) -> None: self.location = self.location or self.region @@ -70,6 +86,10 @@ def __post_init__(self) -> None: or SERVICE_REGION_BY_LOCATION.get(str(self.location).lower()) or self.location ) + self.site_id = self.site_id or self.carbon_profile.site_id or f"{self.region}-site" + self.carbon_profile.site_id = self.site_id + if self.carbon_profile.region == "default": + self.carbon_profile.region = self.region self.reliability_score = clamp( self.base_reliability if self.reliability_score is None else self.reliability_score, 0.35, @@ -102,10 +122,40 @@ def can_host_now(self, task: Task) -> bool: return False if task.allowed_regions and not any(self.matches_deployment_region(region) for region in task.allowed_regions): return False + if not task.allow_region_shift and task.network_source() and not self.matches_deployment_region(task.network_source() or ""): + return False if task.preferred_labels and not task.preferred_labels.issubset(self.labels): return False + trust_rank = {"low": 0, "medium": 1, "high": 2} + if trust_rank.get(self.trust_level, 0) < trust_rank.get(task.security_level, 1): + return False + if task.isolation_level not in self.isolation_levels: + return False + if task.require_encrypted_transport and not self.encrypted_transport: + return False return task.demand.fits_in(self.available()) + def predict_operational_carbon(self, task: Task, duration_seconds: float, tick: int) -> dict[str, float | str]: + cpu_share = ( + clamp(float(task.expected_cpu_utilization)) + if task.expected_cpu_utilization is not None + else task.demand.cpu / max(1.0, self.capacity.cpu) + ) + gpu_share = task.demand.gpu / max(1.0, self.capacity.gpu) if self.capacity.gpu > 0 else 0.0 + power_w = self.power_profile.incremental_power_w(cpu_share, gpu_share) + network_energy = task.estimated_input_size_gb() * self.power_profile.network_kwh_per_gb + result = operational_carbon( + power_w=power_w, + duration_seconds=duration_seconds, + pue=self.carbon_profile.pue, + carbon_intensity_g_per_kwh=self.carbon_profile.intensity_at(tick), + network_energy_kwh=network_energy, + ) + result["power_w"] = power_w + result["carbon_intensity_g_per_kwh"] = self.carbon_profile.intensity_at(tick) + result["pue"] = self.carbon_profile.pue + return result + def performance_for(self, task_type: str) -> float: return clamp(self.performance_factors.get(task_type, 1.0), 0.35, 3.5) @@ -195,4 +245,17 @@ def to_dict(self) -> dict[str, Any]: region: profile.to_dict() for region, profile in sorted(self.network_paths.items(), key=lambda item: item[0]) }, + "site_id": self.site_id, + "power_profile": self.power_profile.to_dict(), + "carbon_profile": self.carbon_profile.to_dict(tick=self.telemetry_tick), + "trust_level": self.trust_level, + "isolation_levels": sorted(self.isolation_levels), + "encrypted_transport": self.encrypted_transport, + "resource_version": self.resource_version, + "current_power_w": round(self.current_power_w, 6), + "energy_kwh_total": round(self.energy_kwh_total, 8), + "operational_carbon_g_total": round(self.operational_carbon_g_total, 6), + "task_energy_kwh_total": round(self.task_energy_kwh_total, 8), + "task_operational_carbon_g_total": round(self.task_operational_carbon_g_total, 6), + "carbon_signal_timestamp": self.carbon_signal_timestamp, } diff --git a/src/tianjun/domain/policy.py b/src/tianjun/domain/policy.py index d02214f..594e477 100644 --- a/src/tianjun/domain/policy.py +++ b/src/tianjun/domain/policy.py @@ -3,7 +3,7 @@ from dataclasses import dataclass, field from typing import Any -from .common import METRIC_KEYS, normalize_weights +from .common import GROUP_KEYS, METRIC_KEYS, normalize_weights @dataclass(slots=True) @@ -38,6 +38,18 @@ class PolicyState: "locality": 0.06, "network": 0.09, "security": 0.08, + "carbon": 0.08, + } + ) + ) + group_weights: dict[str, float] = field( + default_factory=lambda: normalize_weights( + { + "sla_quality": 0.30, + "network_coordination": 0.20, + "resource_efficiency": 0.18, + "economic_cost": 0.12, + "green_carbon": 0.20, } ) ) @@ -49,6 +61,21 @@ def current_weights(self) -> dict[str, float]: complete_weights.update(self.weights) return normalize_weights(complete_weights) + def current_group_weights(self) -> dict[str, float]: + complete_weights = {key: 0.0 for key in GROUP_KEYS} + complete_weights.update(self.group_weights) + return normalize_weights(complete_weights) + + def update_group_weights(self, new_weights: dict[str, float]) -> None: + filtered = { + key: float(value) + for key, value in new_weights.items() + if key in GROUP_KEYS + } + if not filtered: + raise ValueError("group_weights must contain at least one known objective group") + self.group_weights = normalize_weights({key: filtered.get(key, 0.0) for key in GROUP_KEYS}) + def update( self, tick: int, diff --git a/src/tianjun/domain/resource.py b/src/tianjun/domain/resource.py index cfa015e..4590677 100644 --- a/src/tianjun/domain/resource.py +++ b/src/tianjun/domain/resource.py @@ -8,10 +8,22 @@ @dataclass(slots=True) class ResourceVector: + """Shared capacity contract. + + CPU/RAM/GPU/storage preserve the original public API. The additional + fields make CloudSim's PE throughput and I/O resources explicit while + remaining backwards compatible because unspecified dimensions default to + zero and are ignored when a node does not model them. + """ + cpu: float = 0.0 memory: float = 0.0 gpu: float = 0.0 storage: float = 0.0 + mips: float = 0.0 + gpu_memory: float = 0.0 + storage_iops: float = 0.0 + bandwidth: float = 0.0 def fits_in(self, other: "ResourceVector") -> bool: return all(getattr(self, field) <= getattr(other, field) + 1e-9 for field in RESOURCE_FIELDS) @@ -22,6 +34,10 @@ def clamp_non_negative(self) -> "ResourceVector": memory=max(0.0, self.memory), gpu=max(0.0, self.gpu), storage=max(0.0, self.storage), + mips=max(0.0, self.mips), + gpu_memory=max(0.0, self.gpu_memory), + storage_iops=max(0.0, self.storage_iops), + bandwidth=max(0.0, self.bandwidth), ) def ratios_against(self, total: "ResourceVector") -> dict[str, float]: @@ -54,6 +70,10 @@ def __add__(self, other: "ResourceVector") -> "ResourceVector": memory=self.memory + other.memory, gpu=self.gpu + other.gpu, storage=self.storage + other.storage, + mips=self.mips + other.mips, + gpu_memory=self.gpu_memory + other.gpu_memory, + storage_iops=self.storage_iops + other.storage_iops, + bandwidth=self.bandwidth + other.bandwidth, ) def __sub__(self, other: "ResourceVector") -> "ResourceVector": @@ -62,4 +82,8 @@ def __sub__(self, other: "ResourceVector") -> "ResourceVector": memory=self.memory - other.memory, gpu=self.gpu - other.gpu, storage=self.storage - other.storage, + mips=self.mips - other.mips, + gpu_memory=self.gpu_memory - other.gpu_memory, + storage_iops=self.storage_iops - other.storage_iops, + bandwidth=self.bandwidth - other.bandwidth, ) diff --git a/src/tianjun/domain/task.py b/src/tianjun/domain/task.py index 49a8e49..b5ed118 100644 --- a/src/tianjun/domain/task.py +++ b/src/tianjun/domain/task.py @@ -11,9 +11,12 @@ class TaskStatus(str, Enum): PENDING = "pending" + RESERVED = "reserved" + LEASED = "leased" RUNNING = "running" SUCCEEDED = "succeeded" FAILED = "failed" + CANCELLED = "cancelled" @dataclass(slots=True) @@ -32,6 +35,13 @@ class Task: min_bandwidth_mbps: float | None = None network_sensitivity: float = 0.5 intent_weights: dict[str, float] = field(default_factory=dict) + carbon_budget_g: float | None = None + carbon_priority: float = 0.0 + expected_cpu_utilization: float | None = None + allow_region_shift: bool = True + allow_time_shift: bool = False + deferrable_until_tick: int | None = None + batch_id: str | None = None preferred_labels: set[str] = field(default_factory=set) security_level: str = "medium" isolation_level: str = "process" @@ -88,6 +98,13 @@ def to_dict(self) -> dict[str, Any]: "min_bandwidth_mbps": self.min_bandwidth_mbps, "network_sensitivity": self.network_sensitivity, "intent_weights": dict(self.intent_weights), + "carbon_budget_g": self.carbon_budget_g, + "carbon_priority": self.carbon_priority, + "expected_cpu_utilization": self.expected_cpu_utilization, + "allow_region_shift": self.allow_region_shift, + "allow_time_shift": self.allow_time_shift, + "deferrable_until_tick": self.deferrable_until_tick, + "batch_id": self.batch_id, "preferred_labels": sorted(self.preferred_labels), "security_level": self.security_level, "isolation_level": self.isolation_level, diff --git a/src/tianjun/experiments/__init__.py b/src/tianjun/experiments/__init__.py new file mode 100644 index 0000000..912e9d1 --- /dev/null +++ b/src/tianjun/experiments/__init__.py @@ -0,0 +1,11 @@ +from .assignment import AssignmentCandidate, AssignmentSolution, milp_oracle, nsga2_assignments +from .weights import critic_weights, entropy_weights + +__all__ = [ + "AssignmentCandidate", + "AssignmentSolution", + "critic_weights", + "entropy_weights", + "milp_oracle", + "nsga2_assignments", +] diff --git a/src/tianjun/experiments/assignment.py b/src/tianjun/experiments/assignment.py new file mode 100644 index 0000000..2b9c98d --- /dev/null +++ b/src/tianjun/experiments/assignment.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import random +from dataclasses import dataclass, field +from typing import Any + + +RESOURCE_KEYS = ("cpu", "memory", "gpu", "storage") + + +@dataclass(slots=True) +class AssignmentCandidate: + task_id: str + node_id: str + utility: float + demand: dict[str, float] + objectives: dict[str, float] = field(default_factory=dict) + payload: Any = None + + +@dataclass(slots=True) +class AssignmentSolution: + selected: list[AssignmentCandidate] + objective_totals: dict[str, float] + assigned_count: int + utility: float + status: str + + +def milp_oracle( + candidates: list[AssignmentCandidate], + capacities: dict[str, dict[str, float]], +) -> AssignmentSolution: + """Solve the small task-node assignment exactly with SciPy/HiGHS MILP.""" + if not candidates: + return AssignmentSolution([], {}, 0, 0.0, "empty") + try: + import numpy as np + from scipy.optimize import Bounds, LinearConstraint, milp + from scipy.sparse import lil_matrix + except ImportError as exc: # pragma: no cover - optional experiment dependency + raise RuntimeError("MILP Oracle requires the optional 'experiments' dependencies") from exc + + tasks = sorted({item.task_id for item in candidates}) + nodes = sorted(capacities) + rows = len(tasks) + len(nodes) * len(RESOURCE_KEYS) + matrix = lil_matrix((rows, len(candidates)), dtype=float) + upper = [] + task_row = {task_id: index for index, task_id in enumerate(tasks)} + for task_id in tasks: + upper.append(1.0) + resource_row: dict[tuple[str, str], int] = {} + offset = len(tasks) + for node_id in nodes: + for key in RESOURCE_KEYS: + resource_row[(node_id, key)] = offset + upper.append(float(capacities[node_id].get(key, 0.0))) + offset += 1 + for column, item in enumerate(candidates): + matrix[task_row[item.task_id], column] = 1.0 + for key in RESOURCE_KEYS: + matrix[resource_row[(item.node_id, key)], column] = float(item.demand.get(key, 0.0)) + assignment_reward = max(1.0, sum(abs(item.utility) for item in candidates) + 1.0) + objective = np.array([-(assignment_reward + item.utility) for item in candidates], dtype=float) + result = milp( + c=objective, + integrality=np.ones(len(candidates)), + bounds=Bounds(np.zeros(len(candidates)), np.ones(len(candidates))), + constraints=LinearConstraint(matrix.tocsr(), np.zeros(rows), np.array(upper)), + options={"time_limit": 30.0}, + ) + if result.x is None: + return AssignmentSolution([], {}, 0, 0.0, f"milp_{result.message}") + selected = [item for item, value in zip(candidates, result.x) if value >= 0.5] + return _solution(selected, "optimal" if result.success else "time_limit_feasible") + + +def nsga2_assignments( + candidates: list[AssignmentCandidate], + capacities: dict[str, dict[str, float]], + *, + seed: int = 20260718, + population_size: int = 64, + generations: int = 80, +) -> list[AssignmentSolution]: + """Deterministic compact NSGA-II baseline for offline experiments.""" + by_task: dict[str, list[AssignmentCandidate]] = {} + for item in candidates: + by_task.setdefault(item.task_id, []).append(item) + tasks = sorted(by_task) + if not tasks: + return [] + rng = random.Random(seed) + + def random_genome() -> list[int]: + return [rng.randrange(-1, len(by_task[task_id])) for task_id in tasks] + + def decode(genome: list[int]) -> AssignmentSolution: + remaining = {node: dict(values) for node, values in capacities.items()} + selected = [] + order = sorted(range(len(tasks)), key=lambda index: max((item.utility for item in by_task[tasks[index]]), default=0), reverse=True) + for index in order: + choice = genome[index] + if choice < 0: + continue + item = by_task[tasks[index]][choice % len(by_task[tasks[index]])] + if all(float(item.demand.get(key, 0.0)) <= float(remaining[item.node_id].get(key, 0.0)) + 1e-9 for key in RESOURCE_KEYS): + selected.append(item) + for key in RESOURCE_KEYS: + remaining[item.node_id][key] = float(remaining[item.node_id].get(key, 0.0)) - float(item.demand.get(key, 0.0)) + return _solution(selected, "nsga2") + + population = [random_genome() for _ in range(max(8, population_size))] + for _ in range(max(1, generations)): + evaluated = [(genome, decode(genome)) for genome in population] + front = _nondominated([solution for _, solution in evaluated]) + elite_ids = {id(solution) for solution in front} + elites = [genome for genome, solution in evaluated if id(solution) in elite_ids] + if not elites: + elites = [max(evaluated, key=lambda pair: (pair[1].assigned_count, pair[1].utility))[0]] + next_population = [list(genome) for genome in elites[:population_size]] + while len(next_population) < population_size: + left, right = rng.choice(elites), rng.choice(elites) + split = rng.randrange(1, len(tasks)) if len(tasks) > 1 else 1 + child = list(left[:split] + right[split:]) + if rng.random() < 0.35: + index = rng.randrange(len(tasks)) + child[index] = rng.randrange(-1, len(by_task[tasks[index]])) + next_population.append(child) + population = next_population + return _nondominated([decode(genome) for genome in population]) + + +def _solution(selected: list[AssignmentCandidate], status: str) -> AssignmentSolution: + objectives: dict[str, float] = {} + for item in selected: + for key, value in item.objectives.items(): + objectives[key] = objectives.get(key, 0.0) + float(value) + return AssignmentSolution(selected, objectives, len(selected), sum(item.utility for item in selected), status) + + +def _nondominated(solutions: list[AssignmentSolution]) -> list[AssignmentSolution]: + keys = sorted({key for solution in solutions for key in solution.objective_totals}) + result = [] + for current in solutions: + current_vector = [current.assigned_count, current.utility, *(current.objective_totals.get(key, 0.0) for key in keys)] + dominated = False + for other in solutions: + if other is current: + continue + other_vector = [other.assigned_count, other.utility, *(other.objective_totals.get(key, 0.0) for key in keys)] + if all(a >= b - 1e-12 for a, b in zip(other_vector, current_vector)) and any(a > b + 1e-12 for a, b in zip(other_vector, current_vector)): + dominated = True + break + if not dominated: + result.append(current) + unique: dict[tuple[tuple[str, str], ...], AssignmentSolution] = {} + for solution in result: + key = tuple(sorted((item.task_id, item.node_id) for item in solution.selected)) + unique[key] = solution + return list(unique.values()) diff --git a/src/tianjun/experiments/report.py b/src/tianjun/experiments/report.py new file mode 100644 index 0000000..9b8a33f --- /dev/null +++ b/src/tianjun/experiments/report.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import argparse +import csv +import json +import math +import statistics +from collections import defaultdict +from pathlib import Path +from typing import Any + + +METRICS = ( + "acceptance_rate", + "predicted_makespan", + "predicted_carbon_g", + "predicted_carbon_g_per_assignment", + "predicted_sla_violations", + "future_fit_after", + "future_fit_loss", + "future_fit_loss_per_assignment", + "plan_utility", + "decision_time_ms", + "baseline_acceptance_delta", + "baseline_carbon_reduction", + "baseline_carbon_per_assignment_reduction", +) + +T_CRITICAL_95 = { + 1: 12.706, 2: 4.303, 3: 3.182, 4: 2.776, 5: 2.571, + 6: 2.447, 7: 2.365, 8: 2.306, 9: 2.262, 10: 2.228, + 11: 2.201, 12: 2.179, 13: 2.160, 14: 2.145, 15: 2.131, + 16: 2.120, 17: 2.110, 18: 2.101, 19: 2.093, 20: 2.086, + 21: 2.080, 22: 2.074, 23: 2.069, 24: 2.064, 25: 2.060, + 26: 2.056, 27: 2.052, 28: 2.048, 29: 2.045, 30: 2.042, +} + + +def summarize(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + grouped: dict[tuple[Any, ...], list[dict[str, Any]]] = defaultdict(list) + for row in rows: + key = ( + row.get("experiment_label", row.get("strategy", "unknown")), + row.get("objective_scope", "flat_full"), + row.get("node_count"), + row.get("batch_task_count"), + row.get("load_rate"), + row.get("workload"), + row.get("fragmentation_mode", "uniform"), + ) + grouped[key].append(row) + + output: list[dict[str, Any]] = [] + for key, samples in sorted(grouped.items(), key=lambda item: tuple(str(value) for value in item[0])): + label, scope, nodes, tasks, load, workload, fragmentation_mode = key + result: dict[str, Any] = { + "experiment_label": label, + "objective_scope": scope, + "active_objectives": "|".join(str(item) for item in samples[0].get("active_objectives") or []), + "node_count": nodes, + "batch_task_count": tasks, + "load_rate": load, + "workload": workload, + "fragmentation_mode": fragmentation_mode, + "sample_count": len(samples), + } + for metric in METRICS: + values = [float(row[metric]) for row in samples if row.get(metric) is not None] + if not values: + result[f"{metric}_mean"] = None + result[f"{metric}_ci95"] = None + continue + result[f"{metric}_mean"] = statistics.fmean(values) + result[f"{metric}_ci95"] = ( + T_CRITICAL_95.get(len(values) - 1, 1.96) + * statistics.stdev(values) + / math.sqrt(len(values)) + if len(values) > 1 + else 0.0 + ) + output.append(result) + return output + + +def write_csv(rows: list[dict[str, Any]], path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fieldnames = list(rows[0]) if rows else [] + with path.open("w", encoding="utf-8-sig", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + + +def write_markdown(rows: list[dict[str, Any]], path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + lines = [ + "# 天钧引擎目标消融实验汇总", + "", + "每个单元格按相同节点数、任务数、负载和工作负载聚合随机种子;`±` 后为 95% 置信区间。", + "", + "| 实验 | 范围 | 场景 | n | 接纳率 | 运行碳(g) | 碳/接纳任务(g) | Future-Fit | FF损失/接纳任务 | 方案效用 | 决策时间(ms) |", + "|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:|", + ] + for row in rows: + scenario = ( + f"N{row['node_count']}/T{row['batch_task_count']}/" + f"L{float(row['load_rate']):.2f}/{row['workload']}/{row['fragmentation_mode']}" + ) + lines.append( + "| {label} | {scope} | {scenario} | {n} | {acceptance} | {carbon} | {carbon_per_task} | {future_fit} | {future_fit_loss} | {utility} | {latency} |".format( + label=row["experiment_label"], + scope=row["objective_scope"], + scenario=scenario, + n=row["sample_count"], + acceptance=_mean_ci(row, "acceptance_rate", 4), + carbon=_mean_ci(row, "predicted_carbon_g", 3), + carbon_per_task=_mean_ci(row, "predicted_carbon_g_per_assignment", 4), + future_fit=_mean_ci(row, "future_fit_after", 4), + future_fit_loss=_mean_ci(row, "future_fit_loss_per_assignment", 5), + utility=_mean_ci(row, "plan_utility", 4), + latency=_mean_ci(row, "decision_time_ms", 2), + ) + ) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _mean_ci(row: dict[str, Any], metric: str, digits: int) -> str: + mean = row.get(f"{metric}_mean") + ci = row.get(f"{metric}_ci95") + if mean is None: + return "--" + return f"{float(mean):.{digits}f} ± {float(ci or 0):.{digits}f}" + + +def main() -> None: + parser = argparse.ArgumentParser(description="Aggregate objective-ablation results with 95% confidence intervals.") + parser.add_argument("input", help="Raw JSON produced by tianjun.experiments.runner") + parser.add_argument("--csv", default="exp_out/summary.csv") + parser.add_argument("--markdown", default="exp_out/summary.md") + args = parser.parse_args() + rows = json.loads(Path(args.input).read_text(encoding="utf-8")) + summary = summarize(rows) + write_csv(summary, Path(args.csv)) + write_markdown(summary, Path(args.markdown)) + print(f"wrote {len(summary)} aggregate rows to {args.csv} and {args.markdown}") + + +if __name__ == "__main__": + main() diff --git a/src/tianjun/experiments/runner.py b/src/tianjun/experiments/runner.py new file mode 100644 index 0000000..fb9897b --- /dev/null +++ b/src/tianjun/experiments/runner.py @@ -0,0 +1,427 @@ +from __future__ import annotations + +import argparse +import json +import random +import time +from dataclasses import asdict, dataclass +from itertools import combinations, product +from pathlib import Path +from typing import Any, Iterable + +from ..application.control_plane import CentralControlPlane +from ..domain import CarbonSiteProfile, Node, PowerProfile, ResourceVector, RunningTask + + +@dataclass(slots=True) +class ExperimentResult: + schema_version: str + seed: int + node_count: int + batch_task_count: int + load_rate: float + workload: str + fragmentation_mode: str + strategy: str + assigned_tasks: int + unassigned_tasks: int + acceptance_rate: float + predicted_makespan: int + predicted_cost: float + predicted_energy_kwh: float + predicted_carbon_g: float + predicted_carbon_g_per_assignment: float + predicted_sla_violations: int + future_fit_before: float + future_fit_after: float + future_fit_loss: float + future_fit_loss_per_assignment: float + decision_time_ms: float + wall_time_ms: float + plan_utility: float = 0.0 + group_objective_breakdown: dict[str, float] | None = None + intent_weights: dict[str, float] | None = None + group_weights: dict[str, float] | None = None + security_risk_penalty: float = 0.0 + experiment_label: str = "" + objective_scope: str = "flat_full" + active_objectives: list[str] | None = None + objective_hierarchy_version: str = "flat-ten-v1" + carbon_scope: str = "operational_only" + normalization_bounds_version: str = "engineering-v1" + baseline_carbon_reduction: float | None = None + baseline_carbon_per_assignment_reduction: float | None = None + baseline_acceptance_delta: float | None = None + + +def run_matrix(config: dict[str, Any], *, quick: bool = False) -> list[ExperimentResult]: + node_counts = _selection(config, "node_counts", quick) + task_counts = _selection(config, "batch_task_counts", quick) + load_rates = _selection(config, "load_rates", quick) + workloads = _selection(config, "workloads", quick) + fragmentation_modes = _selection(config, "fragmentation_modes", quick) or ["uniform"] + seeds = _selection(config, "seeds", quick) + strategies = list(config.get("online_strategies") or []) + if not quick: + strategies.extend(config.get("offline_strategies") or []) + cases = _experiment_cases(strategies, config, quick=quick) + + results: list[ExperimentResult] = [] + for node_count, task_count, load_rate, workload, fragmentation_mode, seed in product( + node_counts, task_counts, load_rates, workloads, fragmentation_modes, seeds + ): + for case in cases: + strategy = case["strategy"] + if strategy == "B2-milp-oracle" and (task_count > 20 or node_count > 20): + continue + control = CentralControlPlane() + for node in synthetic_nodes( + int(node_count), + float(load_rate), + int(seed), + fragmentation_mode=str(fragmentation_mode), + ): + control.register_node(node) + payload = synthetic_batch( + int(task_count), + str(workload), + int(seed), + case["label"], + intent_weights=case.get("intent_weights"), + ) + imported = control.import_task_batch(payload) + started = time.perf_counter() + plan = control.preview_batch_schedule(imported["batch_id"], { + "strategy": strategy, + "experiment_mode": strategy in {"B2-milp-oracle", "B5-nsga2"}, + "active_metrics": case.get("active_metrics"), + "active_groups": case.get("active_groups"), + "group_weights": case.get("group_weights"), + }) + wall_time_ms = (time.perf_counter() - started) * 1000.0 + assigned = len(plan["task_node_assignments"]) + result = ExperimentResult( + schema_version=str(config.get("schema_version", "1.0")), + seed=int(seed), + node_count=int(node_count), + batch_task_count=int(task_count), + load_rate=float(load_rate), + workload=str(workload), + fragmentation_mode=str(fragmentation_mode), + strategy=strategy, + assigned_tasks=assigned, + unassigned_tasks=len(plan["unassigned_tasks"]), + acceptance_rate=assigned / max(1, int(task_count)), + predicted_makespan=int(plan["predicted_makespan"]), + predicted_cost=float(plan["predicted_cost"]), + predicted_energy_kwh=float(plan["predicted_energy_kwh"]), + predicted_carbon_g=float(plan["predicted_carbon_g"]), + predicted_carbon_g_per_assignment=( + float(plan["predicted_carbon_g"]) / max(1, assigned) + ), + predicted_sla_violations=int(plan["predicted_sla_violations"]), + future_fit_before=float(plan["future_fit_before"]), + future_fit_after=float(plan["future_fit_after"]), + future_fit_loss=max( + 0.0, + float(plan["future_fit_before"]) - float(plan["future_fit_after"]), + ), + future_fit_loss_per_assignment=( + max(0.0, float(plan["future_fit_before"]) - float(plan["future_fit_after"])) + / max(1, assigned) + ), + decision_time_ms=float(plan["decision_time_ms"]), + wall_time_ms=wall_time_ms, + plan_utility=float(plan.get("plan_utility", 0.0)), + group_objective_breakdown={ + str(key): float(value) + for key, value in dict(plan.get("group_objective_breakdown") or {}).items() + }, + intent_weights={ + str(key): float(value) + for key, value in dict(case.get("intent_weights") or {}).items() + }, + group_weights={ + str(key): float(value) + for key, value in dict(case.get("group_weights") or {}).items() + }, + security_risk_penalty=float(plan.get("security_risk_penalty", 0.0)), + experiment_label=case["label"], + objective_scope=case["scope"], + active_objectives=list(plan.get("active_objectives") or []), + objective_hierarchy_version=str(plan.get("objective_hierarchy_version", "flat-ten-v1")), + carbon_scope=str(config.get("carbon_scope", "operational_only")), + normalization_bounds_version=str(config.get("normalization_bounds_version", "engineering-v1")), + ) + results.append(result) + _attach_baseline_deltas(results) + return results + + +def _experiment_cases( + strategies: list[str], + config: dict[str, Any], + *, + quick: bool, +) -> list[dict[str, Any]]: + cases = [ + { + "label": strategy, + "strategy": strategy, + "scope": "hierarchical_full" if strategy == "B6-hierarchical-batch" else "flat_full", + } + for strategy in strategies + ] + profiles = dict(config.get("objective_experiments") or {}) + single_atomic = list(profiles.get("single_atomic") or []) + dual_atomic_config = profiles.get("dual_atomic") or [] + single_groups = list(profiles.get("single_groups") or []) + dual_groups_config = profiles.get("dual_groups") or [] + dual_atomic = ( + list(combinations(single_atomic, 2)) + if dual_atomic_config == "all" + else list(dual_atomic_config) + ) + dual_groups = ( + list(combinations(single_groups, 2)) + if dual_groups_config == "all" + else list(dual_groups_config) + ) + if quick: + single_atomic = single_atomic[:1] + dual_atomic = dual_atomic[:1] + single_groups = single_groups[:1] + dual_groups = dual_groups[:1] + cases.extend({ + "label": f"S1-{metric}", + "strategy": "B4-pareto-tchebycheff", + "scope": "single_atomic", + "active_metrics": [metric], + } for metric in single_atomic) + cases.extend({ + "label": f"S2-{'+'.join(pair)}", + "strategy": "B4-pareto-tchebycheff", + "scope": "dual_atomic", + "active_metrics": list(pair), + } for pair in dual_atomic) + cases.extend({ + "label": f"G1-{group}", + "strategy": "B6-hierarchical-batch", + "scope": "single_group", + "active_groups": [group], + } for group in single_groups) + cases.extend({ + "label": f"G2-{'+'.join(pair)}", + "strategy": "B6-hierarchical-batch", + "scope": "dual_group", + "active_groups": list(pair), + } for pair in dual_groups) + cases.extend({ + "label": str(profile["label"]), + "strategy": str(profile.get("strategy") or "B6-hierarchical-batch"), + "scope": "weight_calibration", + "active_groups": list(profile.get("active_groups") or []) or None, + "intent_weights": { + str(key): float(value) + for key, value in dict(profile.get("intent_weights") or {}).items() + }, + "group_weights": { + str(key): float(value) + for key, value in dict(profile.get("group_weights") or {}).items() + }, + } for profile in list(config.get("weight_profiles") or [])) + return cases + + +def synthetic_nodes( + count: int, + load_rate: float, + seed: int, + *, + fragmentation_mode: str = "uniform", +) -> Iterable[Node]: + rng = random.Random(seed) + regions = ("east", "north", "west") + carbon_levels = (180.0, 430.0, 690.0) + for index in range(count): + region_index = index % len(regions) + gpu = 8.0 if index % 4 == 0 else 0.0 + capacity = ResourceVector( + cpu=32, + memory=128, + gpu=gpu, + storage=2000, + mips=96_000, + gpu_memory=gpu * 24, + storage_iops=120_000, + bandwidth=25_000, + ) + node = Node( + node_id=f"node-{index:03d}", + region=regions[region_index], + labels={"cloudsim", "cpu", *( {"gpu"} if gpu else set() )}, + capacity=capacity, + cost_per_tick=0.7 + region_index * 0.25 + rng.random() * 0.1, + base_reliability=0.96 + rng.random() * 0.035, + performance_factors={"batch_cpu": 0.9 + rng.random() * 0.4, "training": 1.2 + rng.random()}, + power_profile=PowerProfile( + profile_id=f"power-{index % 4}", + idle_power_w=90 + index % 4 * 15, + max_power_w=260 + index % 4 * 35, + gpu_idle_power_w=25, + gpu_max_power_w=320, + ), + carbon_profile=CarbonSiteProfile( + site_id=f"site-{region_index}", + region=regions[region_index], + pue=1.15 + region_index * 0.12, + carbon_intensity_g_per_kwh=carbon_levels[region_index], + carbon_intensity_trace={0: carbon_levels[region_index], 60: carbon_levels[region_index] * 0.72}, + source_version="synthetic-experiment-v1", + ), + ) + if load_rate > 0: + allocation = _background_allocation( + capacity, + load_rate, + index, + fragmentation_mode=fragmentation_mode, + ) + node.running_tasks[f"background-{index}"] = RunningTask( + task_id=f"background-{index}", + node_id=node.node_id, + allocation=allocation, + start_tick=0, + predicted_duration=10_000, + actual_duration=0, + finish_tick=10_000, + success_probability=1.0, + ) + yield node + + +def synthetic_batch( + count: int, + workload: str, + seed: int, + strategy: str, + *, + intent_weights: dict[str, float] | None = None, +) -> dict[str, Any]: + rng = random.Random(seed) + tasks = [] + workload_types = ["cpu", "gpu", "memory", "data"] if workload == "mixed" else [workload] + for index in range(count): + kind = workload_types[index % len(workload_types)] + demand = { + "cpu": rng.choice([1, 2, 4, 8]), + "memory": rng.choice([2, 4, 8, 16]), + "gpu": 1 if kind == "gpu" else 0, + "storage": rng.choice([5, 10, 20, 40]), + "mips": 0, + "gpu_memory": 8 if kind == "gpu" else 0, + "storage_iops": rng.choice([500, 1_000, 2_500, 5_000]), + "bandwidth": rng.choice([100, 250, 500, 1_000]), + } + demand["mips"] = demand["cpu"] * rng.choice([1_000, 1_500, 2_000]) + if kind == "memory": + demand["memory"] *= 3 + tasks.append({ + "task_id": f"task-{index:04d}", + "task_type": "training" if kind == "gpu" else "batch_cpu", + "demand": demand, + "estimated_duration": rng.randint(10, 90), + "priority": rng.randint(3, 9), + "input_size_gb": rng.uniform(4, 25) if kind == "data" else rng.uniform(0.1, 2), + "carbon_priority": 0.7 if index % 3 == 0 else 0.2, + "allow_region_shift": True, + "require_encrypted_transport": True, + }) + return { + "client_batch_id": f"exp-{seed}-{workload}-{count}-{strategy}", + "batch_name": f"{workload}-{count}-{strategy}", + "batch_preferences": { + "intent_weights": dict(intent_weights or {"carbon": 0.2, "fragmentation": 0.15}) + }, + "tasks": tasks, + } + + +def _background_allocation( + capacity: ResourceVector, + load_rate: float, + node_index: int, + *, + fragmentation_mode: str, +) -> ResourceVector: + if fragmentation_mode == "uniform": + fractions = {key: load_rate for key in capacity.to_dict()} + elif fragmentation_mode == "heterogeneous": + # Four complementary background shapes create CPU-, memory-, GPU/IO- + # and network-heavy holes while keeping the fleet-wide pressure close + # to the requested load rate. + delta = min(0.16, max(0.08, (1.0 - load_rate) * 1.5)) + low = max(0.0, load_rate - delta) + high = min(0.985, load_rate + delta) + fractions_by_shape = ( + {"cpu": high, "mips": high, "memory": low, "gpu": load_rate, "gpu_memory": load_rate, "storage": low, "storage_iops": low, "bandwidth": load_rate}, + {"cpu": low, "mips": low, "memory": high, "gpu": load_rate, "gpu_memory": load_rate, "storage": load_rate, "storage_iops": load_rate, "bandwidth": low}, + {"cpu": load_rate, "mips": load_rate, "memory": low, "gpu": low, "gpu_memory": low, "storage": high, "storage_iops": high, "bandwidth": load_rate}, + {"cpu": low, "mips": low, "memory": load_rate, "gpu": high, "gpu_memory": high, "storage": low, "storage_iops": low, "bandwidth": high}, + ) + fractions = fractions_by_shape[node_index % len(fractions_by_shape)] + else: + raise ValueError(f"unknown fragmentation mode: {fragmentation_mode}") + values = capacity.to_dict() + return ResourceVector(**{ + key: float(value) * float(fractions[key]) + for key, value in values.items() + }) + + +def _selection(config: dict[str, Any], key: str, quick: bool) -> list[Any]: + values = list(config.get(key) or []) + return values[:1] if quick else values + + +def _attach_baseline_deltas(results: list[ExperimentResult]) -> None: + baselines = { + (item.seed, item.node_count, item.batch_task_count, item.load_rate, item.workload, item.fragmentation_mode): item + for item in results + if item.strategy == "B0-current" + } + for item in results: + baseline = baselines.get((item.seed, item.node_count, item.batch_task_count, item.load_rate, item.workload, item.fragmentation_mode)) + if baseline is None: + continue + item.baseline_acceptance_delta = item.acceptance_rate - baseline.acceptance_rate + if baseline.predicted_carbon_g > 0: + item.baseline_carbon_reduction = ( + baseline.predicted_carbon_g - item.predicted_carbon_g + ) / baseline.predicted_carbon_g + if baseline.predicted_carbon_g_per_assignment > 0: + item.baseline_carbon_per_assignment_reduction = ( + baseline.predicted_carbon_g_per_assignment + - item.predicted_carbon_g_per_assignment + ) / baseline.predicted_carbon_g_per_assignment + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run reproducible Tianjun batch scheduling experiments.") + parser.add_argument("--config", default="configs/batch_experiments.json") + parser.add_argument("--output", default="exp_out/results.json") + parser.add_argument("--quick", action="store_true", help="Run only the first online matrix cell.") + args = parser.parse_args() + config = json.loads(Path(args.config).read_text(encoding="utf-8")) + results = run_matrix(config, quick=args.quick) + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text( + json.dumps([asdict(item) for item in results], ensure_ascii=False, indent=2), + encoding="utf-8", + ) + print(f"wrote {len(results)} experiment rows to {output}") + + +if __name__ == "__main__": + main() diff --git a/src/tianjun/experiments/weights.py b/src/tianjun/experiments/weights.py new file mode 100644 index 0000000..eaf9321 --- /dev/null +++ b/src/tianjun/experiments/weights.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from math import log, sqrt +from typing import Iterable + + +def critic_weights(rows: Iterable[Iterable[float]]) -> list[float]: + """Return CRITIC objective weights for an already benefit-oriented matrix.""" + matrix = [list(map(float, row)) for row in rows] + if not matrix or not matrix[0]: + return [] + normalized = _column_minmax(matrix) + columns = list(map(list, zip(*normalized))) + std = [_std(column) for column in columns] + information = [] + for index, column in enumerate(columns): + conflict = sum(1.0 - _correlation(column, other) for other in columns) + information.append(std[index] * conflict) + return _normalize(information) + + +def entropy_weights(rows: Iterable[Iterable[float]]) -> list[float]: + """Return entropy weights for an already benefit-oriented matrix.""" + matrix = [list(map(float, row)) for row in rows] + if not matrix or not matrix[0]: + return [] + normalized = _column_minmax(matrix) + columns = list(map(list, zip(*normalized))) + count = len(matrix) + if count <= 1: + return [1.0 / len(columns)] * len(columns) + diversity = [] + for column in columns: + total = sum(column) + probabilities = [value / total for value in column] if total > 0 else [1.0 / count] * count + entropy = -sum(value * log(value) for value in probabilities if value > 0) / log(count) + diversity.append(max(0.0, 1.0 - entropy)) + return _normalize(diversity) + + +def _column_minmax(matrix: list[list[float]]) -> list[list[float]]: + columns = list(map(list, zip(*matrix))) + bounds = [(min(column), max(column)) for column in columns] + return [ + [(value - low) / (high - low) if high > low else 1.0 for value, (low, high) in zip(row, bounds)] + for row in matrix + ] + + +def _std(values: list[float]) -> float: + mean = sum(values) / len(values) + return sqrt(sum((value - mean) ** 2 for value in values) / len(values)) + + +def _correlation(left: list[float], right: list[float]) -> float: + left_mean = sum(left) / len(left) + right_mean = sum(right) / len(right) + numerator = sum((a - left_mean) * (b - right_mean) for a, b in zip(left, right)) + denominator = sqrt(sum((a - left_mean) ** 2 for a in left) * sum((b - right_mean) ** 2 for b in right)) + return numerator / denominator if denominator > 1e-12 else 0.0 + + +def _normalize(values: list[float]) -> list[float]: + total = sum(max(0.0, value) for value in values) + if total <= 1e-12: + return [1.0 / len(values)] * len(values) + return [max(0.0, value) / total for value in values] diff --git a/src/tianjun/integrations/mcp_server.py b/src/tianjun/integrations/mcp_server.py index 13480e4..7ca46a5 100644 --- a/src/tianjun/integrations/mcp_server.py +++ b/src/tianjun/integrations/mcp_server.py @@ -24,16 +24,18 @@ def from_env(cls) -> "TianjunHttpClient": auth_token=os.environ.get("TIANJUN_AUTH_TOKEN"), ) - def get(self, path: str) -> dict[str, Any]: - return self._request("GET", path) + def get(self, path: str, *, tool_name: str | None = None) -> dict[str, Any]: + return self._request("GET", path, tool_name=tool_name) - def post(self, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: - return self._request("POST", path, payload or {}) + def post(self, path: str, payload: dict[str, Any] | None = None, *, tool_name: str | None = None) -> dict[str, Any]: + return self._request("POST", path, payload or {}, tool_name=tool_name) - def _request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + def _request(self, method: str, path: str, payload: dict[str, Any] | None = None, *, tool_name: str | None = None) -> dict[str, Any]: url = f"{self.base_url.rstrip('/')}/{path.lstrip('/')}" data = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8") - headers = {"Content-Type": "application/json"} + headers = {"Content-Type": "application/json", "X-Tianjun-Caller": "external_mcp"} + if tool_name: + headers["X-Tianjun-Tool"] = tool_name if self.auth_token: headers["Authorization"] = f"Bearer {self.auth_token}" request = urllib.request.Request(url, data=data, headers=headers, method=method) @@ -169,6 +171,67 @@ def schedule_pending_task(task_id: str, confirmed: bool = False) -> dict[str, An } return http.post(f"/tasks/{task_id}/schedule", {"confirmed": True}) + @tool + def import_task_batch( + batch_name: str, + tasks: list[dict[str, Any]], + client_batch_id: str = "", + defaults: dict[str, Any] | None = None, + batch_preferences: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """导入并原子校验JSON批任务;不会立即调度或创建租约。""" + return http.post("/task-batches/import", { + "batch_name": batch_name, + "client_batch_id": client_batch_id, + "defaults": defaults or {}, + "batch_preferences": batch_preferences or {}, + "tasks": tasks, + }, tool_name="import_task_batch") + + @tool + def get_task_batch(batch_id: str) -> dict[str, Any]: + """读取批次、任务、最近预演方案和执行状态。""" + return http.get(f"/task-batches/{batch_id}", tool_name="get_task_batch") + + @tool + def get_batch_actual_metrics(batch_id: str) -> dict[str, Any]: + """Read measured JCT, makespan, utilization, energy and operational carbon for an executed batch.""" + return http.get( + f"/task-batches/{batch_id}/metrics", + tool_name="get_batch_actual_metrics", + ) + + @tool + def preview_batch_schedule(batch_id: str, strategy: str = "B4-pareto-tchebycheff") -> dict[str, Any]: + """联合预演整个批次,返回任务节点映射、碳排放、SLA和碎片指标。""" + return http.post(f"/task-batches/{batch_id}/preview", {"strategy": strategy}, tool_name="preview_batch_schedule") + + @tool + def compare_batch_strategies(batch_id: str, strategies: list[str] | None = None) -> dict[str, Any]: + """比较B0、B1、B3和B4等批调度策略,不创建租约。""" + return http.post(f"/task-batches/{batch_id}/compare", {"strategies": strategies or []}, tool_name="compare_batch_strategies") + + @tool + def commit_batch_schedule( + batch_id: str, + plan_id: str, + resource_snapshot_version: int, + confirmed: bool = False, + ) -> dict[str, Any]: + """确认批方案。只有用户明确确认后才会原子预留资源并创建租约。""" + if not confirmed: + return { + "status": "need_confirmation", + "batch_id": batch_id, + "plan_id": plan_id, + "message": "批调度将创建多个执行租约;请先确认,再以 confirmed=true 调用。", + } + return http.post(f"/task-batches/{batch_id}/commit", { + "plan_id": plan_id, + "resource_snapshot_version": resource_snapshot_version, + "confirmed": True, + }, tool_name="commit_batch_schedule") + return mcp diff --git a/src/tianjun/interfaces/dashboard/static/css/base.css b/src/tianjun/interfaces/dashboard/static/css/base.css index 3d46d4b..14d1c78 100644 --- a/src/tianjun/interfaces/dashboard/static/css/base.css +++ b/src/tianjun/interfaces/dashboard/static/css/base.css @@ -1,4 +1,5 @@ * { box-sizing: border-box; } +[hidden] { display: none !important; } html { min-height: 100%; background: var(--color-bg-page); } body { margin: 0; @@ -13,6 +14,7 @@ body { var(--color-bg-page); background-size: 28px 28px; background-blend-mode: soft-light; + overflow-x: hidden; } h1, h2, h3, h4, p { margin: 0; } button, input, textarea { font: inherit; } diff --git a/src/tianjun/interfaces/dashboard/static/css/nav.css b/src/tianjun/interfaces/dashboard/static/css/nav.css index 16e152e..76a86d9 100644 --- a/src/tianjun/interfaces/dashboard/static/css/nav.css +++ b/src/tianjun/interfaces/dashboard/static/css/nav.css @@ -17,7 +17,7 @@ color: var(--color-primary-900); white-space: nowrap; } -.topnav-tabs, .topnav-status { display: flex; gap: var(--space-sm); align-items: center; } +.topnav-tabs, .topnav-status { display: flex; min-width: 0; gap: var(--space-sm); align-items: center; } .topnav-tabs { min-width: 0; overflow-x: auto; } .tab-btn { height: 52px; @@ -66,3 +66,7 @@ .topnav-status { flex-wrap: wrap; } .alert-banner { top: 112px; } } + +@media (max-width: 480px) { + .topnav-status .badge { max-width: 8.5rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +} diff --git a/src/tianjun/interfaces/dashboard/static/css/pages/model.css b/src/tianjun/interfaces/dashboard/static/css/pages/model.css index 09626c2..0079286 100644 --- a/src/tianjun/interfaces/dashboard/static/css/pages/model.css +++ b/src/tianjun/interfaces/dashboard/static/css/pages/model.css @@ -47,6 +47,33 @@ gap: var(--space-xs); } +.group-sliders { + margin-top: var(--space-md); +} + +.group-slider-row { + border-left: 3px solid var(--color-primary-500); + background: linear-gradient(90deg, rgba(79, 70, 229, .06), var(--color-bg-card-alt) 38%); +} + +.atomic-weight-details { + margin-top: var(--space-md); + padding: var(--space-sm); + border: .0625rem solid var(--color-gray-200); + border-radius: var(--radius-md); + background: var(--color-bg-card-alt); +} + +.atomic-weight-details summary { + cursor: pointer; + color: var(--color-gray-800); + font-weight: 750; +} + +.atomic-weight-details > p { + margin: var(--space-sm) 0; +} + .slider-row { display: grid; grid-template-columns: minmax(0, .22fr) minmax(0, 1fr) minmax(0, .16fr); @@ -265,3 +292,23 @@ flex: 1; } } +.weight-source-panel { + display: grid; + gap: var(--space-sm); + padding: var(--space-md); + border: .0625rem solid var(--color-gray-200); + border-radius: var(--radius-md); + background: var(--color-bg-card-alt); +} + +.weight-source-panel h3 { color: var(--color-gray-900); font-size: .95rem; } +.weight-source-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-sm); } +.weight-source-card { display: grid; gap: .35rem; min-width: 0; padding: .7rem; border-radius: var(--radius-sm); background: var(--color-bg-card); } +.weight-source-card div { display: flex; align-items: baseline; justify-content: space-between; gap: .5rem; } +.weight-source-card b { color: var(--color-primary-700); font-family: var(--font-mono); } +.weight-source-card small { color: var(--color-gray-500); text-align: right; } +.weight-source-card p { color: var(--color-gray-700); font-size: .82rem; overflow-wrap: anywhere; } + +@media (max-width: 720px) { + .weight-source-grid { grid-template-columns: 1fr; } +} diff --git a/src/tianjun/interfaces/dashboard/static/css/pages/scheduling.css b/src/tianjun/interfaces/dashboard/static/css/pages/scheduling.css index b5c1137..9a2f5e3 100644 --- a/src/tianjun/interfaces/dashboard/static/css/pages/scheduling.css +++ b/src/tianjun/interfaces/dashboard/static/css/pages/scheduling.css @@ -224,6 +224,193 @@ background: var(--color-primary-50); } +.batch-mode-shell { + display: grid; + gap: var(--space-md); + margin-bottom: var(--space-lg); +} + +.mode-switch { + display: inline-flex; + justify-self: start; + padding: .25rem; + border: .0625rem solid var(--color-gray-200); + border-radius: 999rem; + background: var(--color-bg-card); +} + +.mode-btn { + min-width: 7rem; + border: 0; + border-radius: 999rem; + padding: .65rem 1rem; + background: transparent; + color: var(--color-gray-600); + font-weight: 700; +} + +.mode-btn.active { + background: var(--color-primary-500); + color: white; + box-shadow: 0 .35rem 1rem color-mix(in srgb, var(--color-primary-500) 24%, transparent); +} + +.batch-workbench, +.batch-plan-result { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + gap: var(--space-lg); +} + +.batch-workbench > * { min-width: 0; max-width: 100%; } + +.batch-workbench-head > * { min-width: 0; } +.batch-workbench-head p { overflow-wrap: anywhere; } + +.batch-workbench-head, +.batch-actions, +.batch-commit-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + flex-wrap: wrap; +} + +.eyebrow { + display: block; + margin-bottom: .35rem; + color: var(--color-teal-500); + font: 700 .72rem/1.2 ui-monospace, SFMono-Regular, Consolas, monospace; + letter-spacing: .14em; +} + +.batch-flow { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + overflow: hidden; + border: .0625rem solid var(--color-gray-200); + border-radius: var(--radius-md); +} + +.batch-flow span { + position: relative; + padding: .7rem var(--space-md); + color: var(--color-gray-600); + background: var(--color-bg-card-alt); + font-size: .86rem; + font-weight: 700; + text-align: center; +} + +.batch-flow span + span { border-left: .0625rem solid var(--color-gray-200); } +.batch-flow span.active { color: var(--color-primary-700); background: var(--color-primary-50); } + +.batch-dropzone { + display: grid; + place-items: center; + min-height: 10rem; + gap: .45rem; + border: .1rem dashed var(--color-primary-300); + border-radius: var(--radius-md); + background: linear-gradient(135deg, var(--color-primary-50), var(--color-teal-50)); + color: var(--color-gray-700); + cursor: pointer; + text-align: center; + transition: border-color var(--transition-fast), transform var(--transition-fast); +} + +.batch-dropzone:hover, +.batch-dropzone:focus, +.batch-dropzone.dragging { border-color: var(--color-teal-500); transform: translateY(-.1rem); outline: none; } +.batch-dropzone b { color: var(--color-gray-950); font-size: 1.05rem; } +.batch-dropzone span { color: var(--color-gray-600); } +.batch-dropzone span { max-width: 100%; padding-inline: var(--space-sm); overflow-wrap: anywhere; } + +.batch-import-state { + display: flex; + align-items: center; + gap: var(--space-sm); + min-height: 2.5rem; + padding: .65rem var(--space-md); + border-radius: var(--radius-sm); + background: var(--color-gray-50); +} +.batch-import-state p { margin: 0; overflow-wrap: anywhere; } +.status-beacon { width: .6rem; height: .6rem; border-radius: 50%; background: var(--color-gray-400); flex: 0 0 auto; } +.batch-import-state.working .status-beacon { background: var(--color-primary-500); box-shadow: 0 0 0 .3rem var(--color-primary-50); } +.batch-import-state.success .status-beacon { background: var(--color-success-500); } +.batch-import-state.error .status-beacon { background: var(--color-danger-500); } + +.batch-summary, +.batch-kpis { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-sm); +} + +.batch-summary > div, +.batch-kpis > div { + display: grid; + gap: .25rem; + min-width: 0; + padding: var(--space-md); + border: .0625rem solid var(--color-gray-200); + border-radius: var(--radius-sm); + background: var(--color-bg-card-alt); +} +.batch-summary span, +.batch-kpis span { color: var(--color-gray-600); font-size: .8rem; } +.batch-summary b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.batch-kpis b { color: var(--color-gray-950); font-size: 1.2rem; } +.batch-kpis small { color: var(--color-gray-500); font-size: .72rem; } + +.group-score-strip { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: var(--space-xs); + margin-top: var(--space-sm); +} + +.group-score-strip > div { + display: grid; + gap: 4px; + padding: 10px; + border: .0625rem solid rgba(79, 70, 229, .16); + border-radius: var(--radius-sm); + background: linear-gradient(145deg, rgba(79, 70, 229, .06), rgba(16, 185, 129, .05)); +} + +.group-score-strip span, +.group-score-strip small, +.hierarchy-note { color: var(--color-gray-500); font-size: .75rem; } + +.group-score-strip b { color: var(--color-primary-700); font-size: 1rem; } +.hierarchy-note { margin: var(--space-xs) 0 0; overflow-wrap: anywhere; } + +.batch-actions label { display: flex; align-items: center; gap: var(--space-sm); color: var(--color-gray-600); font-weight: 700; } +.batch-actions select { min-width: 15rem; padding: .65rem .8rem; border: .0625rem solid var(--color-gray-300); border-radius: var(--radius-sm); background: var(--color-bg-card); color: var(--color-gray-900); } + +.strategy-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); gap: var(--space-sm); } +.strategy-card { display: grid; gap: .35rem; padding: var(--space-md); text-align: left; border: .0625rem solid var(--color-gray-200); border-radius: var(--radius-sm); background: var(--color-bg-card-alt); color: var(--color-gray-700); } +.strategy-card.active { border-color: var(--color-teal-500); background: var(--color-teal-50); } +.strategy-card span { font-size: .78rem; color: var(--color-gray-600); } + +.assignment-table-wrap { max-height: 28rem; overflow: auto; border: .0625rem solid var(--color-gray-200); border-radius: var(--radius-sm); } +.assignment-table { width: 100%; min-width: 46rem; border-collapse: collapse; } +.assignment-table th { position: sticky; top: 0; z-index: 1; background: var(--color-gray-50); color: var(--color-gray-600); text-align: left; } +.assignment-table th, +.assignment-table td { padding: .75rem var(--space-md); border-bottom: .0625rem solid var(--color-gray-200); } + +.unassigned-list { display: flex; flex-wrap: wrap; gap: .45rem; } +.unassigned-list b { width: 100%; } +.unassigned-list span { padding: .35rem .55rem; border-radius: 999rem; background: var(--color-danger-50); color: var(--color-danger-700); font-size: .78rem; } +.batch-commit-bar { position: sticky; bottom: 0; z-index: 3; padding: var(--space-md); border: .0625rem solid var(--color-warning-500); border-radius: var(--radius-md); background: color-mix(in srgb, var(--color-warning-50) 94%, white); box-shadow: 0 -.5rem 1.5rem color-mix(in srgb, var(--color-gray-900) 8%, transparent); } +.batch-commit-bar p { display: grid; gap: .25rem; margin: 0; } +.batch-commit-bar span { color: var(--color-gray-600); font-size: .85rem; } + .md-body h3, .md-body h4 { margin: var(--space-sm) 0 var(--space-xs); @@ -457,6 +644,8 @@ } @media (max-width: 1024px) { + .batch-summary, + .batch-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } .node-compare-panel { display: none; } @@ -488,6 +677,20 @@ } @media (max-width: 720px) { + .hermes-fab { left: .75rem; right: auto; bottom: .75rem; max-width: calc(100vw - 1.5rem); } + .batch-flow { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .batch-flow span:nth-child(3) { border-left: 0; border-top: .0625rem solid var(--color-gray-200); } + .batch-flow span:nth-child(4) { border-top: .0625rem solid var(--color-gray-200); } + .batch-summary, + .batch-kpis { grid-template-columns: 1fr; } + .group-score-strip { grid-template-columns: 1fr; } + .batch-actions { align-items: stretch; } + .batch-actions label { flex-direction: column; align-items: stretch; } + .batch-actions label, + .batch-actions select, + .batch-actions button, + .batch-commit-bar button { width: 100%; } + .batch-commit-bar { bottom: .5rem; } .hermes-drawer .parse-panel .kv-grid, .hermes-drawer .context-grid, .hermes-drawer .hermes-runtime, diff --git a/src/tianjun/interfaces/dashboard/static/css/pages/topology.css b/src/tianjun/interfaces/dashboard/static/css/pages/topology.css index 6032948..65a8158 100644 --- a/src/tianjun/interfaces/dashboard/static/css/pages/topology.css +++ b/src/tianjun/interfaces/dashboard/static/css/pages/topology.css @@ -1214,3 +1214,38 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } } +.topology-layer-head, +.layer-switch, +.carbon-site-summary > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); +} + +.layer-switch { + padding: .2rem; + border: .0625rem solid var(--color-gray-200); + border-radius: 999rem; +} + +.layer-switch button { + border: 0; + border-radius: 999rem; + padding: .4rem .7rem; + background: transparent; + color: var(--color-gray-600); + font-weight: 700; +} + +.layer-switch button.active { background: var(--color-primary-500); color: white; } +.topology-canvas[data-layer="carbon"] { background-image: radial-gradient(circle at 72% 28%, color-mix(in srgb, var(--color-success-500) 13%, transparent), transparent 34%); } +.topology-canvas[data-layer="load"] { background-image: radial-gradient(circle at 34% 58%, color-mix(in srgb, var(--color-warning-500) 14%, transparent), transparent 35%); } +.carbon-site-summary { display: grid; gap: var(--space-sm); margin-top: var(--space-lg); } +.carbon-site-summary h3 { margin: 0; font-size: .92rem; } +.carbon-site-summary > div { padding: .65rem; border: .0625rem solid var(--color-gray-200); border-radius: var(--radius-sm); background: var(--color-bg-card-alt); font-size: .78rem; } + +@media (max-width: 720px) { + .topology-layer-head { align-items: flex-start; flex-direction: column; } + .carbon-site-summary > div { align-items: flex-start; flex-direction: column; } +} diff --git a/src/tianjun/interfaces/dashboard/static/index.html b/src/tianjun/interfaces/dashboard/static/index.html index ee441a5..4118279 100644 --- a/src/tianjun/interfaces/dashboard/static/index.html +++ b/src/tianjun/interfaces/dashboard/static/index.html @@ -29,6 +29,7 @@ 系统检查中 模型检查中 Hermes 检查中 + MCP 尚无调用 自动刷新中 --:--:-- diff --git a/src/tianjun/interfaces/dashboard/static/js/api.js b/src/tianjun/interfaces/dashboard/static/js/api.js index 74db25f..87ae8f1 100644 --- a/src/tianjun/interfaces/dashboard/static/js/api.js +++ b/src/tianjun/interfaces/dashboard/static/js/api.js @@ -24,10 +24,30 @@ export async function submitFeedback(payload) { return _post("/feedback", payloa export async function commitPolicy(payload) { return _post("/policies/commit", payload); } export async function updatePolicyWeights(payload) { return _post("/policy-weights", payload); } export async function cancelTaskRun(taskId, requeue = false) { return _post("/task-runs/cancel", { task_id: taskId, requeue }); } +export async function getTaskBatch(batchId) { return _get(`/task-batches/${encodeURIComponent(batchId)}`); } +export async function getTaskBatchMetrics(batchId) { return _get(`/task-batches/${encodeURIComponent(batchId)}/metrics`); } +export async function previewTaskBatch(batchId, payload = {}) { return _post(`/task-batches/${encodeURIComponent(batchId)}/preview`, payload); } +export async function compareTaskBatch(batchId, payload = {}) { return _post(`/task-batches/${encodeURIComponent(batchId)}/compare`, payload); } +export async function commitTaskBatch(batchId, payload) { return _post(`/task-batches/${encodeURIComponent(batchId)}/commit`, payload); } + +export async function importTaskBatch(file) { + const isCsv = file.type.includes("csv") || file.name.toLowerCase().endsWith(".csv"); + const body = await file.text(); + const path = isCsv + ? `/task-batches/import?name=${encodeURIComponent(file.name.replace(/\.csv$/i, ""))}` + : "/task-batches/import"; + const response = await fetch(BASE + path, { + method: "POST", + headers: { "Content-Type": isCsv ? "text/csv; charset=utf-8" : "application/json" }, + body, + }); + if (!response.ok) throw await responseError(response, `POST ${path}`); + return response.json(); +} async function _get(path) { const r = await fetch(BASE + path); - if (!r.ok) throw new Error(`GET ${path} -> ${r.status}`); + if (!r.ok) throw await responseError(r, `GET ${path}`); return r.json(); } @@ -37,6 +57,17 @@ async function _post(path, body) { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); - if (!r.ok) throw new Error(`POST ${path} -> ${r.status}`); + if (!r.ok) throw await responseError(r, `POST ${path}`); return r.json(); } + +async function responseError(response, operation) { + let detail = ""; + try { + const payload = await response.json(); + detail = payload.error || payload.message || payload.validation?.errors?.[0]?.reason || JSON.stringify(payload); + } catch (_) { + detail = await response.text(); + } + return new Error(`${operation} -> ${response.status}${detail ? `: ${detail}` : ""}`); +} diff --git a/src/tianjun/interfaces/dashboard/static/js/pages/model.js b/src/tianjun/interfaces/dashboard/static/js/pages/model.js index 9f5f9f9..ca0266e 100644 --- a/src/tianjun/interfaces/dashboard/static/js/pages/model.js +++ b/src/tianjun/interfaces/dashboard/static/js/pages/model.js @@ -3,15 +3,27 @@ import { emit, state } from "../state.js"; import { METRIC_KEYS, activeDecision, displayKey, escapeHtml, fmt, metricLabel, modelStatusText, pct, topWeights } from "../utils.js"; const defaultWeights = Object.fromEntries(METRIC_KEYS.map((key) => [key, 0.1])); +const GROUP_KEYS = ["sla_quality", "network_coordination", "resource_efficiency", "economic_cost", "green_carbon"]; +const groupMeta = { + sla_quality: ["SLA 与服务质量", "性能 / 完成时效 / 可靠性"], + network_coordination: ["网络与地域协同", "网络质量 / 地域局部性"], + resource_efficiency: ["资源效率", "负载均衡 / 未来可调度碎片"], + economic_cost: ["经济成本", "任务执行成本"], + green_carbon: ["绿色低碳", "运行碳排放"], +}; +const defaultGroupWeights = { sla_quality: 0.3, network_coordination: 0.2, resource_efficiency: 0.18, economic_cost: 0.12, green_carbon: 0.2 }; const templates = { - latency: ["低时延优先", { completion: 0.24, performance: 0.16, network: 0.16, reliability: 0.1, cost: 0.06, security: 0.05, balance: 0.08, fragmentation: 0.03, locality: 0.08 }], - cost: ["成本优先", { cost: 0.24, balance: 0.15, completion: 0.13, performance: 0.09, reliability: 0.06, network: 0.08, security: 0.04, fragmentation: 0.08, locality: 0.08 }], - stability: ["稳定性优先", { network: 0.22, reliability: 0.18, completion: 0.11, performance: 0.1, balance: 0.12, security: 0.08, cost: 0.05, fragmentation: 0.05, locality: 0.06 }], - security: ["安全优先", { security: 0.26, reliability: 0.16, network: 0.14, completion: 0.11, performance: 0.09, cost: 0.06, balance: 0.07, fragmentation: 0.04, locality: 0.05 }], - balanced: ["均衡策略", { completion: 0.14, performance: 0.12, network: 0.13, reliability: 0.11, cost: 0.11, security: 0.1, balance: 0.1, fragmentation: 0.06, locality: 0.08 }], + latency: ["低时延优先", { completion: 0.22, performance: 0.15, network: 0.15, reliability: 0.1, cost: 0.05, security: 0.05, balance: 0.08, fragmentation: 0.03, locality: 0.08, carbon: 0.09 }, { sla_quality: 0.5, network_coordination: 0.25, resource_efficiency: 0.1, economic_cost: 0.05, green_carbon: 0.1 }], + cost: ["成本优先", { cost: 0.23, balance: 0.13, completion: 0.12, performance: 0.08, reliability: 0.06, network: 0.07, security: 0.05, fragmentation: 0.08, locality: 0.08, carbon: 0.1 }, { sla_quality: 0.25, network_coordination: 0.15, resource_efficiency: 0.15, economic_cost: 0.35, green_carbon: 0.1 }], + stability: ["稳定性优先", { network: 0.2, reliability: 0.18, completion: 0.1, performance: 0.09, balance: 0.1, security: 0.08, cost: 0.05, fragmentation: 0.05, locality: 0.06, carbon: 0.09 }, { sla_quality: 0.45, network_coordination: 0.2, resource_efficiency: 0.15, economic_cost: 0.08, green_carbon: 0.12 }], + security: ["安全优先", { security: 0.25, reliability: 0.15, network: 0.12, completion: 0.1, performance: 0.08, cost: 0.05, balance: 0.06, fragmentation: 0.04, locality: 0.06, carbon: 0.09 }, { ...defaultGroupWeights }], + fragmentation: ["低碎片", { fragmentation: 0.25, balance: 0.16, performance: 0.1, completion: 0.1, cost: 0.06, reliability: 0.07, network: 0.06, locality: 0.05, security: 0.05, carbon: 0.1 }, { sla_quality: 0.25, network_coordination: 0.15, resource_efficiency: 0.35, economic_cost: 0.08, green_carbon: 0.17 }], + green: ["绿色优先", { carbon: 0.3, completion: 0.12, performance: 0.08, network: 0.08, reliability: 0.09, cost: 0.07, security: 0.08, balance: 0.07, fragmentation: 0.07, locality: 0.04 }, { sla_quality: 0.2, network_coordination: 0.15, resource_efficiency: 0.15, economic_cost: 0.1, green_carbon: 0.4 }], + balanced: ["均衡策略", { completion: 0.11, performance: 0.1, network: 0.11, reliability: 0.1, cost: 0.1, security: 0.1, balance: 0.1, fragmentation: 0.08, locality: 0.09, carbon: 0.11 }, { ...defaultGroupWeights }], }; let draftWeights = {}; +let draftGroupWeights = {}; let localTimeline = []; let selectedTemplate = ""; @@ -31,10 +43,19 @@ export function initModel() {
-

9 轴权重滑块

-

显示当前策略相对上次提交的变化量。

+

分层目标融合

+

先在五类业务目标内融合十维原子指标,再在目标组之间做 Pareto + Tchebycheff 排序;安全保留为硬约束与不可补偿风险惩罚。

-
+
+

权重来源分解

+
+
+
+
+ 十维原子指标(解释、单目标与双目标消融) +

这里编辑组内意图权重。正式实验会分别运行单指标、双指标、单目标组、双目标组和完整分层融合。

+
+

调整影响预估

@@ -52,6 +73,7 @@ export function initModel() { if (!button) return; selectedTemplate = button.dataset.template; draftWeights = { ...templates[selectedTemplate][1] }; + draftGroupWeights = { ...templates[selectedTemplate][2] }; renderModel(state.report, state.health); }); document.getElementById("previewWeights").addEventListener("click", previewWeights); @@ -67,6 +89,7 @@ export function renderModel(report, health) { const decision = activeDecision(report, state.intentPayload); const snap = decision?.network_snapshot ?? {}; const weights = currentWeights(report); + const groupWeights = currentGroupWeights(report); document.getElementById("modelRuntime").innerHTML = [ ["LSTM 时延预测", snap.model_prediction?.lstm_latency_ms !== undefined ? `${fmt(snap.model_prediction.lstm_latency_ms, 1)} ms` : modelStatusText(runtime)], ["GNN 拓扑稳定性", snap.fusion_features?.gnn_topology !== undefined ? pct(snap.fusion_features.gnn_topology, 1) : "--"], @@ -74,6 +97,8 @@ export function renderModel(report, health) { ["当前策略", decision?.policy_id ?? state.hermesPolicyId ?? "--"], ].map(([k, v]) => `
${escapeHtml(v)}
`).join(""); renderSliders(report, weights); + renderGroupSliders(groupWeights); + renderWeightSources(report); renderImpact(report, weights); renderHistory(report, runtime); } @@ -87,9 +112,43 @@ function renderModelLoading() { ].map(([k, v]) => `
${escapeHtml(v)}
`).join(""); document.getElementById("weightHistory").innerHTML = `
策略调整时间线加载中...
`; document.getElementById("weightSliders").innerHTML = `
权重滑块加载中...
`; + document.getElementById("groupWeightSliders").innerHTML = `
目标组权重加载中...
`; + document.getElementById("weightSourcePreview").innerHTML = `
权重来源加载中...
`; document.getElementById("impactPreview").innerHTML = `
调整影响评估加载中...
`; } +function renderWeightSources(report) { + const sources = report?.group_weight_sources ?? {}; + const rows = [ + ["W_intent^G", sources.intent, "用户、Hermes 或批次级目标偏好"], + ["W_SLA", sources.sla, "任务约束与紧迫度"], + ["W_data", sources.data, sources.data_method ?? "固定历史窗口"], + ["W_final", sources.final, "0.4 / 0.4 / 0.2 融合结果"], + ]; + document.getElementById("weightSourcePreview").innerHTML = rows.map(([label, values, note]) => ` +
+
${escapeHtml(label)}${escapeHtml(note)}
+

${values ? escapeHtml(formatTopGroupWeights(values)) : "--"}

+
`).join(""); +} + +function renderGroupSliders(weights) { + document.getElementById("groupWeightSliders").innerHTML = GROUP_KEYS.map((key) => { + const [label, members] = groupMeta[key]; + const value = Number(weights[key] ?? 0); + return ``; + }).join(""); + document.querySelectorAll("[data-group-weight]").forEach((input) => input.addEventListener("input", () => { + selectedTemplate = ""; + draftGroupWeights[input.dataset.groupWeight] = Number(input.value); + renderModel(state.report, state.health); + })); +} + function renderSliders(report, weights) { const previous = previousWeights(report); document.getElementById("strategyTemplates").querySelectorAll("[data-template]").forEach((button) => button.classList.toggle("active", button.dataset.template === selectedTemplate)); @@ -111,12 +170,11 @@ function renderSliders(report, weights) { function renderImpact(report, weights) { const active = activeTaskCount(report); - const latency = Number(weights.completion ?? 0) + Number(weights.performance ?? 0) + Number(weights.network ?? 0); - const cost = Number(weights.cost ?? 0); + const batch = state.selectedBatchPlan || report?.batch_scheduling?.recent_batches?.at?.(-1)?.latest_plan; const impact = [ - ["预计平均时延变化", latency > 0.48 ? "下降 8% - 14%" : "下降 2% - 6%"], - ["预计成本变化", cost > 0.18 ? "下降 5% - 10%" : "小幅波动"], - ["预计 SLA 达标率变化", latency > 0.42 ? "提升 5% - 11%" : "提升 1% - 4%"], + ["预演运行碳", batch ? `${fmt(batch.predicted_carbon_g, 3)} gCO₂e` : "运行批次预演后生成"], + ["预演能耗", batch ? `${fmt(batch.predicted_energy_kwh, 5)} kWh` : "运行批次预演后生成"], + ["预演 SLA 违规", batch ? `${fmt(batch.predicted_sla_violations, 0)} 个任务` : "运行批次预演后生成"], ["影响活跃任务数", `${active} 条正在执行 / 调度中任务`], ]; document.getElementById("impactPreview").innerHTML = impact.map(([label, value]) => `
${escapeHtml(value)}
`).join(""); @@ -172,6 +230,11 @@ function formatTopWeights(weights) { return topWeights(weights, 3).map(([key, value]) => `${metricLabel(key)} ${pct(value, 0)}`).join(" / "); } +function formatTopGroupWeights(weights) { + return Object.entries(weights).sort((a, b) => Number(b[1]) - Number(a[1])).slice(0, 3) + .map(([key, value]) => `${groupMeta[key]?.[0] ?? key} ${pct(value, 0)}`).join(" / "); +} + function adjustmentMetrics(metrics = {}) { const parts = []; if (metrics.sla_rate !== undefined) parts.push(`SLA ${pct(metrics.sla_rate, 0)}`); @@ -186,6 +249,10 @@ function currentWeights(report) { return { ...defaultWeights, ...(report?.policy_weights ?? {}), ...(decision?.weights ?? {}), ...draftWeights }; } +function currentGroupWeights(report) { + return { ...defaultGroupWeights, ...(report?.policy_group_weights ?? {}), ...draftGroupWeights }; +} + function previousWeights(report) { const history = report?.policy_history ?? report?.adjustment_history ?? []; return history.length ? history.at(-2)?.weights ?? history.at(-1)?.weights ?? {} : report?.policy_weights ?? {}; @@ -203,7 +270,7 @@ function deltaText(delta) { function previewWeights() { const banner = document.getElementById("alertBanner"); const weights = currentWeights(state.report); - banner.textContent = `当前前三项:${formatTopWeights(weights) || "暂无修改"}`; + banner.textContent = `目标组优先级:${formatTopGroupWeights(currentGroupWeights(state.report))};组内前三项:${formatTopWeights(weights) || "暂无修改"}`; banner.hidden = false; } @@ -214,6 +281,7 @@ async function submitWeights() { await updatePolicyWeights({ confirmed_by_user_button: true, weights: submittedWeights, + group_weights: currentGroupWeights(state.report), reason: "用户手动提交多维策略权重。", }); localTimeline.push({ @@ -227,6 +295,7 @@ async function submitWeights() { banner.textContent = "策略提交成功,控制面将在下一次刷新后展示最新结果。"; banner.hidden = false; draftWeights = {}; + draftGroupWeights = {}; selectedTemplate = ""; emit("report:refresh"); } catch (error) { diff --git a/src/tianjun/interfaces/dashboard/static/js/pages/overview.js b/src/tianjun/interfaces/dashboard/static/js/pages/overview.js index 7ec53c1..44d1d65 100644 --- a/src/tianjun/interfaces/dashboard/static/js/pages/overview.js +++ b/src/tianjun/interfaces/dashboard/static/js/pages/overview.js @@ -90,11 +90,17 @@ function renderMetrics(report) { const decision = activeDecision(report, state.intentPayload); const snap = decision?.network_snapshot ?? {}; const gnnValue = metrics.gnn_stability_score ?? snap.fusion_features?.gnn_topology ?? report.model_runtime?.latest_prediction?.gnn_stability_score; + const batches = report.batch_scheduling ?? {}; const cards = [ ["平均时延", `${fmt(metrics.average_stable_latency_ms ?? snap.deterministic_latency_ms, 1)} ms`, "primary", "LSTM 稳健时延预测"], ["GNN 稳定性", gnnValue === undefined ? "--" : pct(gnnValue, 1), "teal", "拓扑稳定性参与调度评分"], ["融合评分", fmt(metrics.average_fusion_score ?? snap.feature_fusion_score ?? decisionScore(decision), 3), "dark", decision ? `当前节点 ${decision.node_id}` : "等待调度决策"], ["在线节点", `${(report.nodes ?? []).filter((node) => node.online !== false).length}`, "good", "可参与资源分配的节点"], + ["批任务接纳率", pct(batches.batch_acceptance_rate ?? 0, 1), "teal", `${fmt(batches.total_batch_tasks ?? 0, 0)} 个批任务已纳管`], + ["运行碳", `${fmt(metrics.total_operational_carbon_g, 3)} g`, "good", `${fmt(metrics.total_energy_kwh, 5)} kWh · operational only`], + ["实际平均 JCT", `${fmt(metrics.average_actual_jct_seconds, 2)} s`, "primary", `P95 ${fmt(metrics.p95_actual_jct_seconds, 2)} s`], + ["实际 Makespan", `${fmt(metrics.actual_makespan_seconds, 2)} s`, "dark", `${fmt(metrics.completed_batch_count, 0)} 个批次已回传`], + ["实际资源利用率", pct(metrics.average_cpu_utilization ?? 0, 1), "teal", `内存 ${pct(metrics.average_memory_utilization ?? 0, 1)} · 带宽 ${pct(metrics.average_bandwidth_utilization ?? 0, 1)}`], ]; document.getElementById("overviewMetrics").innerHTML = cards.map(([label, value, tone, delta], index) => `
diff --git a/src/tianjun/interfaces/dashboard/static/js/pages/scheduling.js b/src/tianjun/interfaces/dashboard/static/js/pages/scheduling.js index 74d8e49..451f8a2 100644 --- a/src/tianjun/interfaces/dashboard/static/js/pages/scheduling.js +++ b/src/tianjun/interfaces/dashboard/static/js/pages/scheduling.js @@ -1,9 +1,11 @@ import { initChat, updateAgentRuntimeStatus } from "../chat.js"; +import { commitTaskBatch, compareTaskBatch, getTaskBatchMetrics, importTaskBatch, previewTaskBatch } from "../api.js"; import { state } from "../state.js"; import { activeDecision, compactText, decisionScore, escapeHtml, fmt, gnnDisplayState, nodeLoad, pct, stableLatencyOf } from "../utils.js"; let initialized = false; let selectedTaskId = null; +let schedulingMode = "single"; export function initScheduling() { document.getElementById("page-scheduling").innerHTML = ` @@ -13,6 +15,39 @@ export function initScheduling() {

当前决策队列、候选节点对比与资源占用预测。

+
+
+ + +
+ +

当前决策队列

@@ -79,10 +114,197 @@ export function initScheduling() { selectedTaskId = item.dataset.taskId; renderScheduling(state.report, state.health); }); + bindBatchWorkbench(); initChat(); initialized = true; } +function bindBatchWorkbench() { + document.querySelectorAll("[data-scheduling-mode]").forEach((button) => button.addEventListener("click", () => { + schedulingMode = button.dataset.schedulingMode; + document.querySelectorAll("[data-scheduling-mode]").forEach((item) => { + const active = item.dataset.schedulingMode === schedulingMode; + item.classList.toggle("active", active); + item.setAttribute("aria-selected", String(active)); + }); + document.getElementById("batchWorkbench").hidden = schedulingMode !== "batch"; + document.querySelector(".scheduling-engine").hidden = schedulingMode === "batch"; + })); + const input = document.getElementById("batchFile"); + const dropzone = document.getElementById("batchDropzone"); + input.addEventListener("change", () => input.files[0] && void handleBatchFile(input.files[0])); + dropzone.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") input.click(); + }); + ["dragenter", "dragover"].forEach((name) => dropzone.addEventListener(name, (event) => { event.preventDefault(); dropzone.classList.add("dragging"); })); + ["dragleave", "drop"].forEach((name) => dropzone.addEventListener(name, (event) => { event.preventDefault(); dropzone.classList.remove("dragging"); })); + dropzone.addEventListener("drop", (event) => event.dataTransfer.files[0] && void handleBatchFile(event.dataTransfer.files[0])); + document.getElementById("downloadBatchTemplate").addEventListener("click", downloadBatchTemplate); + document.getElementById("previewBatch").addEventListener("click", () => void previewSelectedBatch()); + document.getElementById("compareBatch").addEventListener("click", () => void compareSelectedBatch()); + document.getElementById("commitBatch").addEventListener("click", () => void commitSelectedBatch()); + document.getElementById("refreshBatchMetrics").addEventListener("click", () => void refreshSelectedBatchMetrics()); + if (new URLSearchParams(location.search).get("mode") === "batch") { + document.querySelector('[data-scheduling-mode="batch"]').click(); + } +} + +async function handleBatchFile(file) { + setBatchStatus(`正在校验 ${file.name}…`, "working"); + try { + const batch = await importTaskBatch(file); + state.selectedBatch = batch; + state.selectedBatchPlan = null; + state.selectedBatchMetrics = null; + state.batchComparison = null; + setBatchStatus(`批次 ${batch.batch_id} 校验通过`, "success"); + renderBatchSummary(batch); + document.getElementById("previewBatch").disabled = false; + document.getElementById("compareBatch").disabled = false; + document.getElementById("refreshBatchMetrics").disabled = false; + document.getElementById("batchPlanResult").hidden = true; + document.getElementById("batchActualResult").hidden = true; + document.getElementById("batchCommitBar").hidden = true; + } catch (error) { + setBatchStatus(error.message, "error"); + } +} + +function renderBatchSummary(batch) { + const target = document.getElementById("batchSummary"); + target.hidden = false; + target.innerHTML = [ + ["批次", batch.batch_name || batch.batch_id], + ["状态", String(batch.status || "validated").toUpperCase()], + ["任务数", batch.task_count ?? batch.validation?.valid_count ?? 0], + ["内容指纹", compactText(batch.content_hash, 18)], + ].map(([label, value]) => `
${escapeHtml(label)}${escapeHtml(value)}
`).join(""); +} + +async function previewSelectedBatch() { + const batch = state.selectedBatch; + if (!batch) return; + setBatchStatus("正在构建统一资源快照与候选矩阵…", "working"); + try { + const plan = await previewTaskBatch(batch.batch_id, { strategy: document.getElementById("batchStrategy").value }); + state.selectedBatchPlan = plan; + renderBatchPlan(plan); + setBatchStatus(`预演完成:已分配 ${plan.task_node_assignments.length} 个任务`, "success"); + } catch (error) { + setBatchStatus(error.message, "error"); + } +} + +async function compareSelectedBatch() { + const batch = state.selectedBatch; + if (!batch) return; + setBatchStatus("正在使用相同快照对比 B0、B1、B3、B4、B6…", "working"); + try { + const comparison = await compareTaskBatch(batch.batch_id, {}); + state.batchComparison = comparison; + const plan = comparison.strategies.find((item) => item.plan_id === comparison.recommended_plan_id) || comparison.strategies[0]; + state.selectedBatchPlan = plan; + renderBatchPlan(plan, comparison.strategies); + setBatchStatus("策略对比完成,已选择综合结果最佳方案", "success"); + } catch (error) { + setBatchStatus(error.message, "error"); + } +} + +function renderBatchPlan(plan, strategies = []) { + const target = document.getElementById("batchPlanResult"); + target.hidden = false; + const metrics = [ + ["已分配", plan.task_node_assignments?.length ?? 0, "tasks"], + ["未分配", plan.unassigned_tasks?.length ?? 0, "tasks"], + ["Makespan", fmt(plan.predicted_makespan, 1), "ticks"], + ["运行碳", fmt(plan.predicted_carbon_g, 3), "gCO₂e"], + ["能耗", fmt(plan.predicted_energy_kwh, 5), "kWh"], + ["Future-Fit", `${fmt(plan.future_fit_before, 3)} → ${fmt(plan.future_fit_after, 3)}`, ""], + ["方案效用", fmt(plan.plan_utility, 4), "J(plan)"], + ]; + const groupLabels = { sla_quality: "SLA 质量", network_coordination: "网络协同", resource_efficiency: "资源效率", economic_cost: "经济成本", green_carbon: "绿色低碳" }; + const groupScores = Object.entries(plan.group_objective_breakdown || {}); + target.innerHTML = ` + ${strategies.length ? `
${strategies.map((item) => ``).join("")}
` : ""} +
${metrics.map(([label, value, unit]) => `
${label}${escapeHtml(value)} ${unit}
`).join("")}
+ ${groupScores.length ? `
${groupScores.map(([key, value]) => `
${escapeHtml(groupLabels[key] || key)}${fmt(value, 3)}组权重 ${fmt(plan.group_weights?.[key], 3)}
`).join("")}

${escapeHtml(plan.objective_hierarchy_version || "flat-ten-v1")} · 活跃目标:${escapeHtml((plan.active_objectives || []).join(" / "))} · 安全风险惩罚 ${fmt(plan.security_risk_penalty, 4)}

` : ""} +
${(plan.task_node_assignments || []).slice(0, 100).map((item) => ``).join("") || ``}
任务目标节点效用预计完成运行碳
${escapeHtml(item.task_id)}${escapeHtml(item.node_id)}${fmt(item.decision?.total_score, 4)}${fmt(item.decision?.predicted_finish_tick, 0)}${fmt(item.predicted_carbon_g, 4)} g
没有可分配任务
+ ${(plan.unassigned_tasks || []).length ? `
未分配原因${plan.unassigned_tasks.map((item) => `${escapeHtml(item.task_id)} · ${escapeHtml(item.reason)}`).join("")}
` : ""}`; + target.querySelectorAll("[data-plan-id]").forEach((button) => button.addEventListener("click", () => { + const selected = strategies.find((item) => item.plan_id === button.dataset.planId); + if (selected) { state.selectedBatchPlan = selected; renderBatchPlan(selected, strategies); } + })); + document.getElementById("batchCommitBar").hidden = false; +} + +async function commitSelectedBatch() { + const batch = state.selectedBatch; + const plan = state.selectedBatchPlan; + if (!batch || !plan) return; + const button = document.getElementById("commitBatch"); + button.disabled = true; + setBatchStatus("正在校验快照版本并原子写入预留账本…", "working"); + try { + const result = await commitTaskBatch(batch.batch_id, { plan_id: plan.plan_id, resource_snapshot_version: plan.resource_snapshot_version, confirmed_by_user_button: true }); + setBatchStatus(`批次已提交,生成 ${result.leases?.length ?? 0} 条资源租约`, "success"); + document.getElementById("batchCommitBar").hidden = true; + await refreshSelectedBatchMetrics(); + } catch (error) { + setBatchStatus(error.message, "error"); + button.disabled = false; + } +} + +async function refreshSelectedBatchMetrics() { + const batch = state.selectedBatch; + if (!batch) return; + const button = document.getElementById("refreshBatchMetrics"); + button.disabled = true; + try { + const metrics = await getTaskBatchMetrics(batch.batch_id); + state.selectedBatchMetrics = metrics; + renderBatchActualMetrics(metrics); + setBatchStatus(`实际执行进度:${metrics.completed_count}/${metrics.assigned_count},状态 ${metrics.status}`, metrics.failed_count ? "error" : "success"); + } catch (error) { + setBatchStatus(error.message, "error"); + } finally { + button.disabled = false; + } +} + +function renderBatchActualMetrics(metrics) { + const target = document.getElementById("batchActualResult"); + target.hidden = false; + const utilization = `${pct(metrics.average_cpu_utilization ?? 0, 1)} / ${pct(metrics.average_memory_utilization ?? 0, 1)} / ${pct(metrics.average_bandwidth_utilization ?? 0, 1)}`; + const cards = [ + ["执行状态", String(metrics.status || "running").toUpperCase(), ""], + ["实际完成", `${metrics.completed_count ?? 0}/${metrics.assigned_count ?? 0}`, "tasks"], + ["平均 / P95 JCT", `${fmt(metrics.average_jct_seconds, 2)} / ${fmt(metrics.p95_jct_seconds, 2)}`, "s"], + ["实际 Makespan", fmt(metrics.makespan_seconds, 2), "s"], + ["CPU / 内存 / 带宽", utilization, ""], + ["实际能耗", fmt(metrics.total_energy_kwh, 6), "kWh"], + ["实际运行碳", fmt(metrics.total_operational_carbon_g, 3), "gCO₂e"], + ["SLA 违规", fmt(metrics.sla_violation_count, 0), "tasks"], + ]; + target.innerHTML = `
MEASURED EXECUTION

CloudSim 实际执行指标

来自 Cloudlet 结果回传,不是预演估计值。

${cards.map(([label, value, unit]) => `
${escapeHtml(label)}${escapeHtml(String(value))} ${escapeHtml(unit)}
`).join("")}
`; +} + +function setBatchStatus(message, tone) { + const target = document.getElementById("batchImportState"); + target.className = `batch-import-state ${tone || ""}`; + target.querySelector("p").textContent = message; +} + +function downloadBatchTemplate() { + const csv = "task_id,task_type,cpu,memory,gpu,storage,estimated_duration,priority,region,max_latency_ms,security_level,carbon_budget_g,carbon_priority,allow_region_shift,allow_time_shift\nexample-001,inference,4,8,1,20,60,8,shanghai,30,medium,8,0.7,true,false\n"; + const link = document.createElement("a"); + link.href = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" })); + link.download = "tianjun-task-batch-template.csv"; + link.click(); + URL.revokeObjectURL(link.href); +} + export function renderScheduling(report, health) { if (!initialized || !report) return; updateAgentRuntimeStatus(); @@ -202,6 +424,8 @@ function renderContext(report, health) { ["指标评分", decision?.metric_scores ? "已生成" : "等待生成"], ["当前推荐节点", decision?.node_id ?? "等待推荐"], ["控制面状态", health?.status === "ok" ? "系统在线" : "检查中"], + ["当前批次", state.selectedBatch?.batch_id ?? "未选择"], + ["批次实际执行", state.selectedBatchMetrics ? `${state.selectedBatchMetrics.completed_count}/${state.selectedBatchMetrics.assigned_count} · JCT ${fmt(state.selectedBatchMetrics.average_jct_seconds, 2)} s` : "等待 CloudSim 回传"], ]; const target = document.getElementById("schedulingContext"); if (target) target.innerHTML = fields.map(([k, v]) => `
${escapeHtml(k)}${escapeHtml(v)}
`).join(""); diff --git a/src/tianjun/interfaces/dashboard/static/js/pages/tasks.js b/src/tianjun/interfaces/dashboard/static/js/pages/tasks.js index b8def0d..949b20d 100644 --- a/src/tianjun/interfaces/dashboard/static/js/pages/tasks.js +++ b/src/tianjun/interfaces/dashboard/static/js/pages/tasks.js @@ -1,4 +1,4 @@ -import { compactText, displayKey, escapeHtml, fmt, statusText } from "../utils.js"; +import { compactText, displayKey, escapeHtml, fmt, pct, statusText } from "../utils.js"; const pipelineSteps = [ ["接收任务", "请求进入调度队列"], @@ -99,6 +99,11 @@ function renderSummary(report) { ["SLA 达标", stats.slaMet, "完成且满足性能目标", "success"], ["SLA 未达标", stats.slaMiss, "完成但未满足目标", stats.slaMiss > 0 ? "danger" : "neutral"], ["平均执行耗时", stats.avgDuration ? `${fmt(stats.avgDuration, 1)} ticks` : "--", "最近记录均值", "primary"], + ["任务能耗", `${fmt(report.metrics?.total_energy_kwh, 5)} kWh`, "任务增量能耗汇总", "primary"], + ["运行碳", `${fmt(report.metrics?.total_operational_carbon_g, 3)} gCO₂e`, "口径:operational only", "success"], + ["实际平均 JCT", `${fmt(report.metrics?.average_actual_jct_seconds, 2)} s`, `P95 ${fmt(report.metrics?.p95_actual_jct_seconds, 2)} s`, "primary"], + ["实际 Makespan", `${fmt(report.metrics?.actual_makespan_seconds, 2)} s`, "按 Cloudlet 完成时间回传", "neutral"], + ["CPU / 内存利用率", `${pct(report.metrics?.average_cpu_utilization ?? 0, 1)} / ${pct(report.metrics?.average_memory_utilization ?? 0, 1)}`, "任务执行期实际利用率", "primary"], ]; document.getElementById("taskSummary").innerHTML = cards.map(([label, value, hint, tone]) => `
@@ -156,7 +161,7 @@ function renderRecords(report) {
${escapeHtml(compactText(record.task_id, 44))} -

${escapeHtml(type)} · ${escapeHtml(record.node_id ?? "未分配节点")}

+

${escapeHtml(type)} · ${escapeHtml(record.node_id ?? "未分配节点")}${record.batch_id ? ` · 批次 ${escapeHtml(record.batch_id)}` : ""}

${exec.text} @@ -170,6 +175,9 @@ function renderRecords(report) { ${fmt(cpuTicks, 1)} CPU ticks ${escapeHtml(statusText(record.execution_mode ?? record.mode ?? "process"))} ${escapeHtml(slaReason(record))} + ${fmt(record.energy_kwh, 5)} kWh / ${fmt(record.operational_carbon_g, 3)} g + ${fmt(record.queue_wait_seconds, 2)} s / ${fmt(record.jct_seconds, 2)} s + ${pct(record.cpu_utilization ?? 0, 1)} / ${pct(record.memory_utilization ?? 0, 1)}
${expanded ? renderRecordDetails(record) : ""} @@ -183,6 +191,7 @@ function renderRecordDetails(record) { : "无"; const rows = [ ["任务 ID", record.task_id], + ["批次 ID", record.batch_id ?? record.metadata?.batch_id ?? "单任务"], ["节点", record.node_id], ["开始 / 结束 tick", `${record.start_tick ?? "--"} / ${record.end_tick ?? "--"}`], ["预测 / 实际耗时", `${fmt(record.predicted_duration, 1)} / ${fmt(record.actual_duration, 1)} ticks`], @@ -194,6 +203,10 @@ function renderRecordDetails(record) { ["网络时延", `${fmt(record.network_delay_ticks, 0)} ticks`], ["网络风险", fmt(record.network_risk, 4)], ["有效带宽", `${fmt(record.effective_bandwidth_mbps, 1)} Mbps`], + ["计算碳 / 网络碳", `${fmt(record.compute_carbon_g, 4)} / ${fmt(record.network_carbon_g, 4)} gCO₂e`], + ["碳核算范围", record.carbon_scope ?? "operational_only"], + ["排队等待 / JCT", `${fmt(record.queue_wait_seconds, 3)} / ${fmt(record.jct_seconds, 3)} s`], + ["CPU / 内存 / 带宽 / 存储利用率", `${pct(record.cpu_utilization ?? 0, 1)} / ${pct(record.memory_utilization ?? 0, 1)} / ${pct(record.bandwidth_utilization ?? 0, 1)} / ${pct(record.storage_utilization ?? 0, 1)}`], ["交付概率", record.delivery_probability === undefined ? "--" : `${fmt(Number(record.delivery_probability) * 100, 1)}%`], ]; return `
diff --git a/src/tianjun/interfaces/dashboard/static/js/pages/topology.js b/src/tianjun/interfaces/dashboard/static/js/pages/topology.js index 9cd41eb..90eb517 100644 --- a/src/tianjun/interfaces/dashboard/static/js/pages/topology.js +++ b/src/tianjun/interfaces/dashboard/static/js/pages/topology.js @@ -1,4 +1,8 @@ import { renderTopology as renderTopologyCanvas } from "../topology.js"; +import { escapeHtml, fmt } from "../utils.js"; + +let activeLayer = "network"; +let latestReport = null; export function initTopology() { document.getElementById("page-topology").innerHTML = ` @@ -10,17 +14,46 @@ export function initTopology() {
-

交互式网络拓扑

+

交互式网络拓扑

`; + document.getElementById("topologyLayers").addEventListener("click", (event) => { + const button = event.target.closest("[data-layer]"); + if (!button) return; + activeLayer = button.dataset.layer; + document.querySelectorAll("[data-layer]").forEach((item) => item.classList.toggle("active", item.dataset.layer === activeLayer)); + renderTopology(latestReport); + }); } export function renderTopology(report) { + latestReport = report; renderTopologyCanvas(report, document.getElementById("topologyCanvas")); + const canvas = document.getElementById("topologyCanvas"); + canvas.dataset.layer = activeLayer; + renderCarbonSites(report); +} + +function renderCarbonSites(report) { + const target = document.getElementById("carbonSiteSummary"); + if (!target || !report) return; + const nodes = report.nodes || []; + const sites = new Map(); + for (const node of nodes) { + const key = node.site_id || node.region || "unknown"; + if (!sites.has(key)) sites.set(key, { nodes: 0, pue: 0, ci: 0, power: 0 }); + const item = sites.get(key); + item.nodes += 1; + item.pue += Number(node.carbon_profile?.pue || 1); + item.ci += Number(node.carbon_profile?.carbon_intensity_g_per_kwh || 0); + item.power += Number(node.current_power_w || 0); + } + target.innerHTML = `

${activeLayer === "carbon" ? "站点碳强度图层" : activeLayer === "load" ? "站点负载图层" : "站点能源画像"}

${Array.from(sites.entries()).map(([site, item]) => `
${escapeHtml(site)}PUE ${fmt(item.pue / item.nodes, 2)}CI ${fmt(item.ci / item.nodes, 1)} g/kWh${fmt(item.power, 1)} W
`).join("") || `

等待节点能源遥测

`}`; } diff --git a/src/tianjun/interfaces/dashboard/static/js/router.js b/src/tianjun/interfaces/dashboard/static/js/router.js index 8ffd674..21c66b8 100644 --- a/src/tianjun/interfaces/dashboard/static/js/router.js +++ b/src/tianjun/interfaces/dashboard/static/js/router.js @@ -76,6 +76,11 @@ function updateTopnav(report, health) { const llm = health?.chat_runtime?.llm ?? {}; document.getElementById("hermesLlmStatus").className = `badge ${llm.enabled ? "badge-success" : "badge-neutral"}`; document.getElementById("hermesLlmStatus").textContent = llm.enabled ? `当前模型 ${llm.settings?.model || "LLM 已启用"}` : "本地规则"; + const mcpCall = report?.toolchain_runtime?.external_mcp_last_success; + const mcpStatus = document.getElementById("mcpStatus"); + mcpStatus.className = `badge ${mcpCall ? "badge-success" : "badge-neutral"}`; + mcpStatus.textContent = mcpCall ? `外部 MCP · ${mcpCall.tool_name}` : "MCP 尚无成功调用"; + mcpStatus.title = mcpCall ? `最近成功调用 ${new Date(mcpCall.timestamp * 1000).toLocaleString("zh-CN")}` : "进程启动不等于工具已连接"; document.getElementById("autoRefreshStatus").textContent = "自动刷新中"; document.getElementById("lastSync").textContent = new Date().toLocaleTimeString("zh-CN", { hour12: false }); } diff --git a/src/tianjun/interfaces/dashboard/static/js/state.js b/src/tianjun/interfaces/dashboard/static/js/state.js index 5f049f8..a68aa06 100644 --- a/src/tianjun/interfaces/dashboard/static/js/state.js +++ b/src/tianjun/interfaces/dashboard/static/js/state.js @@ -10,6 +10,10 @@ export const state = { hermesBusy: false, abortController: null, pollHandle: null, + selectedBatch: null, + selectedBatchPlan: null, + selectedBatchMetrics: null, + batchComparison: null, }; const listeners = {}; diff --git a/src/tianjun/interfaces/dashboard/static/js/utils.js b/src/tianjun/interfaces/dashboard/static/js/utils.js index 8ac443c..62ce60d 100644 --- a/src/tianjun/interfaces/dashboard/static/js/utils.js +++ b/src/tianjun/interfaces/dashboard/static/js/utils.js @@ -8,6 +8,7 @@ export const textMap = { locality: "地域匹配", network: "网络质量", security: "安全策略", + carbon: "运行碳", pending: "待调度", running: "运行中", succeeded: "已成功", @@ -62,7 +63,7 @@ export const regionMap = { unknown: "未知区域", }; -export const METRIC_KEYS = ["performance", "completion", "cost", "reliability", "balance", "fragmentation", "locality", "network", "security"]; +export const METRIC_KEYS = ["performance", "completion", "cost", "reliability", "balance", "fragmentation", "locality", "network", "security", "carbon"]; export function escapeHtml(value) { return String(value ?? "-") @@ -281,6 +282,7 @@ export function topWeights(weights = {}, count = 3) { export function policyBias(weights = {}) { const top = topWeights(weights, 2).map(([key]) => key); + if (top.includes("carbon")) return "运行碳与绿色算力优先"; if (top.includes("completion") || top.includes("performance")) return "低时延与完成时效优先"; if (top.includes("cost")) return "成本控制优先"; if (top.includes("reliability") || top.includes("network")) return "稳定性与可靠性优先"; diff --git a/src/tianjun/interfaces/http/server.py b/src/tianjun/interfaces/http/server.py index a1375c5..1f4c0e9 100644 --- a/src/tianjun/interfaces/http/server.py +++ b/src/tianjun/interfaces/http/server.py @@ -6,9 +6,10 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any -from urllib.parse import urlparse +from urllib.parse import parse_qs, urlparse from ...application.control_plane import CentralControlPlane +from ...application.batch_scheduling_service import BatchRequestError, MAX_BATCH_BYTES from ...chat import ChatRuntime from ...scenarios import node_from_dict, task_from_dict from ..dashboard.page import render_dashboard_html @@ -51,6 +52,19 @@ def do_GET(self) -> None: # noqa: N802 return if handle_legacy_get(self, path, control_plane, chat): return + if path.startswith("/task-batches/"): + if path.endswith("/metrics"): + batch_id = path.removeprefix("/task-batches/").removesuffix("/metrics").strip("/") + result = control_plane.get_task_batch_actual_metrics(batch_id) + self._record_external_tool("get_batch_actual_metrics", result, batch_id=batch_id) + self._write_json(200, result) + return + batch_id = path.removeprefix("/task-batches/").strip("/") + if batch_id: + result = control_plane.get_task_batch(batch_id) + self._record_external_tool("get_task_batch", result, batch_id=batch_id) + self._write_json(200, result) + return if path.startswith("/policies/"): policy_id = path.removeprefix("/policies/").strip("/") if policy_id: @@ -67,12 +81,25 @@ def do_GET(self) -> None: # noqa: N802 self._write_json(200, chat.get_session(session_id)) return self._write_json(404, {"error": "not_found"}) + except BatchRequestError as exc: + self._write_json(exc.status_code, exc.payload) except Exception as exc: # noqa: BLE001 self._write_json(400, {"error": str(exc)}) def do_POST(self) -> None: # noqa: N802 path = urlparse(self.path).path try: + if path == "/task-batches/import": + content_type = self.headers.get("Content-Type", "application/json").lower() + raw = self._read_body(MAX_BATCH_BYTES) + if content_type.startswith("text/csv"): + name = parse_qs(urlparse(self.path).query).get("name", ["CSV批次"])[0] + result = control_plane.import_task_batch_csv(raw.decode("utf-8"), batch_name=name) + else: + result = control_plane.import_task_batch(json.loads(raw.decode("utf-8") or "{}")) + self._record_external_tool("import_task_batch", result, batch_id=result.get("batch_id")) + self._write_json(201, result) + return payload = self._read_json() if path == "/topology/register": self._write_json(200, control_plane.register_topology(payload)) @@ -93,9 +120,31 @@ def do_POST(self) -> None: # noqa: N802 labels=None if "labels" not in payload else set(payload.get("labels", [])), performance_factors=payload.get("performance_factors"), network_paths=payload.get("network_paths"), + current_power_w=payload.get("power_w", payload.get("current_power_w")), + energy_kwh_delta=payload.get("energy_kwh_delta"), + operational_carbon_g_delta=payload.get("operational_carbon_g_delta"), + carbon_intensity_g_per_kwh=payload.get("carbon_intensity_g_per_kwh"), + carbon_signal_timestamp=payload.get("carbon_signal_timestamp"), ) self._write_json(200, result) return + if path.startswith("/task-batches/"): + suffixes = ("/preview", "/compare", "/commit") + for suffix in suffixes: + if path.endswith(suffix): + batch_id = path.removeprefix("/task-batches/").removesuffix(suffix).strip("/") + if suffix == "/preview": + result = control_plane.preview_batch_schedule(batch_id, payload) + tool_name = "preview_batch_schedule" + elif suffix == "/compare": + result = control_plane.compare_batch_strategies(batch_id, payload) + tool_name = "compare_batch_strategies" + else: + result = control_plane.commit_batch_schedule(batch_id, payload) + tool_name = "commit_batch_schedule" + self._record_external_tool(tool_name, result, batch_id=batch_id, plan_id=result.get("plan_id")) + self._write_json(200, result) + return if path == "/schedule/preview": self._write_json(200, self._schedule_cloudsim_task(payload, commit=False)) return @@ -222,6 +271,7 @@ def do_POST(self) -> None: # noqa: N802 200, control_plane.update_policy_weights( dict(payload.get("weights") or {}), + group_weights=None if payload.get("group_weights") is None else dict(payload.get("group_weights") or {}), reason=str(payload.get("reason") or "用户手动提交多维策略权重。"), ), ) @@ -269,6 +319,10 @@ def do_POST(self) -> None: # noqa: N802 ) return if path == "/task-runs/result": + result_metadata = dict(payload.get("metadata") or {}) + for key in ("energy_kwh", "compute_carbon_g", "network_carbon_g", "operational_carbon_g", "carbon_scope"): + if key in payload: + result_metadata[key] = payload[key] result = control_plane.report_task_result( node_id=payload["node_id"], task_id=payload["task_id"], @@ -279,11 +333,13 @@ def do_POST(self) -> None: # noqa: N802 failure_reason=payload.get("failure_reason"), returncode=payload.get("returncode"), cost=payload.get("cost"), - metadata=payload.get("metadata"), + metadata=result_metadata, ) self._write_json(200, result) return self._write_json(404, {"error": "not_found"}) + except BatchRequestError as exc: + self._write_json(exc.status_code, exc.payload) except Exception as exc: # noqa: BLE001 self._write_json(400, {"error": str(exc)}) @@ -291,9 +347,14 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 return def _read_json(self) -> dict[str, Any]: + raw = self._read_body(MAX_BATCH_BYTES).decode("utf-8") + return json.loads(raw or "{}") + + def _read_body(self, max_bytes: int) -> bytes: length = int(self.headers.get("Content-Length", "0")) - raw = self.rfile.read(length).decode("utf-8") if length else "{}" - return json.loads(raw) + if length > max_bytes: + raise BatchRequestError(413, {"error": "request body exceeds 5MB"}) + return self.rfile.read(length) if length else b"" def _write_json(self, status: int, payload: Any) -> None: body = json.dumps(payload, ensure_ascii=False).encode("utf-8") @@ -366,6 +427,26 @@ def _dashboard_payload_from_chat_result(self, result: dict[str, Any]) -> dict[st "policy": policy, } + def _record_external_tool( + self, + fallback_tool_name: str, + result: dict[str, Any], + *, + batch_id: str | None = None, + plan_id: str | None = None, + ) -> None: + if self.headers.get("X-Tianjun-Caller") != "external_mcp": + return + control_plane.record_tool_call( + tool_name=self.headers.get("X-Tianjun-Tool") or fallback_tool_name, + actor="external_mcp", + result_status="success", + batch_id=batch_id, + plan_id=plan_id, + session_id=self.headers.get("X-Tianjun-Session"), + request_id=self.headers.get("X-Request-ID"), + ) + def _write_chat_event_stream(self, runner) -> None: self.send_response(200) self.send_header("Content-Type", "text/event-stream; charset=utf-8") diff --git a/src/tianjun/policy/feedback.py b/src/tianjun/policy/feedback.py index fb6ed14..f222de3 100644 --- a/src/tianjun/policy/feedback.py +++ b/src/tianjun/policy/feedback.py @@ -16,6 +16,7 @@ ("fragmentation", ("碎片", "gpu 等待", "gpu排队", "卡资源", "fragmentation")), ("locality", ("本地", "就近", "跨地域", "数据驻留", "locality")), ("network", ("网络", "抖动", "丢包", "带宽", "network", "jitter")), + ("carbon", ("低碳", "绿色", "减排", "碳预算", "碳排放", "carbon", "green")), ("workflow", ("流程", "步骤", "审批", "确认", "自动", "workflow", "process")), ("module", ("模块", "组件", "调度器", "模型", "网络", "算力", "component", "module")), ] @@ -59,6 +60,7 @@ "balance_weight": "balance", "fragmentation_weight": "fragmentation", "locality_weight": "locality", + "carbon_weight": "carbon", } @@ -145,7 +147,7 @@ def _infer_deltas(text: str, target: str, sentiment: str) -> dict[str, float]: deltas["completion"] = adjustment * 0.55 if any(word in text for word in ("网络", "抖动", "丢包")): deltas["network"] = max(deltas.get("network", 0.0), adjustment * 0.65) - elif target in {"balance", "fragmentation", "locality", "network"}: + elif target in {"balance", "fragmentation", "locality", "network", "carbon"}: deltas[target] = adjustment return deltas @@ -180,6 +182,7 @@ def _valid_target(value: str | None) -> str | None: "fragmentation", "locality", "network", + "carbon", "module", "workflow", } else None diff --git a/src/tianjun/policy/generator.py b/src/tianjun/policy/generator.py index dbd75a1..893811e 100644 --- a/src/tianjun/policy/generator.py +++ b/src/tianjun/policy/generator.py @@ -103,6 +103,7 @@ "fragmentation", "locality", "network", + "carbon", } PRIORITY_TO_METRICS = { "latency": {"performance": 0.52, "completion": 0.18, "network": 0.30}, @@ -113,6 +114,7 @@ "fragmentation": {"fragmentation": 1.0}, "locality": {"locality": 1.0}, "network": {"network": 0.78, "performance": 0.22}, + "carbon": {"carbon": 0.82, "cost": 0.08, "fragmentation": 0.10}, } @@ -143,6 +145,12 @@ def parse_requirement( "priority": priority, "priority_vector": self._priority_vector(text, priority), "deployment": self._deployment_spec(text), + "batch_id": self._batch_id(text), + "carbon_budget_g": self._carbon_budget_g(text), + "carbon_priority": 0.85 if self._mentions_carbon(text) else 0.0, + "allow_region_shift": self._allow_region_shift(text), + "allow_time_shift": self._allow_time_shift(text), + "deferrable_until_tick": self._deferrable_until_tick(text), } if overrides: data.update({key: value for key, value in overrides.items() if value is not None and not str(key).startswith("__")}) @@ -209,6 +217,14 @@ def merge_requirement_update( dict(data.get("priority_vector") or {}), parsed.priority_vector, ) + if self._mentions_carbon(text): + data["carbon_budget_g"] = parsed.carbon_budget_g + data["carbon_priority"] = parsed.carbon_priority + data["allow_region_shift"] = parsed.allow_region_shift + data["allow_time_shift"] = parsed.allow_time_shift + data["deferrable_until_tick"] = parsed.deferrable_until_tick + if parsed.batch_id: + data["batch_id"] = parsed.batch_id if overrides: data.update({key: value for key, value in overrides.items() if value is not None and not str(key).startswith("__")}) @@ -523,6 +539,7 @@ def task_from_requirement( "latency": 9, "quality": 8, "security": 8, + "green": 7, "balanced": 6, "cost": 4, }[requirement.priority] @@ -552,6 +569,12 @@ def task_from_requirement( min_bandwidth_mbps=requirement.bandwidth_mbps, network_sensitivity=0.9 if requirement.priority == "latency" else defaults["network_sensitivity"], intent_weights=self._metric_intent_weights(requirement), + carbon_budget_g=requirement.carbon_budget_g, + carbon_priority=requirement.carbon_priority, + allow_region_shift=requirement.allow_region_shift, + allow_time_shift=requirement.allow_time_shift, + deferrable_until_tick=requirement.deferrable_until_tick, + batch_id=requirement.batch_id, preferred_labels=preferred_labels, security_level=requirement.security_level, isolation_level=isolation_level, @@ -609,6 +632,16 @@ def apply_feedback( dict(data.get("priority_vector") or {}), {"quality": 1.0}, ) + if feedback.target == "carbon" or deltas.get("carbon", 0.0) > 0: + data["priority"] = "green" + data["carbon_priority"] = max(0.8, float(data.get("carbon_priority") or 0.0)) + data["priority_vector"] = self._merge_priority_vectors( + dict(data.get("priority_vector") or {}), + {"carbon": 1.0}, + ) + budget = self._carbon_budget_g(instruction) + if budget is not None: + data["carbon_budget_g"] = budget data["objective"] = f"{requirement.objective} | feedback: {feedback.instruction}" data["missing_fields"] = [] @@ -1443,6 +1476,7 @@ def _latency_ms(self, text: str) -> float | None: r"(?:端到端|推理|响应|latency)[^\d]{0,16}(\d+(?:\.\d+)?)\s*ms", r"低于\s*(\d+(?:\.\d+)?)\s*ms", r"小于\s*(\d+(?:\.\d+)?)\s*ms", + r"(?:不违反|不超过|上限)?\s*(\d+(?:\.\d+)?)\s*ms[^,。;;]{0,8}(?:时延|延迟|响应)", ] for pattern in patterns: match = re.search(pattern, text, re.IGNORECASE) @@ -1492,6 +1526,8 @@ def _mentions_security(self, text: str) -> bool: def _priority(self, text: str) -> str: lower = text.lower() + if self._mentions_carbon(text): + return "green" if any(word in lower for word in ("latency", "realtime")) or any(word in text for word in ("低延迟", "低时延", "实时")): return "latency" if any(word in lower for word in ("cheap", "cost")) or any(word in text for word in ("低成本", "成本", "便宜")): @@ -1538,14 +1574,58 @@ def _priority_vector(self, text: str, primary: str) -> dict[str, float]: ("network", "网络", "抖动", "丢包", "带宽"), 0.58, ), + "carbon": ( + ("carbon", "green", "低碳", "绿色", "减排", "碳预算", "碳优先"), + 0.74, + ), } for key, (keywords, weight) in signals.items(): if any(keyword in lower if keyword.isascii() else keyword in text for keyword in keywords): vector[key] = weight if primary != "balanced": - vector[primary] = max(vector.get(primary, 0.0), 0.72) + primary_key = "carbon" if primary == "green" else primary + vector[primary_key] = max(vector.get(primary_key, 0.0), 0.72) return self._normalize_priority_vector(vector) + @staticmethod + def _mentions_carbon(text: str) -> bool: + lower = text.lower() + return any(word in lower for word in ("carbon", "green")) or any( + word in text for word in ("低碳", "绿色", "减排", "碳预算", "碳排放") + ) + + @staticmethod + def _carbon_budget_g(text: str) -> float | None: + match = re.search(r"(?:碳预算|碳排放(?:上限)?)[^\d]{0,12}(\d+(?:\.\d+)?)\s*(?:g|克)", text, re.IGNORECASE) + return float(match.group(1)) if match else None + + @staticmethod + def _batch_id(text: str) -> str | None: + match = re.search(r"(?:批次|batch)[\s:#:-]*([A-Za-z0-9_.-]+)", text, re.IGNORECASE) + return match.group(1) if match else None + + @staticmethod + def _allow_region_shift(text: str) -> bool: + if any(word in text for word in ("禁止跨地域", "不允许跨地域", "不可跨地域")): + return False + return True + + @staticmethod + def _allow_time_shift(text: str) -> bool: + lower = text.lower() + if any(word in lower for word in ("not deferrable", "no time shift")) or any( + word in text for word in ("不允许延后", "禁止延后", "不可延后", "不能错峰", "不允许时间平移") + ): + return False + return any(word in lower for word in ("deferrable", "time shift")) or any( + word in text for word in ("允许延后", "可以延后", "错峰", "时间平移", "等待低碳") + ) + + @staticmethod + def _deferrable_until_tick(text: str) -> int | None: + match = re.search(r"(?:延后|推迟|等待)[^\d]{0,8}(\d+)\s*(?:tick|秒|s)", text, re.IGNORECASE) + return int(match.group(1)) if match else None + @staticmethod def _normalize_priority_vector(vector: dict[str, float]) -> dict[str, float]: valid = { diff --git a/src/tianjun/scenarios/fixtures.py b/src/tianjun/scenarios/fixtures.py index 3d1091d..e40d050 100644 --- a/src/tianjun/scenarios/fixtures.py +++ b/src/tianjun/scenarios/fixtures.py @@ -6,8 +6,10 @@ from ..domain import ( ExecutionMode, + CarbonSiteProfile, NetworkPathProfile, Node, + PowerProfile, ResourceVector, Task, TaskExecutionSpec, @@ -37,6 +39,19 @@ def node_from_dict(data: dict[str, Any]) -> Node: str(region): NetworkPathProfile(**profile) for region, profile in data.get("network_paths", {}).items() }, + site_id=data.get("site_id"), + power_profile=PowerProfile.from_dict(data.get("power_profile")), + carbon_profile=CarbonSiteProfile.from_dict(data.get("carbon_profile"), region=data.get("region", "default")), + trust_level=str(data.get("trust_level", "high")), + isolation_levels=set(data.get("isolation_levels", ["none", "process", "container", "namespace"])), + encrypted_transport=bool(data.get("encrypted_transport", True)), + resource_version=int(data.get("resource_version", 0)), + current_power_w=float(data.get("current_power_w", 0.0)), + energy_kwh_total=float(data.get("energy_kwh_total", 0.0)), + operational_carbon_g_total=float(data.get("operational_carbon_g_total", 0.0)), + task_energy_kwh_total=float(data.get("task_energy_kwh_total", 0.0)), + task_operational_carbon_g_total=float(data.get("task_operational_carbon_g_total", 0.0)), + carbon_signal_timestamp=data.get("carbon_signal_timestamp"), ) @@ -60,6 +75,17 @@ def task_from_dict(data: dict[str, Any]) -> Task: str(key): float(value) for key, value in dict(data.get("intent_weights", {})).items() }, + carbon_budget_g=data.get("carbon_budget_g"), + carbon_priority=float(data.get("carbon_priority", 0.0)), + expected_cpu_utilization=( + None + if data.get("expected_cpu_utilization") is None + else max(0.0, min(1.0, float(data["expected_cpu_utilization"]))) + ), + allow_region_shift=bool(data.get("allow_region_shift", True)), + allow_time_shift=bool(data.get("allow_time_shift", False)), + deferrable_until_tick=data.get("deferrable_until_tick"), + batch_id=data.get("batch_id"), preferred_labels=set(data.get("preferred_labels", [])), security_level=str(data.get("security_level", "medium")), isolation_level=str(data.get("isolation_level", "process")), diff --git a/src/tianjun/scheduling/engine.py b/src/tianjun/scheduling/engine.py index 937f057..bf69760 100644 --- a/src/tianjun/scheduling/engine.py +++ b/src/tianjun/scheduling/engine.py @@ -6,7 +6,11 @@ from ..ml.runtime import TrainedModelRuntime, get_default_model_runtime from ..domain import ( + GROUP_INNER_WEIGHTS, + GROUP_KEYS, METRIC_KEYS, + METRIC_TO_GROUP, + OBJECTIVE_GROUPS, Node, PhysicalTopology, PolicyState, @@ -19,6 +23,19 @@ class ClosedLoopAdaptiveScheduler: + ENGINEERING_BOUNDS: dict[str, tuple[float, float]] = { + "performance": (0.0, 1.0), + "completion": (0.0, 1.0), + "cost": (0.0, 10.0), + "reliability": (0.0, 1.0), + "balance": (0.0, 1.0), + "fragmentation": (0.0, 1.0), + "locality": (0.0, 1.0), + "network": (0.0, 1.0), + "security": (0.0, 1.0), + "carbon": (0.0, 1.0), + } + def __init__( self, policy_state: PolicyState, @@ -39,9 +56,15 @@ def select_node( current_tick: int, *, topology_nodes: Iterable[Node] | None = None, + scoring_strategy: str = "weighted_sum", + active_metrics: Iterable[str] | None = None, + active_groups: Iterable[str] | None = None, + group_weight_overrides: dict[str, float] | None = None, + future_tasks: Iterable[Task] | None = None, ) -> SchedulingDecision | None: candidate_pool = list(nodes) neighbor_pool = list(topology_nodes) if topology_nodes is not None else candidate_pool + future_task_samples = [sample for sample in (future_tasks or ()) if sample.task_id != task.task_id] region_pressure = self._region_pressure_by_node(neighbor_pool, task) raw_metrics: dict[str, dict[str, float]] = {} candidate_details: dict[str, dict[str, Any]] = {} @@ -58,10 +81,40 @@ def select_node( predicted_duration = node.predict_duration(task) + int(transfer_ticks) queue_snapshot = self._queue_snapshot(node, task, current_tick, predicted_duration) predicted_start_tick = int(queue_snapshot["predicted_start_tick"]) + carbon_tick = predicted_start_tick + if task.allow_time_shift and task.deferrable_until_tick is not None: + latest_start = max(predicted_start_tick, int(task.deferrable_until_tick)) + trace_ticks = [ + tick + for tick in node.carbon_profile.carbon_intensity_trace + if predicted_start_tick <= tick <= latest_start + ] + carbon_tick = min( + {predicted_start_tick, latest_start, *trace_ticks}, + key=node.carbon_profile.intensity_at, + ) + predicted_start_tick = carbon_tick + queue_snapshot["carbon_deferred_from_tick"] = int(queue_snapshot["predicted_start_tick"]) + queue_snapshot["predicted_start_tick"] = predicted_start_tick predicted_finish_tick = predicted_start_tick + predicted_duration predicted_cost = max(1.0, predicted_duration - transfer_ticks) * node.cost_per_tick + carbon = node.predict_operational_carbon(task, predicted_duration, carbon_tick) + carbon["scheduled_carbon_tick"] = carbon_tick + predicted_carbon_g = float(carbon["operational_carbon_g"]) + if task.carbon_budget_g is not None and predicted_carbon_g > float(task.carbon_budget_g): + continue + deadline_tick = task.effective_deadline_tick() + if deadline_tick is not None and predicted_finish_tick > deadline_tick: + continue candidates.append(node) + structural_fragmentation = node.fragmentation_after(task.demand) + future_fit_after = self._future_fit_after(node, task.demand, future_task_samples) + fragmentation_score = ( + structural_fragmentation + if not future_task_samples + else 0.35 * structural_fragmentation + 0.65 * future_fit_after + ) raw_metrics[node.node_id] = { "performance": self._performance_raw(task, predicted_duration, predicted_finish_tick), "completion": self._completion_raw(task, current_tick, predicted_finish_tick), @@ -79,10 +132,11 @@ def select_node( queue_snapshot, region_pressure.get(node.node_id, node.dominant_utilization_after(task.demand)), ), - "fragmentation": node.fragmentation_after(task.demand), + "fragmentation": clamp(fragmentation_score), "locality": node.locality_score(task), "network": self._network_raw(task, network_snapshot), "security": self._security_raw(task, node, network_snapshot), + "carbon": 1.0 / (1.0 + predicted_carbon_g), } candidate_details[node.node_id] = { "predicted_duration": float(predicted_duration), @@ -92,6 +146,10 @@ def select_node( "network_snapshot": network_snapshot, "queue_snapshot": queue_snapshot, "region_pressure": region_pressure.get(node.node_id, 0.0), + "carbon": carbon, + "structural_fragmentation_after": structural_fragmentation, + "future_fit_after": future_fit_after, + "future_fit_sample_count": len(future_task_samples), } if task.budget is not None: @@ -118,19 +176,71 @@ def select_node( return None metric_scores = self._normalize_metric_matrix(raw_metrics) - weights = self._derive_task_weights(task, current_tick) + atomic_sources = self.weight_components(task, current_tick) + selected_metrics = tuple(key for key in (active_metrics or METRIC_KEYS) if key in METRIC_KEYS) + if not selected_metrics: + selected_metrics = METRIC_KEYS + weights = self._masked_weights(atomic_sources["final"], selected_metrics) + inner_group_weights = self.inner_group_weights(atomic_sources["final"]) + group_scores = { + node_id: self.objective_group_scores(scores, inner_group_weights) + for node_id, scores in metric_scores.items() + } + group_sources = self.group_weight_components(task, current_tick, atomic_sources=atomic_sources) + selected_groups = tuple(key for key in (active_groups or GROUP_KEYS) if key in GROUP_KEYS) + if not selected_groups: + selected_groups = GROUP_KEYS + group_weights = self._masked_weights(group_sources["final"], selected_groups) + explicit_group_weights = group_weight_overrides is not None + if group_weight_overrides: + group_weights = self._masked_weights(group_weight_overrides, selected_groups) + group_sources = { + **group_sources, + "override": dict(group_weights), + "final": dict(group_weights), + } + hierarchical = scoring_strategy in { + "hierarchical_tchebycheff", + "B6-hierarchical-batch", + } + + if hierarchical: + pareto_ids = self._pareto_front(group_scores, selected_groups) + candidates = [node for node in candidates if node.node_id in pareto_ids] + elif scoring_strategy in {"pareto_tchebycheff", "B4-pareto-tchebycheff"}: + pareto_ids = self._pareto_front(metric_scores, selected_metrics) + candidates = [node for node in candidates if node.node_id in pareto_ids] def adjusted_total(node: Node) -> float: - score = sum(metric_scores[node.node_id][key] * weights[key] for key in METRIC_KEYS) + if hierarchical: + score = self.tchebycheff_utility( + group_scores[node.node_id], + group_weights, + selected_groups, + ) + score -= self.security_risk_penalty(task, metric_scores[node.node_id]["security"]) + elif scoring_strategy in {"pareto_tchebycheff", "B4-pareto-tchebycheff"}: + score = self.tchebycheff_utility(metric_scores[node.node_id], weights, selected_metrics) + else: + score = sum(metric_scores[node.node_id][key] * weights[key] for key in selected_metrics) # Avoid burning scarce GPU nodes for CPU-only jobs when a CPU-capable node exists. # This is a soft preference, not a hard constraint: if a region only has a GPU node, # the task can still run there. if task.demand.gpu <= 0 and node.capacity.gpu > 0: score -= 0.75 fleet_pressure = float(candidate_details[node.node_id].get("region_pressure", 0.0)) - score -= fleet_pressure * 1.10 + # An explicitly calibrated green profile may consolidate work in a + # lower-carbon region. Capacity remains a hard constraint; only + # the soft fleet-spreading penalty is relaxed in proportion to the + # requested green weight. + pressure_scale = ( + max(0.25, 1.0 - 0.85 * float(group_weights.get("green_carbon", 0.0))) + if hierarchical and explicit_group_weights + else 1.0 + ) + score -= fleet_pressure * 1.10 * pressure_scale if fleet_pressure >= 0.72: - score -= (fleet_pressure - 0.72) * 3.0 + score -= (fleet_pressure - 0.72) * 3.0 * pressure_scale return score best_node = max( @@ -149,9 +259,38 @@ def adjusted_total(node: Node) -> float: queue_detail = dict(detail["queue_snapshot"]) queue_detail["region_pressure"] = float(detail.get("region_pressure", 0.0)) decision_snapshot["queue"] = queue_detail + decision_snapshot["carbon"] = dict(detail["carbon"]) + decision_snapshot["structural_fragmentation_after"] = float( + detail.get("structural_fragmentation_after", 0.0) + ) + decision_snapshot["future_fit_after"] = float(detail.get("future_fit_after", 0.0)) + decision_snapshot["future_fit_sample_count"] = int(detail.get("future_fit_sample_count", 0)) + decision_snapshot["scoring_strategy"] = scoring_strategy + decision_snapshot["active_atomic_metrics"] = list(selected_metrics) + decision_snapshot["active_objective_groups"] = list(selected_groups) if hierarchical else [] + decision_snapshot["objective_hierarchy_version"] = "five-groups-v1" if hierarchical else "flat-ten-v1" + decision_snapshot["objective_groups"] = group_scores[best_node.node_id] + decision_snapshot["objective_group_weights"] = group_weights + decision_snapshot["objective_group_weight_sources"] = group_sources + decision_snapshot["green_pressure_scale"] = ( + max(0.25, 1.0 - 0.85 * float(group_weights.get("green_carbon", 0.0))) + if hierarchical and explicit_group_weights + else 1.0 + ) + decision_snapshot["objective_group_inner_weights"] = inner_group_weights + decision_snapshot["security_risk_penalty"] = self.security_risk_penalty( + task, metric_scores[best_node.node_id]["security"] + ) + fleet_pressure = float(detail.get("region_pressure", 0.0)) + decision_snapshot["placement_penalties"] = { + "scarce_gpu": 0.12 if task.demand.gpu <= 0 and best_node.capacity.gpu > 0 else 0.0, + "region_pressure": min(0.18, fleet_pressure * 0.08), + } + decision_snapshot["normalization_bounds_version"] = "engineering-v1" decision_snapshot["adaptive_scoring_formula"] = ( - "score=sum(w_k*s_k); completion penalizes predicted finish time; " - "balance penalizes dominant utilization, queue depth, queued work, and hot DC pressure." + "nested augmented Tchebycheff with task-adaptive inner weights over five semantic objective groups; security is a guardrail penalty" + if hierarchical + else "flat atomic objective scoring retained as an ablation baseline" ) explanation = self._build_explanation( task, @@ -174,6 +313,52 @@ def adjusted_total(node: Node) -> float: network_snapshot=decision_snapshot, ) + def _future_fit_after( + self, + node: Node, + placed_demand: ResourceVector, + future_tasks: list[Task], + ) -> float: + """Estimate whether realistic future tasks still fit after this placement.""" + if not future_tasks: + return 0.0 + remaining = node.remaining_after(placed_demand) + trust_rank = {"low": 0, "medium": 1, "high": 2} + fits = 0 + for sample in future_tasks: + if node.node_id in sample.forbidden_nodes: + continue + if sample.allowed_regions and not any( + node.matches_deployment_region(region) for region in sample.allowed_regions + ): + continue + if ( + not sample.allow_region_shift + and sample.network_source() + and not node.matches_deployment_region(sample.network_source() or "") + ): + continue + if sample.preferred_labels and not sample.preferred_labels.issubset(node.labels): + continue + if trust_rank.get(node.trust_level, 0) < trust_rank.get(sample.security_level, 1): + continue + if sample.isolation_level not in node.isolation_levels: + continue + if sample.require_encrypted_transport and not node.encrypted_transport: + continue + if not sample.demand.fits_in(remaining): + continue + path = node.path_profile_for(sample.network_source()) + if sample.max_latency_ms is not None and path.robust_latency_ms() > sample.max_latency_ms: + continue + if ( + sample.min_bandwidth_mbps is not None + and path.guaranteed_bandwidth_mbps() < sample.min_bandwidth_mbps + ): + continue + fits += 1 + return fits / len(future_tasks) + def _performance_raw(self, task: Task, predicted_duration: int, predicted_finish_tick: int) -> float: delay_penalty = 1.0 deadline_tick = task.effective_deadline_tick() @@ -270,52 +455,171 @@ def _queue_snapshot( } def _derive_task_weights(self, task: Task, current_tick: int) -> dict[str, float]: - weights = self.policy_state.current_weights() - urgency = task.urgency_score(current_tick) + return self.weight_components(task, current_tick)["final"] - weights["performance"] += 0.18 * urgency - weights["completion"] += 0.14 + (0.18 * urgency) - weights["reliability"] += 0.10 * urgency - weights["balance"] += 0.08 + def weight_components(self, task: Task, current_tick: int) -> dict[str, dict[str, float]]: + """Expose the auditable intent/SLA/data fusion used for one task.""" + intent = self.policy_state.current_weights() + urgency = task.urgency_score(current_tick) for metric, boost in task.intent_weights.items(): - if metric in weights: - weights[metric] += max(0.0, float(boost)) + if metric in intent: + intent[metric] += max(0.0, float(boost)) + if task.carbon_priority > 0: + intent["carbon"] += task.carbon_priority + intent = normalize_weights(intent) + + sla = {key: 0.01 for key in METRIC_KEYS} + sla["performance"] += 0.18 * urgency + sla["completion"] += 0.14 + (0.18 * urgency) + sla["reliability"] += 0.10 * urgency + sla["balance"] += 0.08 if task.deadline is not None: - weights["completion"] += 0.10 + sla["completion"] += 0.10 if task.budget is not None: - weights["cost"] += 0.24 + sla["cost"] += 0.24 + if task.carbon_budget_g is not None: + sla["carbon"] += 0.28 if task.data_region is not None or task.preferred_labels: - weights["locality"] += 0.06 + sla["locality"] += 0.06 if task.demand.gpu > 0: - weights["fragmentation"] += 0.05 + sla["fragmentation"] += 0.05 if task.task_type in {"batch_cpu", "analytics"}: - weights["completion"] += 0.08 - weights["balance"] += 0.06 + sla["completion"] += 0.08 + sla["balance"] += 0.06 if ( task.network_source() is not None or task.max_latency_ms is not None or task.min_bandwidth_mbps is not None ): - weights["network"] += 0.16 + sla["network"] += 0.16 if task.network_sensitivity >= 0.75 or task.task_type in {"streaming", "inference"}: - weights["network"] += 0.20 - weights["performance"] += 0.05 + sla["network"] += 0.20 + sla["performance"] += 0.05 elif task.network_sensitivity >= 0.5: - weights["network"] += 0.10 + sla["network"] += 0.10 if task.priority <= 4: - weights["cost"] += 0.14 - weights["performance"] -= 0.12 - weights["completion"] -= 0.06 - weights["reliability"] -= 0.04 + sla["cost"] += 0.14 if task.security_level == "high": - weights["security"] += 0.22 - weights["reliability"] += 0.08 - weights["locality"] += 0.06 + sla["security"] += 0.22 + sla["reliability"] += 0.08 + sla["locality"] += 0.06 elif task.security_level == "medium": - weights["security"] += 0.10 + sla["security"] += 0.10 if task.allowed_regions or task.forbidden_nodes or task.require_encrypted_transport: - weights["security"] += 0.08 - return normalize_weights(weights) + sla["security"] += 0.08 + sla = normalize_weights(sla) + data = normalize_weights({ + "performance": 0.12, "completion": 0.12, "cost": 0.08, + "reliability": 0.12, "balance": 0.12, "fragmentation": 0.10, + "locality": 0.07, "network": 0.11, "security": 0.08, "carbon": 0.08, + }) + final = normalize_weights({ + key: 0.4 * intent[key] + 0.4 * sla[key] + 0.2 * data[key] + for key in METRIC_KEYS + }) + return { + "intent": intent, + "sla": sla, + "data": data, + "final": final, + } + + def group_weight_components( + self, + task: Task, + current_tick: int, + *, + atomic_sources: dict[str, dict[str, float]] | None = None, + ) -> dict[str, dict[str, float]]: + """Fuse intent, SLA and data preferences at the semantic group layer.""" + atomic = atomic_sources or self.weight_components(task, current_tick) + intent = self.policy_state.current_group_weights() + for key, boost in task.intent_weights.items(): + group = key if key in GROUP_KEYS else METRIC_TO_GROUP.get(key) + if group is not None: + intent[group] += max(0.0, float(boost)) + if task.carbon_priority > 0: + intent["green_carbon"] += task.carbon_priority + intent = normalize_weights(intent) + + sla = self._aggregate_atomic_weights(atomic["sla"]) + data = normalize_weights({ + "sla_quality": 0.26, + "network_coordination": 0.20, + "resource_efficiency": 0.22, + "economic_cost": 0.12, + "green_carbon": 0.20, + }) + final = normalize_weights({ + key: 0.4 * intent[key] + 0.4 * sla[key] + 0.2 * data[key] + for key in GROUP_KEYS + }) + return {"intent": intent, "sla": sla, "data": data, "final": final} + + @staticmethod + def _aggregate_atomic_weights(weights: dict[str, float]) -> dict[str, float]: + grouped = { + group: sum(float(weights.get(metric, 0.0)) for metric in metrics) + for group, metrics in OBJECTIVE_GROUPS.items() + } + return normalize_weights(grouped) + + @staticmethod + def _masked_weights(weights: dict[str, float], active_keys: Iterable[str]) -> dict[str, float]: + keys = tuple(active_keys) + return normalize_weights({key: float(weights.get(key, 0.0)) for key in keys}) + + @staticmethod + def inner_group_weights( + atomic_weights: dict[str, float], + *, + prior_ratio: float = 0.35, + ) -> dict[str, dict[str, float]]: + """Blend stable semantic priors with task-specific fused atomic weights.""" + result: dict[str, dict[str, float]] = {} + for group, metrics in OBJECTIVE_GROUPS.items(): + adaptive = normalize_weights({metric: float(atomic_weights.get(metric, 0.0)) for metric in metrics}) + prior = GROUP_INNER_WEIGHTS[group] + result[group] = normalize_weights({ + metric: prior_ratio * float(prior[metric]) + (1.0 - prior_ratio) * adaptive[metric] + for metric in metrics + }) + return result + + @classmethod + def objective_group_scores( + cls, + metric_scores: dict[str, float], + inner_weights: dict[str, dict[str, float]] | None = None, + ) -> dict[str, float]: + groups: dict[str, float] = {} + weights_by_group = inner_weights or GROUP_INNER_WEIGHTS + for group, metrics in OBJECTIVE_GROUPS.items(): + if len(metrics) == 1: + groups[group] = clamp(float(metric_scores[metrics[0]])) + continue + inner = weights_by_group[group] + groups[group] = cls.tchebycheff_utility(metric_scores, inner, metrics) + return groups + + @staticmethod + def tchebycheff_utility( + scores: dict[str, float], + weights: dict[str, float], + active_keys: Iterable[str], + *, + rho: float = 0.01, + ) -> float: + keys = tuple(active_keys) + if not keys: + return 0.0 + distances = [float(weights[key]) * abs(1.0 - float(scores[key])) for key in keys] + return clamp(1.0 - (max(distances) + rho * sum(distances))) + + @staticmethod + def security_risk_penalty(task: Task, security_score: float) -> float: + coefficient = {"low": 0.04, "medium": 0.08, "high": 0.14}.get(task.security_level, 0.08) + return coefficient * (1.0 - clamp(float(security_score))) def _normalize_metric_matrix( self, @@ -325,17 +629,36 @@ def _normalize_metric_matrix( node_id: {} for node_id in metric_matrix } for metric in METRIC_KEYS: - values = [metrics[metric] for metrics in metric_matrix.values()] - minimum = min(values) - maximum = max(values) + minimum, maximum = self.ENGINEERING_BOUNDS[metric] span = maximum - minimum for node_id, metrics in metric_matrix.items(): - if span <= 1e-9: - normalized[node_id][metric] = 1.0 - else: - normalized[node_id][metric] = (metrics[metric] - minimum) / span + normalized[node_id][metric] = clamp((metrics[metric] - minimum) / max(span, 1e-9)) return normalized + @staticmethod + def _pareto_front( + metric_scores: dict[str, dict[str, float]], + active_keys: Iterable[str], + ) -> set[str]: + keys = tuple(active_keys) + node_ids = list(metric_scores) + front: set[str] = set() + for node_id in node_ids: + dominated = False + current = metric_scores[node_id] + for other_id in node_ids: + if other_id == node_id: + continue + other = metric_scores[other_id] + if all(other[key] >= current[key] for key in keys) and any( + other[key] > current[key] for key in keys + ): + dominated = True + break + if not dominated: + front.add(node_id) + return front + def _build_explanation( self, task: Task, @@ -346,6 +669,7 @@ def _build_explanation( ) -> str: labels = { "performance": "性能", + "completion": "完成时效", "cost": "成本", "reliability": "可靠性", "balance": "负载均衡", @@ -353,16 +677,34 @@ def _build_explanation( "locality": "局部性", "network": "网络稳定性", "security": "安全", + "carbon": "运行碳", } contributions = sorted( ( - (metric, metric_scores[metric] * weights[metric]) + (metric, metric_scores[metric] * weights.get(metric, 0.0)) for metric in METRIC_KEYS ), key=lambda item: item[1], reverse=True, ) - top_metrics = "、".join(labels.get(metric, metric) for metric, _ in contributions[:3]) + if network_snapshot.get("objective_hierarchy_version") == "five-groups-v1": + group_labels = { + "sla_quality": "SLA 与服务质量", + "network_coordination": "网络与地域协同", + "resource_efficiency": "资源效率", + "economic_cost": "经济成本", + "green_carbon": "绿色低碳", + } + group_scores = dict(network_snapshot.get("objective_groups") or {}) + group_weights = dict(network_snapshot.get("objective_group_weights") or {}) + ranked_groups = sorted( + group_scores, + key=lambda key: group_scores[key] * float(group_weights.get(key, 0.0)), + reverse=True, + ) + top_metrics = "、".join(group_labels.get(key, key) for key in ranked_groups[:3]) + else: + top_metrics = "、".join(labels.get(metric, metric) for metric, _ in contributions[:3]) stable_latency = float(network_snapshot.get("stable_latency_ms", 0.0)) fusion_score = float(network_snapshot.get("feature_fusion_score", 0.0)) confidence = float(network_snapshot.get("deterministic_confidence", 0.0)) diff --git a/src/tianjun/tools/schema.py b/src/tianjun/tools/schema.py index a82a8b4..3be1c91 100644 --- a/src/tianjun/tools/schema.py +++ b/src/tianjun/tools/schema.py @@ -14,6 +14,12 @@ "optimize_policy_from_feedback", "commit_policy", "schedule_pending_task", + "import_task_batch", + "get_task_batch", + "get_batch_actual_metrics", + "preview_batch_schedule", + "compare_batch_strategies", + "commit_batch_schedule", ] CHAT_TOOL_NAMES = [ @@ -35,6 +41,12 @@ "optimize_policy_from_feedback", "commit_policy", "schedule_pending_task", + "import_task_batch", + "get_task_batch", + "get_batch_actual_metrics", + "preview_batch_schedule", + "compare_batch_strategies", + "commit_batch_schedule", ] diff --git a/src/tianjun/tools/service.py b/src/tianjun/tools/service.py index 555b559..db99a5a 100644 --- a/src/tianjun/tools/service.py +++ b/src/tianjun/tools/service.py @@ -70,6 +70,23 @@ def run(self, tool_name: str, arguments: dict[str, Any] | None = None) -> dict[s str(args["task_id"]), confirmed_by_user_button=bool(args.get("confirmed_by_user_button") or args.get("confirmed")), ) + if tool_name == "import_task_batch": + return self.import_task_batch(args) + if tool_name == "get_task_batch": + return self.get_task_batch(str(args["batch_id"])) + if tool_name == "get_batch_actual_metrics": + return self.get_batch_actual_metrics(str(args["batch_id"])) + if tool_name == "preview_batch_schedule": + return self.preview_batch_schedule(str(args["batch_id"]), args) + if tool_name == "compare_batch_strategies": + return self.compare_batch_strategies(str(args["batch_id"]), args) + if tool_name == "commit_batch_schedule": + return self.commit_batch_schedule( + str(args["batch_id"]), + str(args["plan_id"]), + int(args["resource_snapshot_version"]), + confirmed_by_user_button=bool(args.get("confirmed_by_user_button") or args.get("confirmed")), + ) raise ValueError(f"Unsupported Tianjun tool: {tool_name}") def get_cluster_state(self) -> dict[str, Any]: @@ -158,6 +175,40 @@ def schedule_pending_task(self, task_id: str, *, confirmed_by_user_button: bool raise PermissionError("schedule_pending_task requires explicit user confirmation") return self.control_plane.schedule_pending_task(task_id) + def import_task_batch(self, payload: dict[str, Any]) -> dict[str, Any]: + return self.control_plane.import_task_batch(payload) + + def get_task_batch(self, batch_id: str) -> dict[str, Any]: + return self.control_plane.get_task_batch(batch_id) + + def get_batch_actual_metrics(self, batch_id: str) -> dict[str, Any]: + return self.control_plane.get_task_batch_actual_metrics(batch_id) + + def preview_batch_schedule(self, batch_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + return self.control_plane.preview_batch_schedule(batch_id, payload) + + def compare_batch_strategies(self, batch_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + return self.control_plane.compare_batch_strategies(batch_id, payload) + + def commit_batch_schedule( + self, + batch_id: str, + plan_id: str, + resource_snapshot_version: int, + *, + confirmed_by_user_button: bool = False, + ) -> dict[str, Any]: + if not confirmed_by_user_button: + raise PermissionError("commit_batch_schedule requires explicit user confirmation") + return self.control_plane.commit_batch_schedule( + batch_id, + { + "plan_id": plan_id, + "resource_snapshot_version": resource_snapshot_version, + "confirmed_by_user_button": True, + }, + ) + @staticmethod def _policy_summary(policy: dict[str, Any]) -> dict[str, Any]: effect = policy["expected_effect"] diff --git a/tests/test_batch_carbon_scheduling.py b/tests/test_batch_carbon_scheduling.py new file mode 100644 index 0000000..803c7f2 --- /dev/null +++ b/tests/test_batch_carbon_scheduling.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import pytest + +from tianjun.application.batch_scheduling_service import BatchRequestError +from tianjun.application.control_plane import CentralControlPlane +from tianjun.domain import CarbonSiteProfile, Node, PowerProfile, ResourceVector, RunningTask, Task + + +def node(node_id: str, *, carbon: float, cpu: float = 16.0) -> Node: + return Node( + node_id=node_id, + region=node_id, + labels={"cloudsim"}, + capacity=ResourceVector(cpu=cpu, memory=64, gpu=2, storage=500), + performance_factors={"batch_cpu": 1.0}, + power_profile=PowerProfile( + profile_id=f"power-{node_id}", + idle_power_w=100, + max_power_w=300, + gpu_idle_power_w=20, + gpu_max_power_w=220, + ), + carbon_profile=CarbonSiteProfile( + site_id=f"site-{node_id}", + region=node_id, + pue=1.2, + carbon_intensity_g_per_kwh=carbon, + ), + ) + + +def batch_payload(client_batch_id: str = "batch-client-1") -> dict: + return { + "client_batch_id": client_batch_id, + "batch_name": "联合调度回归", + "batch_preferences": {"intent_weights": {"carbon": 0.7, "fragmentation": 0.3}}, + "tasks": [ + { + "task_id": f"task-{index}", + "task_type": "batch_cpu", + "demand": {"cpu": 4, "memory": 8, "gpu": 0, "storage": 10}, + "estimated_duration": 20, + "priority": 6, + "carbon_priority": 0.9, + } + for index in range(3) + ], + } + + +def test_batch_import_preview_commit_is_idempotent_and_capacity_safe() -> None: + control = CentralControlPlane() + control.register_node(node("dirty", carbon=780)) + control.register_node(node("green", carbon=120)) + + imported = control.import_task_batch(batch_payload()) + replay = control.import_task_batch(batch_payload()) + assert replay["batch_id"] == imported["batch_id"] + assert replay["idempotent_replay"] is True + + plan = control.preview_batch_schedule(imported["batch_id"], {"strategy": "B4-pareto-tchebycheff"}) + assert len(plan["task_node_assignments"]) == 3 + assigned_nodes = [item["node_id"] for item in plan["task_node_assignments"]] + assert assigned_nodes[0] == "green" + assert assigned_nodes.count("green") >= assigned_nodes.count("dirty") + assert plan["predicted_carbon_g"] > 0 + assert plan["resource_snapshot_version"] == control.resource_snapshot_version + + with pytest.raises(BatchRequestError) as unconfirmed: + control.commit_batch_schedule(imported["batch_id"], { + "plan_id": plan["plan_id"], + "resource_snapshot_version": plan["resource_snapshot_version"], + }) + assert unconfirmed.value.status_code == 403 + + committed = control.commit_batch_schedule(imported["batch_id"], { + "plan_id": plan["plan_id"], + "resource_snapshot_version": plan["resource_snapshot_version"], + "confirmed_by_user_button": True, + }) + assert len(committed["leases"]) == 3 + assert sum(item.used().cpu for item in control.nodes.values()) == 12 + assert all(item.used().fits_in(item.capacity) for item in control.nodes.values()) + + for index, lease in enumerate(committed["leases"]): + control.report_task_result( + node_id=lease["node_id"], + task_id=lease["task_id"], + success=True, + duration_seconds=10 + index, + metadata={ + "queue_wait_seconds": index, + "jct_seconds": 10 + index * 2, + "cpu_utilization": 0.5 + index * 0.1, + "memory_utilization": 0.4, + "bandwidth_utilization": 0.2, + "storage_utilization": 0.1, + }, + ) + actual = control.get_task_batch_actual_metrics(imported["batch_id"]) + assert actual["status"] == "completed" + assert actual["completed_count"] == 3 + assert actual["makespan_seconds"] == 14 + assert actual["average_cpu_utilization"] == pytest.approx(0.6) + assert actual["prediction"]["decision_time_ms"] > 0 + + +def test_snapshot_conflict_creates_no_partial_reservation() -> None: + control = CentralControlPlane() + control.register_node(node("green", carbon=120)) + imported = control.import_task_batch(batch_payload("snapshot-conflict")) + plan = control.preview_batch_schedule(imported["batch_id"], {"strategy": "B1-batch-greedy"}) + + control.record_heartbeat("green", health_score=0.99) + with pytest.raises(BatchRequestError) as conflict: + control.commit_batch_schedule(imported["batch_id"], { + "plan_id": plan["plan_id"], + "resource_snapshot_version": plan["resource_snapshot_version"], + "confirmed_by_user_button": True, + }) + assert conflict.value.status_code == 409 + assert control.leases == {} + assert control.reservation_ledgers == {} + + +def test_carbon_time_shift_uses_lowest_forecast_tick_only_when_allowed() -> None: + control = CentralControlPlane() + green = node("trace", carbon=700) + green.carbon_profile.carbon_intensity_trace = {0: 700, 30: 100, 60: 500} + control.register_node(green) + task = Task( + task_id="deferrable", + task_type="batch_cpu", + demand=ResourceVector(cpu=2, memory=4, storage=5), + estimated_duration=10, + carbon_priority=1.0, + allow_time_shift=True, + deferrable_until_tick=60, + ) + decision = control.preview_task(task) + assert decision is not None + assert decision["predicted_start_tick"] == 30 + assert decision["network_snapshot"]["carbon"]["scheduled_carbon_tick"] == 30 + + +def test_hermes_parses_green_goal_and_latency_hard_limit() -> None: + control = CentralControlPlane() + requirement = control.parse_requirement("绿色优先但不违反30ms时延,碳预算8克,允许跨地域,不允许延后") + assert requirement["priority"] == "green" + assert requirement["latency_target_ms"] == 30 + assert requirement["carbon_budget_g"] == 8 + assert requirement["carbon_priority"] > 0 + assert requirement["allow_region_shift"] is True + assert requirement["allow_time_shift"] is False + + +def test_hierarchical_batch_exposes_five_groups_and_security_guardrail() -> None: + control = CentralControlPlane() + control.register_node(node("dirty", carbon=780)) + control.register_node(node("green", carbon=120)) + imported = control.import_task_batch(batch_payload("hierarchical-five-groups")) + + plan = control.preview_batch_schedule(imported["batch_id"], { + "strategy": "B6-hierarchical-batch", + }) + + assert plan["objective_hierarchy_version"] == "five-groups-v1" + assert set(plan["group_objective_breakdown"]) == { + "sla_quality", + "network_coordination", + "resource_efficiency", + "economic_cost", + "green_carbon", + } + assert sum(plan["group_weights"].values()) == pytest.approx(1.0) + assert "security" not in plan["group_weights"] + assert 0 <= plan["plan_utility"] <= 1 + decision = plan["task_node_assignments"][0]["decision"] + assert decision["network_snapshot"]["adaptive_scoring_formula"].startswith("nested augmented") + assert "security_risk_penalty" in decision["network_snapshot"] + assert decision["network_snapshot"]["future_fit_sample_count"] == 2 + assert 0 <= decision["network_snapshot"]["future_fit_after"] <= 1 + + +def test_single_and_dual_objective_masks_are_auditable() -> None: + control = CentralControlPlane() + control.register_node(node("dirty", carbon=780)) + control.register_node(node("green", carbon=120)) + imported = control.import_task_batch(batch_payload("objective-masks")) + + single = control.preview_batch_schedule(imported["batch_id"], { + "strategy": "B4-pareto-tchebycheff", + "active_metrics": ["carbon"], + }) + dual = control.preview_batch_schedule(imported["batch_id"], { + "strategy": "B6-hierarchical-batch", + "active_groups": ["sla_quality", "green_carbon"], + }) + + assert single["active_objectives"] == ["carbon"] + assert dual["active_objectives"] == ["sla_quality", "green_carbon"] + first = single["task_node_assignments"][0]["decision"] + assert first["weights"]["carbon"] == pytest.approx(1.0) + assert sum(value for key, value in first["weights"].items() if key != "carbon") == pytest.approx(0.0) + + with pytest.raises(BatchRequestError) as invalid: + control.preview_batch_schedule(imported["batch_id"], { + "strategy": "B6-hierarchical-batch", + "active_groups": ["unknown_group"], + }) + assert invalid.value.status_code == 422 + + +def test_named_green_profiles_preserve_public_strategy_and_weights() -> None: + control = CentralControlPlane() + control.register_node(node("dirty", carbon=780)) + control.register_node(node("green", carbon=120)) + imported = control.import_task_batch(batch_payload("named-green-profiles")) + + single = control.preview_batch_schedule(imported["batch_id"], { + "strategy": "B6-green-single-v1", + }) + dual = control.preview_batch_schedule(imported["batch_id"], { + "strategy": "B6-green-sla-85-v1", + }) + + assert single["strategy"] == "B6-green-single-v1" + assert single["active_objectives"] == ["green_carbon"] + assert single["group_weights"] == {"green_carbon": pytest.approx(1.0)} + assert dual["strategy"] == "B6-green-sla-85-v1" + assert set(dual["active_objectives"]) == {"green_carbon", "sla_quality"} + assert dual["group_weights"]["green_carbon"] == pytest.approx(0.85) + assert dual["group_weights"]["sla_quality"] == pytest.approx(0.15) + + +def test_expected_cpu_utilization_drives_incremental_carbon_prediction() -> None: + target = node("carbon-site", carbon=500) + low = Task( + task_id="low-util", + task_type="batch_cpu", + demand=ResourceVector(cpu=4, memory=8, storage=10), + estimated_duration=20, + expected_cpu_utilization=0.2, + ) + high = Task( + task_id="high-util", + task_type="batch_cpu", + demand=ResourceVector(cpu=4, memory=8, storage=10), + estimated_duration=20, + expected_cpu_utilization=0.8, + ) + + low_prediction = target.predict_operational_carbon(low, 20, 0) + high_prediction = target.predict_operational_carbon(high, 20, 0) + + assert high_prediction["power_w"] > low_prediction["power_w"] + assert high_prediction["operational_carbon_g"] > low_prediction["operational_carbon_g"] + assert high.to_dict()["expected_cpu_utilization"] == pytest.approx(0.8) + + +def test_future_fit_counts_feasible_task_node_pairs() -> None: + control = CentralControlPlane() + blocked = node("blocked", carbon=500) + available = node("available", carbon=500) + blocked.running_tasks["background"] = RunningTask( + task_id="background", + node_id=blocked.node_id, + allocation=ResourceVector(cpu=14, memory=4, storage=1), + start_tick=0, + predicted_duration=100, + actual_duration=100, + finish_tick=100, + success_probability=1.0, + ) + future = Task( + task_id="future", + task_type="batch_cpu", + demand=ResourceVector(cpu=4, memory=8, storage=10), + estimated_duration=20, + ) + + score = control.batch_scheduling_service._future_fit([blocked, available], [future]) + + assert score == pytest.approx(0.5) diff --git a/tests/test_cloudsim_contract.py b/tests/test_cloudsim_contract.py index 6d7bf12..8894dd9 100644 --- a/tests/test_cloudsim_contract.py +++ b/tests/test_cloudsim_contract.py @@ -48,3 +48,40 @@ def test_cloudsim_example_has_fast_batch_and_listener_mode() -> None: assert "executeExternalLease" in experiment assert "ESTIMATED_DURATION_PATTERN" in bridge assert "PREDICTED_COST_PATTERN" in bridge + + +def test_cloudsim_example_reports_incremental_operational_carbon() -> None: + experiment = Path( + "examples/cloudsimplus/src/main/java/org/cloudsimplus/examples/HuaweiDciTianjunExperiment.java" + ).read_text(encoding="utf-8") + bridge = Path( + "examples/cloudsimplus/src/main/java/org/cloudsimplus/examples/tianjun/TianjunHttpBridge.java" + ).read_text(encoding="utf-8") + + assert "PowerModelHostSimple" in experiment + assert "computeCarbonG" in experiment + assert "networkCarbonG" in experiment + assert '"carbon_scope": "operational_only"' in bridge + assert '"energy_kwh_delta"' in bridge + assert '"carbon_intensity_g_per_kwh"' in bridge + assert Path("examples/cloudsimplus/src/main/resources/tianjun-power-profiles.json").is_file() + assert Path("examples/cloudsimplus/src/main/resources/tianjun-carbon-intensity-trace.csv").is_file() + + +def test_cloudsim_batch_bridge_uses_unified_resources_and_measured_metrics() -> None: + experiment = Path( + "examples/cloudsimplus/src/main/java/org/cloudsimplus/examples/HuaweiDciTianjunExperiment.java" + ).read_text(encoding="utf-8") + bridge = Path( + "examples/cloudsimplus/src/main/java/org/cloudsimplus/examples/tianjun/TianjunHttpBridge.java" + ).read_text(encoding="utf-8") + + for resource in ("cpu", "memory", "gpu", "storage", "mips", "gpu_memory", "storage_iops", "bandwidth"): + assert f'"{resource}"' in bridge + assert "commitTaskBatch" in bridge + assert '"jct_seconds"' in bridge + assert '"cpu_utilization"' in bridge + assert "getBatchActualMetrics" in bridge + assert "loadPowerProfiles" in experiment + assert "loadCarbonProfiles" in experiment + assert "powerW * intervalSeconds / 3_600_000.0" in bridge diff --git a/tests/test_dashboard_contract.py b/tests/test_dashboard_contract.py index cc17284..0c812d3 100644 --- a/tests/test_dashboard_contract.py +++ b/tests/test_dashboard_contract.py @@ -30,3 +30,14 @@ def test_topology_displays_gpu_capacity() -> None: assert "node-gpu" not in topology assert ".node-gpu" not in styles assert topology.index('${detailRow("内存使用率", `${vm.memory}%`)}') < topology.index("${gpuDetail}") + + +def test_dashboard_exposes_hierarchical_batch_strategy_and_group_weights() -> None: + scheduling = Path("src/tianjun/interfaces/dashboard/static/js/pages/scheduling.js").read_text(encoding="utf-8") + model = Path("src/tianjun/interfaces/dashboard/static/js/pages/model.js").read_text(encoding="utf-8") + + assert "B6-hierarchical-batch" in scheduling + assert "group_objective_breakdown" in scheduling + assert "group_weights" in model + assert "五类业务目标" in model + assert "十维原子指标(解释、单目标与双目标消融)" in model diff --git a/tests/test_experiment_baselines.py b/tests/test_experiment_baselines.py new file mode 100644 index 0000000..18f872a --- /dev/null +++ b/tests/test_experiment_baselines.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from dataclasses import asdict + +import pytest + +from tianjun.application.batch_scheduling_service import BatchRequestError +from tianjun.application.control_plane import CentralControlPlane +from tianjun.domain import Node, ResourceVector +from tianjun.experiments import ( + AssignmentCandidate, + critic_weights, + entropy_weights, + milp_oracle, + nsga2_assignments, +) +from tianjun.experiments.runner import run_matrix +from tianjun.experiments.report import summarize + + +def test_objective_weight_methods_are_normalized_and_deterministic() -> None: + rows = [ + [0.1, 0.8, 0.4], + [0.4, 0.3, 0.7], + [0.9, 0.2, 0.5], + [0.6, 0.6, 0.1], + ] + critic = critic_weights(rows) + entropy = entropy_weights(rows) + + assert critic == critic_weights(rows) + assert entropy == entropy_weights(rows) + assert sum(critic) == pytest.approx(1.0) + assert sum(entropy) == pytest.approx(1.0) + assert all(weight >= 0 for weight in critic + entropy) + + +def test_milp_oracle_maximizes_admission_without_resource_oversell() -> None: + candidates = [ + AssignmentCandidate("task-a", "node-a", 0.9, {"cpu": 4}), + AssignmentCandidate("task-a", "node-b", 0.5, {"cpu": 4}), + AssignmentCandidate("task-b", "node-a", 0.8, {"cpu": 4}), + AssignmentCandidate("task-b", "node-b", 0.7, {"cpu": 4}), + ] + capacities = { + "node-a": {"cpu": 4, "memory": 8, "gpu": 0, "storage": 20}, + "node-b": {"cpu": 4, "memory": 8, "gpu": 0, "storage": 20}, + } + + solution = milp_oracle(candidates, capacities) + + assert solution.status == "optimal" + assert solution.assigned_count == 2 + assert {(item.task_id, item.node_id) for item in solution.selected} == { + ("task-a", "node-a"), + ("task-b", "node-b"), + } + + +def test_nsga2_baseline_is_reproducible() -> None: + candidates = [ + AssignmentCandidate( + task_id=f"task-{task}", + node_id=f"node-{node}", + utility=1.0 - node * 0.1, + demand={"cpu": 1}, + objectives={"carbon": 1.0 - node * 0.2, "completion": 0.8 + node * 0.1}, + ) + for task in range(3) + for node in range(2) + ] + capacities = { + "node-0": {"cpu": 2, "memory": 8, "gpu": 0, "storage": 20}, + "node-1": {"cpu": 2, "memory": 8, "gpu": 0, "storage": 20}, + } + + first = nsga2_assignments(candidates, capacities, population_size=16, generations=8) + second = nsga2_assignments(candidates, capacities, population_size=16, generations=8) + + first_allocations = [tuple(sorted((item.task_id, item.node_id) for item in solution.selected)) for solution in first] + second_allocations = [tuple(sorted((item.task_id, item.node_id) for item in solution.selected)) for solution in second] + assert first_allocations == second_allocations + assert max(solution.assigned_count for solution in first) == 3 + + +def test_experiment_solvers_require_explicit_experiment_mode() -> None: + control = CentralControlPlane() + control.register_node(Node( + node_id="node-a", + region="east", + labels={"cloudsim"}, + capacity=ResourceVector(cpu=8, memory=16, gpu=0, storage=100), + )) + imported = control.import_task_batch({ + "client_batch_id": "experiment-mode-gate", + "tasks": [ + { + "task_id": "oracle-task", + "task_type": "batch_cpu", + "demand": {"cpu": 2, "memory": 2, "gpu": 0, "storage": 5}, + "estimated_duration": 10, + "priority": 5, + } + ], + }) + + with pytest.raises(BatchRequestError) as forbidden: + control.preview_batch_schedule(imported["batch_id"], {"strategy": "B2-milp-oracle"}) + assert forbidden.value.status_code == 403 + + plan = control.preview_batch_schedule(imported["batch_id"], { + "strategy": "B2-milp-oracle", + "experiment_mode": True, + }) + assert len(plan["task_node_assignments"]) == 1 + assert plan["task_node_assignments"][0]["decision"]["network_snapshot"]["experiment_solver_status"] == "optimal" + + +def test_experiment_runner_emits_fixed_baseline_comparison_schema() -> None: + results = run_matrix({ + "schema_version": "1.0", + "node_counts": [2], + "batch_task_counts": [2], + "load_rates": [0.3], + "workloads": ["cpu"], + "seeds": [7], + "online_strategies": ["B0-current", "B1-batch-greedy"], + "offline_strategies": [], + "carbon_scope": "operational_only", + "normalization_bounds_version": "engineering-v1", + }) + + assert [item.strategy for item in results] == ["B0-current", "B1-batch-greedy"] + assert all(item.carbon_scope == "operational_only" for item in results) + assert results[0].baseline_carbon_reduction == pytest.approx(0.0) + assert results[1].baseline_acceptance_delta is not None + + +def test_experiment_runner_covers_single_dual_and_hierarchical_objectives() -> None: + results = run_matrix({ + "schema_version": "2.0", + "node_counts": [2], + "batch_task_counts": [2], + "load_rates": [0.3], + "workloads": ["cpu"], + "seeds": [7], + "online_strategies": ["B0-current", "B6-hierarchical-batch"], + "objective_experiments": { + "single_atomic": ["carbon"], + "dual_atomic": [["completion", "carbon"]], + "single_groups": ["green_carbon"], + "dual_groups": [["sla_quality", "green_carbon"]], + }, + }) + + assert {item.objective_scope for item in results} == { + "flat_full", + "hierarchical_full", + "single_atomic", + "dual_atomic", + "single_group", + "dual_group", + } + assert all(item.group_objective_breakdown is not None for item in results) + assert all(0 <= item.plan_utility <= 1 for item in results) + summary = summarize([asdict(item) for item in results]) + assert len(summary) == len(results) + assert all(item["sample_count"] == 1 for item in summary) diff --git a/tests/test_http_routes.py b/tests/test_http_routes.py index 172dc49..8b4b512 100644 --- a/tests/test_http_routes.py +++ b/tests/test_http_routes.py @@ -61,6 +61,20 @@ def post_status(base_url: str, path: str, payload: dict) -> tuple[int, dict]: return exc.code, json.loads(exc.read().decode("utf-8")) +def post_raw(base_url: str, path: str, body: bytes, content_type: str, headers: dict[str, str] | None = None) -> tuple[int, dict]: + request = urllib.request.Request( + f"{base_url}{path}", + data=body, + headers={"Content-Type": content_type, **(headers or {})}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=5) as response: + return response.status, json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + return exc.code, json.loads(exc.read().decode("utf-8")) + + def test_official_health_report_dashboard_routes() -> None: with running_server() as base_url: assert get_json(base_url, "/health")["status"] == "ok" @@ -118,3 +132,45 @@ def test_confirmation_boundaries_reject_missing_confirmation() -> None: assert "confirmation" in task_payload["error"] assert weights_status == 403 assert "confirmation" in weights_payload["error"] + + +def test_batch_json_csv_routes_and_external_mcp_audit() -> None: + task = { + "task_id": "http-batch-task", + "task_type": "batch_cpu", + "demand": {"cpu": 1, "memory": 1, "gpu": 0, "storage": 1}, + "estimated_duration": 3, + "priority": 5, + } + with running_server() as base_url: + status, imported = post_raw( + base_url, + "/task-batches/import", + json.dumps({"client_batch_id": "http-batch", "batch_name": "HTTP批次", "tasks": [task]}).encode("utf-8"), + "application/json", + {"X-Tianjun-Caller": "external_mcp", "X-Tianjun-Tool": "import_task_batch"}, + ) + assert status == 201 + assert imported["validation"]["error_count"] == 0 + batch = get_json(base_url, f"/task-batches/{imported['batch_id']}") + assert batch["task_count"] == 1 + actual = get_json(base_url, f"/task-batches/{imported['batch_id']}/metrics") + assert actual["completed_count"] == 0 + assert actual["task_count"] == 1 + preview = post_json(base_url, f"/task-batches/{imported['batch_id']}/preview", {"strategy": "B1-batch-greedy"}) + assert preview["resource_snapshot_version"] >= 0 + unconfirmed_status, _ = post_status(base_url, f"/task-batches/{imported['batch_id']}/commit", { + "plan_id": preview["plan_id"], + "resource_snapshot_version": preview["resource_snapshot_version"], + }) + assert unconfirmed_status == 403 + report = get_json(base_url, "/report") + assert report["toolchain_runtime"]["external_mcp_last_success"]["tool_name"] == "import_task_batch" + + csv_body = ( + "task_id,task_type,cpu,memory,gpu,storage,estimated_duration,priority,allow_region_shift\n" + "csv-task,batch_cpu,1,2,0,1,3,5,not-a-boolean\n" + ).encode("utf-8") + csv_status, csv_error = post_raw(base_url, "/task-batches/import?name=CSV", csv_body, "text/csv; charset=utf-8") + assert csv_status == 422 + assert csv_error["validation"]["errors"][0]["field"] == "allow_region_shift" From 36cb8d6e02dcc3204bf7857a695ad1278a3b9e2f Mon Sep 17 00:00:00 2001 From: Yu <1305203710@qq.com> Date: Sun, 26 Jul 2026 09:25:04 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=81=A5=E6=B5=8B?= =?UTF-8?q?=E3=80=81=E6=8A=A5=E5=91=8A=E4=B8=8E=E4=BB=AA=E8=A1=A8=E7=9B=98?= =?UTF-8?q?=E5=8F=AF=E9=9D=A0=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 33 +++ configs/tianjun.example.toml | 1 + docs/api.md | 14 +- docs/dashboard-test-checklist.md | 4 + docs/security-boundary.md | 8 + src/tianjun/application/control_plane.py | 47 +++- .../application/dashboard_reporting.py | 138 +++++++++++ src/tianjun/application/node_registry.py | 26 +- src/tianjun/application/task_lease_service.py | 1 + src/tianjun/domain/node.py | 14 ++ src/tianjun/integrations/mcp_server.py | 62 +++-- .../interfaces/dashboard/static/css/base.css | 12 +- .../dashboard/static/css/components.css | 138 +++++++++++ .../interfaces/dashboard/static/css/nav.css | 20 +- .../dashboard/static/css/pages/overview.css | 30 ++- .../dashboard/static/css/pages/tasks.css | 33 --- .../dashboard/static/css/pages/topology.css | 95 +++++++- .../interfaces/dashboard/static/index.html | 38 ++- .../interfaces/dashboard/static/js/api.js | 14 +- .../dashboard/static/js/pages/overview.js | 130 ++++++++-- .../dashboard/static/js/pages/tasks.js | 120 ++++++++-- .../dashboard/static/js/pages/topology.js | 195 ++++++++++++++- .../interfaces/dashboard/static/js/router.js | 107 +++++++-- .../interfaces/dashboard/static/js/state.js | 5 + .../dashboard/static/js/topology.js | 223 +++++++++++++----- .../interfaces/dashboard/static/js/utils.js | 5 + src/tianjun/interfaces/http/server.py | 131 +++++++--- src/tianjun/scenarios/fixtures.py | 6 + src/tianjun/storage/sqlite_state_store.py | 35 ++- tests/test_control_plane_services.py | 68 ++++++ tests/test_dashboard_contract.py | 28 ++- tests/test_http_routes.py | 81 ++++++- 32 files changed, 1586 insertions(+), 276 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/tianjun/application/dashboard_reporting.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1702a12 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: Tianjun CI + +on: + push: + pull_request: + +jobs: + verify: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Install test dependencies + run: python -m pip install -e ".[dev]" + - name: Compile Python + run: python -m compileall -q src tests + - name: Check Dashboard JavaScript syntax + run: find src/tianjun/interfaces/dashboard/static/js -name '*.js' -print0 | xargs -0 -n1 node --check + - name: Run tests + run: python -m pytest + - name: Run convergence checks + run: python scripts/convergence_check.py + - name: Run offline smoke test + run: python scripts/smoke_test.py --port 8136 diff --git a/configs/tianjun.example.toml b/configs/tianjun.example.toml index b83fcba..b6fca05 100644 --- a/configs/tianjun.example.toml +++ b/configs/tianjun.example.toml @@ -7,6 +7,7 @@ host = "127.0.0.1" port = 8024 heartbeat_timeout_seconds = 15 policy_update_interval = 2 +state_db = "${TIANJUN_CONFIG_DIR}/tianjun-state.sqlite" [model] dir = "${TIANJUN_HOME}/data/trained_models" diff --git a/docs/api.md b/docs/api.md index ea49944..eec0b6a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -6,8 +6,14 @@ | 方法 | 路径 | 用途 | | --- | --- | --- | -| GET | `/health` | 运行时健康、模型状态、聊天运行时状态 | -| GET | `/report` | Dashboard 和工具用的控制平面状态 | +| GET | `/health` | 进程健康和去敏后的依赖状态;始终返回可解析状态 | +| GET | `/ready` | 就绪检查;依赖未就绪时返回 503 | +| GET | `/report` | 完整控制平面状态,保留给 MCP 和兼容客户端 | +| GET | `/report/summary` | Dashboard 总览和顶部状态的精简报告 | +| GET | `/report/scheduling` | 调度决策页所需节点与决策报告 | +| GET | `/report/topology` | 拓扑、VM 遥测和路径报告 | +| GET | `/report/tasks?limit=50&cursor=0` | 分页任务执行报告 | +| GET | `/report/model` | 模型、权重与策略历史报告 | | GET | `/dashboard` | 静态 Dashboard 壳页面 | | GET | `/chat/sessions/{session_id}` | 读取聊天会话状态 | | POST | `/chat/sessions` | 开始聊天会话 | @@ -68,3 +74,7 @@ HTTP 路由调用 `CentralControlPlane` facade。facade 将已迁移的行为转 - `PolicyWorkflowService` 处理策略起草、比较、模拟、提交和反馈路由。 本文只描述公开 API 行为;服务拆分不改变路由语义。 + +Dashboard 只轮询按页面拆分的报告。所有报告视图都带有 `report_version`、`resource_snapshot_version` 和 `generated_at`,客户端可据此识别跨请求快照差异。完整 `/report` 不再用于浏览器高频轮询。 + +CloudSimPlus 心跳中的 `telemetry.cpu_utilization`、`ram_utilization` 和 `bandwidth_utilization` 会规范化为节点的 `runtime_utilization`。未上报的指标保持 `null`,Dashboard 显示为 `--`,不会生成伪实时值。 diff --git a/docs/dashboard-test-checklist.md b/docs/dashboard-test-checklist.md index 0e9c379..156e3b8 100644 --- a/docs/dashboard-test-checklist.md +++ b/docs/dashboard-test-checklist.md @@ -13,3 +13,7 @@ Dashboard 是静态 HTML/CSS/JS,没有构建步骤。在演示前,运行冒 - 模型页面权重更新需要明确确认并调用 `/policy-weights`。 - 任务取消调用 `/task-runs/cancel`。 - Dashboard 代码中没有调用 `/intent`、`/chat` 或 `/hermes/*`。 +- 顶部标签页同步 `aria-selected`,并支持左右方向键、Home 和 End。 +- 页面隐藏时自动刷新暂停,返回前台后恢复且不会出现重叠请求。 +- CloudSimPlus VM 心跳遥测在拓扑节点详情中显示;未上报指标显示 `--`。 +- 总览、调度、拓扑、任务和模型页面分别读取对应的精简报告视图。 diff --git a/docs/security-boundary.md b/docs/security-boundary.md index 4e3e8b5..8affc08 100644 --- a/docs/security-boundary.md +++ b/docs/security-boundary.md @@ -2,6 +2,10 @@ Tianjun 是本地研究和演示控制平面。生产使用需要额外的身份验证、授权、隔离、审计和执行器加固。 +服务默认绑定 `127.0.0.1`。当前版本没有远程访问鉴权,因此不得直接暴露到公网或不可信局域网。Dashboard 响应包含 CSP、禁止嗅探和禁止嵌入等基础安全头,但这些不能替代身份验证。CSP 允许页面现有的数据驱动内联样式,用于拓扑坐标和指标进度条;脚本仍只允许同源模块。 + +`/health` 返回去敏后的运行状态;`/ready` 用于依赖就绪检查。模型绝对路径、密钥来源和密钥指纹不会通过健康接口返回。 + ## 确认边界 以下操作需要明确的确认参数或 Dashboard 按钮流程: @@ -42,3 +46,7 @@ LLM 可以解释、总结和帮助解析用户需求。它不得捏造资源清 - `RequirementDialogueService`:需求会话状态变更。 `CentralControlPlane` 为调用方暴露稳定 facade 方法,但已经迁移的服务逻辑不应复制回 facade。 + +## MCP 状态边界 + +Dashboard 的 MCP 工具状态表示控制平面收到了带 `X-Tianjun-Caller: external_mcp` 和工具名的 HTTP 请求,并记录了结果。它是调用审计,不是经过身份认证的 MCP 进程在线证明。若系统需要远程或多租户使用,应增加独立的 MCP 会话身份、心跳和请求签名。 diff --git a/src/tianjun/application/control_plane.py b/src/tianjun/application/control_plane.py index ecb45c8..13fbbef 100644 --- a/src/tianjun/application/control_plane.py +++ b/src/tianjun/application/control_plane.py @@ -161,6 +161,8 @@ def record_tool_call( "result_status": result_status, }) self.tool_audit_log = self.tool_audit_log[-200:] + if self.state_store is not None: + self.state_store.set_control_value("tool_audit_log", list(self.tool_audit_log)) def parse_requirement( self, @@ -309,6 +311,9 @@ def record_heartbeat( operational_carbon_g_delta: float | None = None, carbon_intensity_g_per_kwh: float | None = None, carbon_signal_timestamp: float | None = None, + runtime_telemetry: dict[str, float] | None = None, + telemetry_source: str | None = None, + simulation_tick: float | None = None, ) -> dict[str, Any]: return self.node_registry.record_heartbeat( node_id, @@ -327,6 +332,9 @@ def record_heartbeat( operational_carbon_g_delta=operational_carbon_g_delta, carbon_intensity_g_per_kwh=carbon_intensity_g_per_kwh, carbon_signal_timestamp=carbon_signal_timestamp, + runtime_telemetry=runtime_telemetry, + telemetry_source=telemetry_source, + simulation_tick=simulation_tick, ) def request_lease(self, node_id: str) -> dict[str, Any] | None: @@ -459,6 +467,7 @@ def report_task_result( storage_utilization=clamp(float(result_metadata.get("storage_utilization", 0.0))), ) self.execution_history.append(record) + self.execution_history = self.execution_history[-SQLiteStateStore.MAX_EXECUTION_RECORDS:] self.task_progress.pop(task_id, None) node.update_after_record(task, record) node.task_energy_kwh_total += energy_kwh @@ -645,8 +654,16 @@ def build_report(self) -> dict[str, Any]: ), self.current_tick(), ) + external_mcp_calls = [ + item for item in self.tool_audit_log if item.get("actor") == "external_mcp" + ] + external_mcp_successes = [ + item for item in external_mcp_calls if item.get("result_status") == "success" + ] return { "tick": self.current_tick(), + "generated_at": time.time(), + "report_version": f"{self.resource_snapshot_version}:{self.current_tick()}", "totals": { "tasks": len(self.tasks), "completed_attempts": len(self.execution_history), @@ -720,7 +737,10 @@ def build_report(self) -> dict[str, Any]: }, "batch_scheduling": self.batch_scheduling_service.report(), "toolchain_runtime": { - "external_mcp_last_success": next((item for item in reversed(self.tool_audit_log) if item["actor"] == "external_mcp" and item["result_status"] == "success"), None), + "external_mcp_last_call": external_mcp_calls[-1] if external_mcp_calls else None, + "external_mcp_last_success": external_mcp_successes[-1] if external_mcp_successes else None, + "external_mcp_call_count": len(external_mcp_calls), + "external_mcp_success_count": len(external_mcp_successes), "recent_calls": list(self.tool_audit_log[-20:]), }, "resource_snapshot_version": self.resource_snapshot_version, @@ -805,7 +825,13 @@ def _node_report_payload(self, node: Node) -> dict[str, Any]: **node.to_dict(), "last_heartbeat_age": round(time.monotonic() - self.last_heartbeat_at.get(node.node_id, self.started_at), 3), } - runtime_utilization = {"cpu": 0.0, "memory": 0.0, "gpu": 0.0, "storage": 0.0} + runtime_utilization: dict[str, float | None] = { + "cpu": node.runtime_telemetry.get("cpu"), + "memory": node.runtime_telemetry.get("memory"), + "gpu": node.runtime_telemetry.get("gpu"), + "storage": node.runtime_telemetry.get("storage"), + "bandwidth": node.runtime_telemetry.get("bandwidth"), + } active_task_ids: list[str] = [] active_stages: list[str] = [] for progress in self.task_progress.values(): @@ -819,10 +845,17 @@ def _node_report_payload(self, node: Node) -> dict[str, Any]: util = dict(dict(progress.get("metrics") or {}).get("simulated_utilization") or {}) for key in runtime_utilization: try: - runtime_utilization[key] = max(runtime_utilization[key], float(util.get(key, 0.0))) + observed = util.get(key) + if observed is not None: + current = runtime_utilization[key] + runtime_utilization[key] = max(0.0 if current is None else current, float(observed)) except (TypeError, ValueError): pass - payload["runtime_utilization"] = {key: round(clamp(value), 4) for key, value in runtime_utilization.items()} + payload["runtime_utilization"] = { + key: None if value is None else round(clamp(value), 4) + for key, value in runtime_utilization.items() + } + payload["runtime_telemetry_available"] = any(value is not None for value in runtime_utilization.values()) payload["active_task_ids"] = active_task_ids payload["active_stages"] = active_stages return payload @@ -1029,6 +1062,12 @@ def _restore_from_store(self) -> None: if restored_group_weights: self.policy_state.group_weights = restored_group_weights + restored_tool_audit_log = snapshot["control_state"].get("tool_audit_log") + if isinstance(restored_tool_audit_log, list): + self.tool_audit_log = [ + dict(item) for item in restored_tool_audit_log if isinstance(item, dict) + ][-200:] + restored_topology = snapshot["control_state"].get("physical_topology") if restored_topology: self.physical_topology = PhysicalTopology.from_dict(restored_topology) diff --git a/src/tianjun/application/dashboard_reporting.py b/src/tianjun/application/dashboard_reporting.py new file mode 100644 index 0000000..b0e4fae --- /dev/null +++ b/src/tianjun/application/dashboard_reporting.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from typing import Any + + +COMMON_FIELDS = { + "tick", + "generated_at", + "report_version", + "resource_snapshot_version", + "totals", + "metrics", + "toolchain_runtime", + "data_gaps", +} + +VIEW_FIELDS = { + "summary": COMMON_FIELDS + | { + "batch_scheduling", + "model_runtime", + "nodes", + "recent_decisions", + "active_runs", + "recent_progress_events", + "recent_records", + "task_statuses", + "pending_task_queue", + }, + "scheduling": COMMON_FIELDS + | { + "batch_scheduling", + "model_runtime", + "nodes", + "recent_decisions", + "active_runs", + "pending_task_queue", + }, + "topology": COMMON_FIELDS + | { + "nodes", + "physical_topology", + "recent_decisions", + "active_runs", + "recent_progress_events", + "recent_records", + "task_statuses", + }, + "tasks": COMMON_FIELDS + | { + "task_statuses", + "active_runs", + "recent_progress_events", + "recent_records", + "execution_records", + "pending_task_queue", + }, + "model": COMMON_FIELDS + | { + "model_runtime", + "policy_weights", + "policy_group_weights", + "weight_sources", + "group_weight_sources", + "policy_history", + "batch_scheduling", + "task_statuses", + "recent_decisions", + "algorithm_profile", + }, +} + +SUMMARY_NODE_FIELDS = { + "node_id", + "region", + "location", + "service_region", + "online", + "health_score", + "reliability_score", + "capacity", + "available", + "runtime_utilization", + "runtime_telemetry_available", + "telemetry_source", + "simulation_tick", + "active_task_ids", + "active_stages", +} + +SCHEDULING_NODE_FIELDS = SUMMARY_NODE_FIELDS | {"network_paths"} + + +def dashboard_report_view( + report: dict[str, Any], + view: str, + *, + cursor: int = 0, + limit: int = 50, +) -> dict[str, Any]: + """Return a bounded Dashboard projection while preserving the full report API.""" + normalized_view = view if view in VIEW_FIELDS else "summary" + result = { + key: value + for key, value in report.items() + if key in VIEW_FIELDS[normalized_view] + } + result["view"] = normalized_view + + if normalized_view in {"summary", "scheduling"}: + node_fields = SCHEDULING_NODE_FIELDS if normalized_view == "scheduling" else SUMMARY_NODE_FIELDS + result["nodes"] = [ + {key: value for key, value in node.items() if key in node_fields} + for node in report.get("nodes", []) + ] + + decision_limit = 3 if normalized_view in {"summary", "topology"} else 8 + if "recent_decisions" in result: + result["recent_decisions"] = list(report.get("recent_decisions", []))[-decision_limit:] + + if normalized_view == "tasks": + records = list(report.get("execution_records", [])) + safe_cursor = max(0, int(cursor)) + safe_limit = max(1, min(200, int(limit))) + end = max(0, len(records) - safe_cursor) + start = max(0, end - safe_limit) + result["execution_records"] = records[start:end] + result["pagination"] = { + "cursor": safe_cursor, + "limit": safe_limit, + "total": len(records), + "next_cursor": None if start == 0 else safe_cursor + (end - start), + } + + if normalized_view == "model" and "policy_history" in result: + result["policy_history"] = list(report.get("policy_history", []))[-50:] + + return result diff --git a/src/tianjun/application/node_registry.py b/src/tianjun/application/node_registry.py index 33afc41..cf21c94 100644 --- a/src/tianjun/application/node_registry.py +++ b/src/tianjun/application/node_registry.py @@ -8,7 +8,7 @@ from .control_plane import CentralControlPlane from ..domain import Node -from ..domain import NetworkPathProfile +from ..domain import NetworkPathProfile, clamp @dataclass(slots=True) @@ -57,6 +57,9 @@ def record_heartbeat( operational_carbon_g_delta: float | None = None, carbon_intensity_g_per_kwh: float | None = None, carbon_signal_timestamp: float | None = None, + runtime_telemetry: dict[str, float] | None = None, + telemetry_source: str | None = None, + simulation_tick: float | None = None, ) -> dict[str, Any]: control = self.control_plane with control.lock: @@ -102,6 +105,24 @@ def record_heartbeat( node.operational_carbon_g_total += max(0.0, float(energy_kwh_delta)) * node.carbon_profile.pue * intensity if carbon_signal_timestamp is not None: node.carbon_signal_timestamp = float(carbon_signal_timestamp) + if runtime_telemetry is not None: + aliases = { + "ram_utilization": "memory", + "memory_utilization": "memory", + "cpu_utilization": "cpu", + "gpu_utilization": "gpu", + "storage_utilization": "storage", + "bandwidth_utilization": "bandwidth", + } + node.runtime_telemetry = { + aliases.get(str(key), str(key)): clamp(float(value)) + for key, value in runtime_telemetry.items() + if value is not None + } + if telemetry_source is not None: + node.telemetry_source = str(telemetry_source) + if simulation_tick is not None: + node.simulation_tick = float(simulation_tick) node.resource_version += 1 control.resource_snapshot_version += 1 control.last_heartbeat_at[node_id] = time.monotonic() @@ -117,6 +138,9 @@ def record_heartbeat( "energy_kwh_total": node.energy_kwh_total, "operational_carbon_g_total": node.operational_carbon_g_total, "carbon_signal_timestamp": node.carbon_signal_timestamp, + "runtime_telemetry": dict(node.runtime_telemetry), + "telemetry_source": node.telemetry_source, + "simulation_tick": node.simulation_tick, "network_paths": { region: profile.to_dict() for region, profile in sorted(node.network_paths.items(), key=lambda item: item[0]) diff --git a/src/tianjun/application/task_lease_service.py b/src/tianjun/application/task_lease_service.py index 19b3ca8..0917c13 100644 --- a/src/tianjun/application/task_lease_service.py +++ b/src/tianjun/application/task_lease_service.py @@ -208,6 +208,7 @@ def activate_task_lease( if remove_from_pending and task.task_id in control.pending_queue: control.pending_queue.remove(task.task_id) control.decision_log.append(decision) + control.decision_log = control.decision_log[-2000:] lease = TaskLease( task_id=task.task_id, diff --git a/src/tianjun/domain/node.py b/src/tianjun/domain/node.py index f2feef5..8d32161 100644 --- a/src/tianjun/domain/node.py +++ b/src/tianjun/domain/node.py @@ -78,6 +78,9 @@ class Node: task_energy_kwh_total: float = 0.0 task_operational_carbon_g_total: float = 0.0 carbon_signal_timestamp: float | None = None + runtime_telemetry: dict[str, float] = field(default_factory=dict) + telemetry_source: str | None = None + simulation_tick: float | None = None def __post_init__(self) -> None: self.location = self.location or self.region @@ -105,6 +108,11 @@ def __post_init__(self) -> None: ) for region, profile in self.network_paths.items() } + self.runtime_telemetry = { + str(key): clamp(float(value)) + for key, value in self.runtime_telemetry.items() + if value is not None + } def used(self) -> ResourceVector: total = ResourceVector() @@ -258,4 +266,10 @@ def to_dict(self) -> dict[str, Any]: "task_energy_kwh_total": round(self.task_energy_kwh_total, 8), "task_operational_carbon_g_total": round(self.task_operational_carbon_g_total, 6), "carbon_signal_timestamp": self.carbon_signal_timestamp, + "runtime_telemetry": { + key: round(value, 6) + for key, value in sorted(self.runtime_telemetry.items()) + }, + "telemetry_source": self.telemetry_source, + "simulation_tick": self.simulation_tick, } diff --git a/src/tianjun/integrations/mcp_server.py b/src/tianjun/integrations/mcp_server.py index 7ca46a5..8ed96ba 100644 --- a/src/tianjun/integrations/mcp_server.py +++ b/src/tianjun/integrations/mcp_server.py @@ -33,8 +33,9 @@ def post(self, path: str, payload: dict[str, Any] | None = None, *, tool_name: s def _request(self, method: str, path: str, payload: dict[str, Any] | None = None, *, tool_name: str | None = None) -> dict[str, Any]: url = f"{self.base_url.rstrip('/')}/{path.lstrip('/')}" data = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8") - headers = {"Content-Type": "application/json", "X-Tianjun-Caller": "external_mcp"} + headers = {"Content-Type": "application/json"} if tool_name: + headers["X-Tianjun-Caller"] = "external_mcp" headers["X-Tianjun-Tool"] = tool_name if self.auth_token: headers["Authorization"] = f"Bearer {self.auth_token}" @@ -81,27 +82,35 @@ def tool(func: Callable[..., dict[str, Any]]) -> Callable[..., dict[str, Any]]: @tool def get_cluster_state() -> dict[str, Any]: """获取当前算力网络控制面状态、节点、任务、策略权重和模型状态。""" - return http.get("/report") + return http.get("/report", tool_name="get_cluster_state") @tool def start_chat_session(message: str) -> dict[str, Any]: """启动智能聊天会话。系统会自动澄清需求、生成策略、仿真并等待确认。""" - return http.post("/chat/sessions", {"message": message}) + return http.post("/chat/sessions", {"message": message}, tool_name="start_chat_session") @tool def continue_chat_session(session_id: str, message: str) -> dict[str, Any]: """继续智能聊天会话,可用于补充槽位或反馈优化。正式提交需调用 commit_policy(confirmed=true)。""" - return http.post(f"/chat/sessions/{session_id}/messages", {"message": message}) + return http.post( + f"/chat/sessions/{session_id}/messages", + {"message": message}, + tool_name="continue_chat_session", + ) @tool def get_chat_session(session_id: str) -> dict[str, Any]: """读取智能聊天会话状态、消息历史、策略 ID 和待确认状态。""" - return http.get(f"/chat/sessions/{session_id}") + return http.get(f"/chat/sessions/{session_id}", tool_name="get_chat_session") @tool def start_requirement_dialogue(message: str, overrides: dict[str, Any] | None = None) -> dict[str, Any]: """启动结构化需求澄清,只返回槽位、问题和会话状态。""" - return http.post("/conversations/start", {"message": message, "overrides": overrides or {}}) + return http.post( + "/conversations/start", + {"message": message, "overrides": overrides or {}}, + tool_name="start_requirement_dialogue", + ) @tool def continue_requirement_dialogue( @@ -113,12 +122,17 @@ def continue_requirement_dialogue( return http.post( f"/conversations/{session_id}/continue", {"message": message, "overrides": overrides or {}}, + tool_name="continue_requirement_dialogue", ) @tool def draft_compute_network_policy(session_id: str, execution: dict[str, Any] | None = None) -> dict[str, Any]: """基于已澄清需求生成算网策略草案。""" - return http.post(f"/conversations/{session_id}/draft", {"execution": execution}) + return http.post( + f"/conversations/{session_id}/draft", + {"execution": execution}, + tool_name="draft_compute_network_policy", + ) @tool def compare_policy_options(session_id: str, execution: dict[str, Any] | None = None) -> dict[str, Any]: @@ -127,27 +141,39 @@ def compare_policy_options(session_id: str, execution: dict[str, Any] | None = N requirement = session.get("requirement") if not isinstance(requirement, dict): raise RuntimeError(f"Requirement session {session_id} is not ready for policy comparison.") - return http.post("/policies/compare", {"requirement": requirement, "execution": execution}) + return http.post( + "/policies/compare", + {"requirement": requirement, "execution": execution}, + tool_name="compare_policy_options", + ) @tool def simulate_policy(policy_id: str) -> dict[str, Any]: """仿真策略,返回负载、时延、成本、服务质量、安全和诊断建议。""" - return http.post("/policies/simulate", {"policy_id": policy_id}) + return http.post("/policies/simulate", {"policy_id": policy_id}, tool_name="simulate_policy") @tool def explain_policy(policy_id: str) -> dict[str, Any]: """读取策略详情,用于向用户解释组件选择、预期效果和风险。""" - return http.get(f"/policies/{policy_id}") + return http.get(f"/policies/{policy_id}", tool_name="explain_policy") @tool def parse_user_feedback(policy_id: str, instruction: str) -> dict[str, Any]: """将用户自然语言反馈归一化为结构化反馈。""" - return http.post("/feedback/parse", {"policy_id": policy_id, "instruction": instruction}) + return http.post( + "/feedback/parse", + {"policy_id": policy_id, "instruction": instruction}, + tool_name="parse_user_feedback", + ) @tool def optimize_policy_from_feedback(policy_id: str, instruction: str) -> dict[str, Any]: """根据用户反馈生成优化后的策略。""" - return http.post(f"/policies/{policy_id}/optimize", {"instruction": instruction}) + return http.post( + f"/policies/{policy_id}/optimize", + {"instruction": instruction}, + tool_name="optimize_policy_from_feedback", + ) @tool def commit_policy(policy_id: str, confirmed: bool = False) -> dict[str, Any]: @@ -158,7 +184,11 @@ def commit_policy(policy_id: str, confirmed: bool = False) -> dict[str, Any]: "policy_id": policy_id, "message": "提交会创建真实任务;请先向用户确认,再以 confirmed=true 调用。", } - return http.post("/policies/commit", {"policy_id": policy_id, "confirmed": True}) + return http.post( + "/policies/commit", + {"policy_id": policy_id, "confirmed": True}, + tool_name="commit_policy", + ) @tool def schedule_pending_task(task_id: str, confirmed: bool = False) -> dict[str, Any]: @@ -169,7 +199,11 @@ def schedule_pending_task(task_id: str, confirmed: bool = False) -> dict[str, An "task_id": task_id, "message": "调度将为现有任务创建执行租约;请先向用户确认,再以 confirmed=true 调用。", } - return http.post(f"/tasks/{task_id}/schedule", {"confirmed": True}) + return http.post( + f"/tasks/{task_id}/schedule", + {"confirmed": True}, + tool_name="schedule_pending_task", + ) @tool def import_task_batch( diff --git a/src/tianjun/interfaces/dashboard/static/css/base.css b/src/tianjun/interfaces/dashboard/static/css/base.css index 14d1c78..b96d9b9 100644 --- a/src/tianjun/interfaces/dashboard/static/css/base.css +++ b/src/tianjun/interfaces/dashboard/static/css/base.css @@ -16,6 +16,13 @@ body { background-blend-mode: soft-light; overflow-x: hidden; } + +button:focus-visible, +a:focus-visible, +[role="button"]:focus-visible { + outline: 2px solid var(--color-primary-500); + outline-offset: 3px; +} h1, h2, h3, h4, p { margin: 0; } button, input, textarea { font: inherit; } button { cursor: pointer; } @@ -34,8 +41,11 @@ textarea { width: 100%; resize: vertical; } .grid { display: grid; gap: var(--space-md); } .two-col { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } .empty { color: var(--color-gray-400); font-size: var(--font-size-sm); padding: var(--space-md); } +@media (max-width: 1180px) { + #app { padding-top: 108px; } +} @media (max-width: 980px) { - #app { width: calc(100% - 24px); max-width: 1760px; padding-top: 132px; } + #app { width: calc(100% - 24px); max-width: 1760px; padding-top: 108px; } .two-col { grid-template-columns: 1fr; } .page-head { align-items: start; flex-direction: column; } } diff --git a/src/tianjun/interfaces/dashboard/static/css/components.css b/src/tianjun/interfaces/dashboard/static/css/components.css index ad1f304..a296c9f 100644 --- a/src/tianjun/interfaces/dashboard/static/css/components.css +++ b/src/tianjun/interfaces/dashboard/static/css/components.css @@ -46,6 +46,144 @@ .metric-value.neutral { color: var(--color-gray-600); } .metric-label { font-size: var(--font-size-xs); color: var(--color-gray-400); } .metric-delta { font-size: var(--font-size-xs); color: var(--color-gray-600); } +.metric-summary-panel { + position: relative; + overflow: hidden; + padding: 0; + border-color: color-mix(in srgb, var(--color-gray-200) 82%, var(--color-primary-100)); + background: + linear-gradient(135deg, color-mix(in srgb, var(--color-bg-card) 96%, var(--color-primary-50)), var(--color-bg-card) 54%), + var(--color-bg-card); + box-shadow: 0 18px 42px color-mix(in srgb, var(--color-gray-900) 7%, transparent); +} +.metric-summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} +.metric-summary-group { + --summary-accent: var(--color-primary-500); + position: relative; + display: flex; + min-width: 0; + min-height: 10.75rem; + flex-direction: column; + padding: 1.15rem 1.25rem 1rem; + border-right: 0.5px solid var(--color-gray-200); +} +.metric-summary-group:last-child { border-right: 0; } +.metric-summary-group::before { + content: ""; + position: absolute; + inset: 0 0 auto; + height: 3px; + background: var(--summary-accent); +} +.metric-summary-group.tone-teal { --summary-accent: var(--color-teal-700); } +.metric-summary-group.tone-success { --summary-accent: var(--color-success-500); } +.metric-summary-group.tone-danger { --summary-accent: var(--color-danger-500); } +.metric-summary-group.tone-ink { --summary-accent: var(--color-gray-900); } +.summary-eyebrow { + color: var(--color-gray-500); + font-size: var(--font-size-xs); + font-weight: 600; + letter-spacing: .04em; +} +.summary-primary-line { + display: flex; + min-width: 0; + align-items: baseline; + gap: .45rem; + margin-top: .45rem; +} +.summary-value { + color: var(--color-gray-900); + font-size: clamp(1.8rem, 2.35vw, 2.35rem); + font-weight: 650; + line-height: 1.05; + letter-spacing: -.035em; + white-space: nowrap; +} +.summary-unit { + color: var(--summary-accent); + font-size: var(--font-size-sm); + font-weight: 650; + white-space: nowrap; +} +.summary-label { + margin-top: .3rem; + color: var(--color-gray-800); + font-size: var(--font-size-sm); + font-weight: 650; +} +.summary-caption { + min-height: 1.25rem; + margin-top: .18rem; + color: var(--color-gray-500); + font-size: var(--font-size-xs); + overflow-wrap: anywhere; +} +.summary-facts { + display: flex; + flex-wrap: wrap; + gap: .45rem 1rem; + margin-top: auto; + padding-top: .8rem; + border-top: 0.5px solid color-mix(in srgb, var(--color-gray-200) 82%, transparent); +} +.summary-facts span { + display: inline-flex; + min-width: 0; + align-items: baseline; + gap: .3rem; +} +.summary-facts small { + color: var(--color-gray-400); + font-size: .72rem; +} +.summary-facts b { + color: var(--color-gray-700); + font-size: var(--font-size-xs); + font-weight: 650; + white-space: nowrap; +} +.summary-alerts { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm); + padding: .75rem 1rem; + border-top: 0.5px solid color-mix(in srgb, var(--color-danger-500) 22%, var(--color-gray-200)); + background: color-mix(in srgb, var(--color-danger-50) 72%, var(--color-bg-card)); +} +.summary-alerts button, +.summary-alerts > span { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + border: 0; + background: transparent; + color: var(--color-danger-500); + font-size: var(--font-size-xs); +} +.summary-alerts button { cursor: pointer; } +.summary-alerts button:hover span { text-decoration: underline; } +.summary-alerts small, +.summary-alerts button span { color: var(--color-gray-600); } +@media (max-width: 1120px) { + .metric-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .metric-summary-group { border-bottom: 0.5px solid var(--color-gray-200); } + .metric-summary-group:nth-child(2n) { border-right: 0; } + .metric-summary-group:nth-last-child(-n + 2) { border-bottom: 0; } +} +@media (max-width: 620px) { + .metric-summary-grid { grid-template-columns: 1fr; } + .metric-summary-group { + min-height: 9.75rem; + border-right: 0; + border-bottom: 0.5px solid var(--color-gray-200); + } + .metric-summary-group:nth-last-child(-n + 2) { border-bottom: 0.5px solid var(--color-gray-200); } + .metric-summary-group:last-child { border-bottom: 0; } +} .btn-primary { background: var(--color-primary-500); color: var(--color-bg-card); border: none; border-radius: var(--radius-sm); padding: 8px 18px; font-size: var(--font-size-base); cursor: pointer; transition: background var(--transition-fast); } .btn-primary:hover { background: var(--color-primary-700); } .btn-ghost { background: transparent; border: 0.5px solid var(--color-gray-200); color: var(--color-gray-600); border-radius: var(--radius-sm); padding: 8px 16px; cursor: pointer; } diff --git a/src/tianjun/interfaces/dashboard/static/css/nav.css b/src/tianjun/interfaces/dashboard/static/css/nav.css index 76a86d9..9f9dfc5 100644 --- a/src/tianjun/interfaces/dashboard/static/css/nav.css +++ b/src/tianjun/interfaces/dashboard/static/css/nav.css @@ -61,10 +61,24 @@ font-size: var(--font-size-sm); } @media (max-width: 1180px) { - .topnav { height: auto; min-height: 52px; grid-template-columns: 1fr; padding: var(--space-sm) var(--space-md); } + .topnav { + height: auto; + min-height: 88px; + grid-template-columns: auto minmax(0, 1fr); + column-gap: var(--space-md); + row-gap: var(--space-xs); + padding: var(--space-xs) var(--space-md); + } + .topnav-tabs { scrollbar-width: thin; } + .topnav-status { + grid-column: 1 / -1; + justify-content: flex-end; + flex-wrap: nowrap; + overflow-x: auto; + padding-bottom: 1px; + } .tab-btn { height: 34px; border-radius: var(--radius-sm); } - .topnav-status { flex-wrap: wrap; } - .alert-banner { top: 112px; } + .alert-banner { top: 88px; } } @media (max-width: 480px) { diff --git a/src/tianjun/interfaces/dashboard/static/css/pages/overview.css b/src/tianjun/interfaces/dashboard/static/css/pages/overview.css index 7648a19..1532e28 100644 --- a/src/tianjun/interfaces/dashboard/static/css/pages/overview.css +++ b/src/tianjun/interfaces/dashboard/static/css/pages/overview.css @@ -1,5 +1,4 @@ .overview-metrics { - grid-template-columns: repeat(4, minmax(0, 1fr)); margin-bottom: var(--space-md); } @@ -15,15 +14,6 @@ margin-bottom: var(--space-md); } -.kpi-core .metric-value { - font-size: clamp(2.5rem, 3vw, 3rem); -} - -.secondary-metric .metric-value { - font-size: clamp(1.75rem, 2.2vw, 2rem); - font-weight: 500; -} - .capacity-matrix { display: grid; grid-template-rows: repeat(3, minmax(0, 1fr)); @@ -174,9 +164,17 @@ align-content: start; gap: var(--space-sm); min-height: 100%; + cursor: pointer; +} + +.sla-alert-card.has-alert { border-color: var(--color-danger-500); background: var(--color-danger-50); - cursor: pointer; +} + +.sla-alert-card.is-clear { + border-color: color-mix(in srgb, var(--color-success-500) 28%, var(--color-gray-200)); + background: linear-gradient(145deg, var(--color-bg-card), color-mix(in srgb, var(--color-success-50) 66%, var(--color-bg-card))); } .sla-alert-number { @@ -186,6 +184,11 @@ line-height: 1; } +.sla-alert-card.is-clear .sla-alert-number { + color: var(--color-success-500); + font-size: clamp(2rem, 3vw, 3rem); +} + .sla-alert-card p { color: var(--color-gray-700); } @@ -194,6 +197,10 @@ color: var(--color-danger-500); } +.sla-alert-card.is-clear b { + color: var(--color-success-500); +} + .decision-wide .list-item { background: var(--color-bg-card); } @@ -223,7 +230,6 @@ } @media (max-width: 1024px) { - .overview-metrics, .capacity-row, .queue-columns { grid-template-columns: 1fr; diff --git a/src/tianjun/interfaces/dashboard/static/css/pages/tasks.css b/src/tianjun/interfaces/dashboard/static/css/pages/tasks.css index 9e355cf..4a9551b 100644 --- a/src/tianjun/interfaces/dashboard/static/css/pages/tasks.css +++ b/src/tianjun/interfaces/dashboard/static/css/pages/tasks.css @@ -1,35 +1,7 @@ .task-summary { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: var(--space-md); margin-bottom: var(--space-md); } -.task-stat { - min-height: 120px; - padding: var(--space-md); - display: flex; - flex-direction: column; - justify-content: center; - gap: 5px; -} - -.task-stat.warning { - border-color: rgba(245, 158, 11, 0.22); -} - -.task-stat.primary { - border-color: rgba(79, 70, 229, 0.18); -} - -.task-stat.success { - border-color: rgba(16, 185, 129, 0.2); -} - -.task-stat.danger { - border-color: rgba(239, 68, 68, 0.22); -} - .task-pipeline-card { margin-bottom: var(--space-md); } @@ -303,10 +275,6 @@ } @media (max-width: 1200px) { - .task-summary { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .pipeline { grid-template-columns: repeat(3, minmax(0, 1fr)); } @@ -333,7 +301,6 @@ } @media (max-width: 680px) { - .task-summary, .pipeline, .record-grid, .record-detail-grid, diff --git a/src/tianjun/interfaces/dashboard/static/css/pages/topology.css b/src/tianjun/interfaces/dashboard/static/css/pages/topology.css index 65a8158..4c534d9 100644 --- a/src/tianjun/interfaces/dashboard/static/css/pages/topology.css +++ b/src/tianjun/interfaces/dashboard/static/css/pages/topology.css @@ -1215,8 +1215,7 @@ } } .topology-layer-head, -.layer-switch, -.carbon-site-summary > div { +.layer-switch { display: flex; align-items: center; justify-content: space-between; @@ -1238,14 +1237,92 @@ font-weight: 700; } -.layer-switch button.active { background: var(--color-primary-500); color: white; } -.topology-canvas[data-layer="carbon"] { background-image: radial-gradient(circle at 72% 28%, color-mix(in srgb, var(--color-success-500) 13%, transparent), transparent 34%); } -.topology-canvas[data-layer="load"] { background-image: radial-gradient(circle at 34% 58%, color-mix(in srgb, var(--color-warning-500) 14%, transparent), transparent 35%); } -.carbon-site-summary { display: grid; gap: var(--space-sm); margin-top: var(--space-lg); } -.carbon-site-summary h3 { margin: 0; font-size: .92rem; } -.carbon-site-summary > div { padding: .65rem; border: .0625rem solid var(--color-gray-200); border-radius: var(--radius-sm); background: var(--color-bg-card-alt); font-size: .78rem; } +.layer-switch button:focus-visible { outline: 2px solid var(--color-primary-300); outline-offset: 2px; } +.layer-switch button[data-layer="network"].active { background: var(--color-primary-500); color: white; } +.layer-switch button[data-layer="load"].active { background: var(--color-warning-500); color: white; } +.layer-switch button[data-layer="carbon"].active { background: var(--color-success-500); color: white; } +.topology-canvas[data-layer="carbon"] { background-image: radial-gradient(circle at 72% 28%, color-mix(in srgb, var(--color-success-500) 12%, transparent), transparent 34%); } +.topology-canvas[data-layer="load"] { background-image: radial-gradient(circle at 34% 58%, color-mix(in srgb, var(--color-warning-500) 12%, transparent), transparent 35%); } +.topology-canvas[data-layer="load"] .network-link:not(.route), +.topology-canvas[data-layer="carbon"] .network-link:not(.route) { opacity: .34; } +.topology-canvas[data-layer="load"] .layer-heat-target, +.topology-canvas[data-layer="carbon"] .layer-heat-target { + --heat-color: var(--color-success-500); + outline: 3px solid var(--heat-color); + outline-offset: 2px; +} +.topology-canvas .layer-heat-target.heat-medium { --heat-color: var(--color-warning-500); } +.topology-canvas .layer-heat-target.heat-high { --heat-color: var(--color-danger-500); } +.topology-canvas .layer-heat-target::after { + content: attr(data-layer-value); + position: absolute; + z-index: 8; + top: -10px; + right: -8px; + padding: 2px 6px; + border: 1px solid color-mix(in srgb, var(--heat-color) 52%, white); + border-radius: 999px; + background: color-mix(in srgb, var(--heat-color) 16%, white); + color: color-mix(in srgb, var(--heat-color) 84%, #0f172a); + box-shadow: 0 5px 12px rgba(15, 23, 42, .12); + font-size: 10px; + font-weight: 800; + line-height: 1.3; + white-space: nowrap; +} +.topology-canvas .vm-node.layer-heat-target::after { + top: -7px; + right: -5px; + padding: 1px 4px; + font-size: 8px; +} +.topology-layer-summary { display: grid; gap: var(--space-sm); margin-top: var(--space-lg); } +.layer-summary-head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-sm); } +.layer-summary-head h3 { margin: 1px 0 0; font-size: .95rem; } +.layer-summary-kicker { color: var(--color-gray-400); font-size: .65rem; font-weight: 800; letter-spacing: .08em; } +.layer-summary-badge { padding: 3px 8px; border-radius: 999px; font-size: .68rem; font-weight: 750; white-space: nowrap; } +.layer-summary-badge.network { background: var(--color-primary-50); color: var(--color-primary-700); } +.layer-summary-badge.load { background: var(--color-warning-50); color: var(--color-warning-500); } +.layer-summary-badge.carbon { background: var(--color-success-50); color: var(--color-success-500); } +.layer-summary-copy { margin: 0; color: var(--color-gray-500); font-size: .75rem; line-height: 1.55; } +.layer-summary-list { display: grid; gap: .45rem; } +.layer-summary-row { + --heat-color: var(--color-success-500); + display: grid; + gap: .55rem; + padding: .7rem; + border: 1px solid color-mix(in srgb, var(--heat-color) 24%, var(--color-gray-200)); + border-left: 3px solid var(--heat-color); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--heat-color) 5%, var(--color-bg-card-alt)); +} +.layer-summary-row.heat-medium { --heat-color: var(--color-warning-500); } +.layer-summary-row.heat-high { --heat-color: var(--color-danger-500); } +.layer-summary-row-head { display: flex; justify-content: space-between; gap: var(--space-sm); align-items: center; } +.layer-summary-row-head b { color: var(--color-gray-900); font-size: .8rem; } +.layer-summary-row-head span { color: var(--heat-color); font-size: .7rem; font-weight: 750; } +.layer-summary-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .4rem; } +.layer-summary-metrics.carbon { grid-template-columns: 1.35fr .75fr 1fr; } +.layer-summary-metrics span { display: grid; gap: 1px; min-width: 0; } +.layer-summary-metrics small { color: var(--color-gray-400); font-size: .64rem; } +.layer-summary-metrics b { color: var(--color-gray-700); font-size: .7rem; overflow-wrap: anywhere; } +.network-layer-legend { display: grid; gap: .4rem; } +.network-layer-legend span { display: grid; grid-template-columns: 22px auto minmax(0, 1fr); gap: .45rem; align-items: center; padding: .5rem .6rem; border: 1px solid var(--color-gray-200); border-radius: var(--radius-sm); background: var(--color-bg-card-alt); } +.network-layer-legend b { font-size: .74rem; } +.network-layer-legend small { color: var(--color-gray-500); font-size: .68rem; text-align: right; } +.legend-line { display: block; width: 20px; height: 3px; border-radius: 999px; } +.legend-line.route { background: #0ea5e9; box-shadow: 0 0 0 3px rgba(14, 165, 233, .12); } +.legend-dot { display: block; width: 10px; height: 10px; margin-left: 5px; border-radius: 999px; } +.legend-dot.congested { background: var(--color-warning-500); } +.legend-dot.fault { background: var(--color-danger-500); } +.heat-legend { display: flex; align-items: center; flex-wrap: wrap; gap: .65rem; padding-top: .2rem; color: var(--color-gray-600); font-size: .68rem; } +.heat-legend span { display: inline-flex; align-items: center; gap: .3rem; } +.heat-legend i { display: block; width: 8px; height: 8px; border-radius: 999px; background: var(--color-success-500); } +.heat-legend i.medium { background: var(--color-warning-500); } +.heat-legend i.high { background: var(--color-danger-500); } +.heat-legend small { margin-left: auto; color: var(--color-gray-400); } @media (max-width: 720px) { .topology-layer-head { align-items: flex-start; flex-direction: column; } - .carbon-site-summary > div { align-items: flex-start; flex-direction: column; } + .layer-summary-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } } diff --git a/src/tianjun/interfaces/dashboard/static/index.html b/src/tianjun/interfaces/dashboard/static/index.html index 4118279..0fdb299 100644 --- a/src/tianjun/interfaces/dashboard/static/index.html +++ b/src/tianjun/interfaces/dashboard/static/index.html @@ -17,42 +17,32 @@
-
- - - - +
+ + + +
- - - - - - - - - - diff --git a/src/tianjun/interfaces/dashboard/static/js/api.js b/src/tianjun/interfaces/dashboard/static/js/api.js index 87ae8f1..bc3b25e 100644 --- a/src/tianjun/interfaces/dashboard/static/js/api.js +++ b/src/tianjun/interfaces/dashboard/static/js/api.js @@ -1,7 +1,13 @@ const BASE = ""; -export async function fetchReport() { return _get("/report"); } -export async function fetchHealth() { return _get("/health"); } +export async function fetchReport(view = "summary", options = {}) { + const query = new URLSearchParams(); + if (options.cursor !== undefined) query.set("cursor", String(options.cursor)); + if (options.limit !== undefined) query.set("limit", String(options.limit)); + const suffix = query.size ? `?${query}` : ""; + return _get(`/report/${encodeURIComponent(view)}${suffix}`, options.signal); +} +export async function fetchHealth(options = {}) { return _get("/health", options.signal); } export async function startHermesSession(payload) { return _post("/chat/sessions", payload); } export async function streamHermesChat(sessionId, message, signal) { @@ -45,8 +51,8 @@ export async function importTaskBatch(file) { return response.json(); } -async function _get(path) { - const r = await fetch(BASE + path); +async function _get(path, signal) { + const r = await fetch(BASE + path, { signal }); if (!r.ok) throw await responseError(r, `GET ${path}`); return r.json(); } diff --git a/src/tianjun/interfaces/dashboard/static/js/pages/overview.js b/src/tianjun/interfaces/dashboard/static/js/pages/overview.js index 44d1d65..52fa228 100644 --- a/src/tianjun/interfaces/dashboard/static/js/pages/overview.js +++ b/src/tianjun/interfaces/dashboard/static/js/pages/overview.js @@ -12,7 +12,7 @@ export function initOverview() {

资源容量、实时队列、SLA 风险与最近调度决策。

-
+

资源池

@@ -70,12 +70,17 @@ export function renderOverview(report, health) { } function renderOverviewLoading() { - document.getElementById("overviewMetrics").innerHTML = Array.from({ length: 4 }, () => ` -
- 加载中 - -- - 正在获取调度数据 -
`).join(""); + document.getElementById("overviewMetrics").innerHTML = ` +
+
+ ${Array.from({ length: 4 }, () => ` +
+ 加载中 + -- + 正在获取调度数据 +
`).join("")} +
+
`; document.getElementById("capacityMatrix").innerHTML = `
资源池数据加载中...
`; document.getElementById("realtimeQueue").innerHTML = `
实时调度队列加载中...
`; document.getElementById("overviewDecision").innerHTML = `
最近决策加载中...
`; @@ -87,27 +92,95 @@ function renderOverviewLoading() { function renderMetrics(report) { const metrics = report.metrics ?? {}; + const totals = report.totals ?? {}; const decision = activeDecision(report, state.intentPayload); const snap = decision?.network_snapshot ?? {}; const gnnValue = metrics.gnn_stability_score ?? snap.fusion_features?.gnn_topology ?? report.model_runtime?.latest_prediction?.gnn_stability_score; const batches = report.batch_scheduling ?? {}; - const cards = [ - ["平均时延", `${fmt(metrics.average_stable_latency_ms ?? snap.deterministic_latency_ms, 1)} ms`, "primary", "LSTM 稳健时延预测"], - ["GNN 稳定性", gnnValue === undefined ? "--" : pct(gnnValue, 1), "teal", "拓扑稳定性参与调度评分"], - ["融合评分", fmt(metrics.average_fusion_score ?? snap.feature_fusion_score ?? decisionScore(decision), 3), "dark", decision ? `当前节点 ${decision.node_id}` : "等待调度决策"], - ["在线节点", `${(report.nodes ?? []).filter((node) => node.online !== false).length}`, "good", "可参与资源分配的节点"], - ["批任务接纳率", pct(batches.batch_acceptance_rate ?? 0, 1), "teal", `${fmt(batches.total_batch_tasks ?? 0, 0)} 个批任务已纳管`], - ["运行碳", `${fmt(metrics.total_operational_carbon_g, 3)} g`, "good", `${fmt(metrics.total_energy_kwh, 5)} kWh · operational only`], - ["实际平均 JCT", `${fmt(metrics.average_actual_jct_seconds, 2)} s`, "primary", `P95 ${fmt(metrics.p95_actual_jct_seconds, 2)} s`], - ["实际 Makespan", `${fmt(metrics.actual_makespan_seconds, 2)} s`, "dark", `${fmt(metrics.completed_batch_count, 0)} 个批次已回传`], - ["实际资源利用率", pct(metrics.average_cpu_utilization ?? 0, 1), "teal", `内存 ${pct(metrics.average_memory_utilization ?? 0, 1)} · 带宽 ${pct(metrics.average_bandwidth_utilization ?? 0, 1)}`], + const nodes = report.nodes ?? []; + const onlineNodes = nodes.filter((node) => node.online !== false).length; + const averageResource = (key) => nodes.length + ? nodes.reduce((sum, node) => sum + resourceValue(node, key), 0) / nodes.length + : 0; + const gpuSummary = gpuCapacitySummary(nodes); + const gpuUtilization = typeof gpuSummary === "number" ? gpuSummary : Number(gpuSummary.value ?? 0); + const managedTasks = Number(totals.tasks ?? batches.total_batch_tasks ?? 0); + const latencyValue = metrics.average_stable_latency_ms ?? snap.deterministic_latency_ms; + const fusionValue = metrics.average_fusion_score ?? snap.feature_fusion_score ?? (decision ? decisionScore(decision) : undefined); + const acceptanceValue = batches.batch_acceptance_rate; + const groups = [ + { + tone: "primary", + eyebrow: "资源状态", + label: "在线节点", + value: String(onlineNodes), + unit: nodes.length ? `/ ${nodes.length}` : "", + caption: "可参与当前资源分配", + facts: [ + ["CPU", pct(averageResource("cpu"), 1)], + ["内存", pct(averageResource("memory"), 1)], + ["GPU", pct(gpuUtilization, 1)], + ], + }, + { + tone: "teal", + eyebrow: "工作负载", + label: "纳管任务", + value: String(managedTasks), + unit: "", + caption: acceptanceValue === undefined ? "接纳率等待批次数据" : `批任务接纳率 ${pct(acceptanceValue, 1)}`, + facts: [ + ["待调度", fmt(totals.pending ?? totals.pending_tasks ?? 0, 0)], + ["运行中", fmt(totals.running ?? totals.running_tasks ?? 0, 0)], + ["已完成", fmt(totals.completed ?? totals.completed_attempts ?? 0, 0)], + ], + }, + { + tone: "ink", + eyebrow: "调度质量", + label: "平均时延", + value: latencyValue === undefined ? "--" : fmt(latencyValue, 1), + unit: latencyValue === undefined ? "" : "ms", + caption: decision ? `最近目标节点 ${decision.node_id}` : "等待首次调度决策", + facts: [ + ["GNN 稳定性", gnnValue === undefined ? "--" : pct(gnnValue, 1)], + ["融合评分", fusionValue === undefined ? "--" : fmt(fusionValue, 3)], + ], + }, + { + tone: "success", + eyebrow: "绿色运行", + label: "运行碳", + value: fmt(metrics.total_operational_carbon_g, 3), + unit: "gCO₂e", + caption: "仅统计运行阶段排放", + facts: [ + ["能耗", `${fmt(metrics.total_energy_kwh, 5)} kWh`], + ["口径", "Operational"], + ], + }, ]; - document.getElementById("overviewMetrics").innerHTML = cards.map(([label, value, tone, delta], index) => ` -
- ${escapeHtml(label)} - ${escapeHtml(String(value))} - ${escapeHtml(delta)} -
`).join(""); + document.getElementById("overviewMetrics").innerHTML = ` +
+
+ ${groups.map(renderSummaryGroup).join("")} +
+
`; +} + +function renderSummaryGroup(group) { + return `
+ ${escapeHtml(group.eyebrow)} +
+ ${escapeHtml(group.value)} + ${group.unit ? `${escapeHtml(group.unit)}` : ""} +
+ ${escapeHtml(group.label)} + ${escapeHtml(group.caption)} +
+ ${group.facts.map(([label, value]) => `${escapeHtml(label)}${escapeHtml(value)}`).join("")} +
+
`; } function renderCapacity(report) { @@ -170,7 +243,7 @@ function dcKey(node) { } function resourceValue(node, key) { - const direct = node[`${key}_utilization`] ?? node[`${key}_used_ratio`] ?? node[`${key}_usage`]; + const direct = node.runtime_utilization?.[key] ?? node.runtime_telemetry?.[key] ?? node[`${key}_utilization`] ?? node[`${key}_used_ratio`] ?? node[`${key}_usage`]; if (direct !== undefined && Number.isFinite(Number(direct))) return clamp01(Number(direct)); const cap = resourceCapacity(node, key); const used = resourceUsed(node, key, cap); @@ -296,10 +369,17 @@ function renderSlaAlert(report) { const completed = Number(totals.completed ?? totals.completed_attempts ?? records.length); const slaMet = Number(totals.sla_met ?? records.filter((record) => record.sla_met === true).length); const slaMiss = Number(totals.sla_missed ?? totals.sla_unmet ?? Math.max(0, completed - slaMet)); - document.getElementById("slaAlertCard").innerHTML = ` + const card = document.getElementById("slaAlertCard"); + card.classList.toggle("has-alert", slaMiss > 0); + card.classList.toggle("is-clear", slaMiss === 0); + card.innerHTML = slaMiss > 0 ? ` SLA 未达标任务 ${fmt(slaMiss, 0)}

执行完成但未满足时延、成本或稳定性目标。点击进入任务执行页定位异常记录。

+ 查看任务执行 →` : ` + SLA 运行状态 + 正常 +

${completed > 0 ? `${fmt(completed, 0)} 个已完成任务中暂无 SLA 异常。` : "等待任务完成后进行 SLA 校验。"}

查看任务执行 →`; } diff --git a/src/tianjun/interfaces/dashboard/static/js/pages/tasks.js b/src/tianjun/interfaces/dashboard/static/js/pages/tasks.js index 949b20d..e2598b8 100644 --- a/src/tianjun/interfaces/dashboard/static/js/pages/tasks.js +++ b/src/tianjun/interfaces/dashboard/static/js/pages/tasks.js @@ -64,6 +64,14 @@ export function initTasks() { } renderRecords(lastReport); }); + document.getElementById("taskSummary").addEventListener("click", (event) => { + const button = event.target.closest("[data-summary-filter]"); + if (!button) return; + activeFilter = button.dataset.summaryFilter; + filterEl.querySelectorAll(".filter-btn").forEach((item) => item.classList.toggle("active", item.dataset.filter === activeFilter)); + renderRecords(lastReport); + document.querySelector(".records-card-panel")?.scrollIntoView({ behavior: "smooth", block: "start" }); + }); } export function renderTasks(report) { @@ -78,39 +86,99 @@ export function renderTasks(report) { } function renderTasksLoading() { - document.getElementById("taskSummary").innerHTML = Array.from({ length: 4 }, () => ` -
-
加载中
-
--
-
正在获取任务执行记录
-
`).join(""); + document.getElementById("taskSummary").innerHTML = ` +
+
+ ${Array.from({ length: 4 }, () => ` +
+ 加载中 + -- + 正在获取执行数据 +
`).join("")} +
+
`; document.getElementById("taskPipeline").innerHTML = `
执行阶段加载中...
`; document.getElementById("taskRecords").innerHTML = `
任务执行记录加载中...
`; } function renderSummary(report) { const stats = taskStats(report); - const cards = [ - ["总任务数", stats.total, "当前调度批次任务量", "neutral"], - ["待调度", stats.pending, "等待进入策略决策", "warning"], - ["运行中", stats.running, "正在执行或等待回调", "primary"], - ["成功", stats.succeeded, "任务进程已完成", "success"], - ["失败", stats.failed, "执行链路出现异常", stats.failed > 0 ? "danger" : "neutral"], - ["SLA 达标", stats.slaMet, "完成且满足性能目标", "success"], - ["SLA 未达标", stats.slaMiss, "完成但未满足目标", stats.slaMiss > 0 ? "danger" : "neutral"], - ["平均执行耗时", stats.avgDuration ? `${fmt(stats.avgDuration, 1)} ticks` : "--", "最近记录均值", "primary"], - ["任务能耗", `${fmt(report.metrics?.total_energy_kwh, 5)} kWh`, "任务增量能耗汇总", "primary"], - ["运行碳", `${fmt(report.metrics?.total_operational_carbon_g, 3)} gCO₂e`, "口径:operational only", "success"], - ["实际平均 JCT", `${fmt(report.metrics?.average_actual_jct_seconds, 2)} s`, `P95 ${fmt(report.metrics?.p95_actual_jct_seconds, 2)} s`, "primary"], - ["实际 Makespan", `${fmt(report.metrics?.actual_makespan_seconds, 2)} s`, "按 Cloudlet 完成时间回传", "neutral"], - ["CPU / 内存利用率", `${pct(report.metrics?.average_cpu_utilization ?? 0, 1)} / ${pct(report.metrics?.average_memory_utilization ?? 0, 1)}`, "任务执行期实际利用率", "primary"], + const metrics = report.metrics ?? {}; + const completed = stats.succeeded + stats.failed; + const slaChecked = stats.slaMet + stats.slaMiss; + const hasMeasuredRun = Number(metrics.completed_batch_count ?? 0) > 0 || Number(metrics.average_actual_jct_seconds ?? 0) > 0; + const slaRate = slaChecked > 0 ? pct(stats.slaMet / slaChecked, 1) : "--"; + const backlogThreshold = Math.max(5, Math.ceil(stats.total * 0.1)); + const groups = [ + { + tone: stats.failed > 0 ? "danger" : "primary", + eyebrow: "执行状态", + label: "已完成 / 总任务", + value: `${completed} / ${stats.total}`, + unit: "", + caption: stats.failed > 0 ? `${stats.failed} 个任务执行异常` : "当前执行链路正常", + facts: [["待调度", stats.pending], ["运行中", stats.running], ["失败", stats.failed]], + }, + { + tone: stats.slaMiss > 0 ? "danger" : "success", + eyebrow: "服务目标", + label: "SLA 达标率", + value: slaRate, + unit: "", + caption: slaChecked > 0 ? `${slaChecked} 个已完成任务完成校验` : "等待任务完成后校验", + facts: [["达标", stats.slaMet], ["未达标", stats.slaMiss]], + }, + { + tone: "ink", + eyebrow: "执行性能", + label: "实际平均 JCT", + value: hasMeasuredRun ? fmt(metrics.average_actual_jct_seconds, 2) : "--", + unit: hasMeasuredRun ? "s" : "", + caption: hasMeasuredRun ? `P95 ${fmt(metrics.p95_actual_jct_seconds, 2)} s` : "等待 Cloudlet 指标回传", + facts: [ + ["Makespan", hasMeasuredRun ? `${fmt(metrics.actual_makespan_seconds, 2)} s` : "--"], + ["平均耗时", stats.avgDuration ? `${fmt(stats.avgDuration, 1)} ticks` : "--"], + ], + }, + { + tone: "teal", + eyebrow: "资源成本", + label: "CPU 利用率", + value: pct(metrics.average_cpu_utilization ?? 0, 1), + unit: "", + caption: `内存利用率 ${pct(metrics.average_memory_utilization ?? 0, 1)}`, + facts: [ + ["能耗", `${fmt(metrics.total_energy_kwh, 5)} kWh`], + ["运行碳", `${fmt(metrics.total_operational_carbon_g, 3)} gCO₂e`], + ], + }, ]; - document.getElementById("taskSummary").innerHTML = cards.map(([label, value, hint, tone]) => ` -
-
${escapeHtml(label)}
-
${escapeHtml(String(value))}
-
${escapeHtml(hint)}
-
`).join(""); + const alerts = []; + if (stats.failed > 0) alerts.push(``); + if (stats.slaMiss > 0) alerts.push(``); + if (stats.pending > backlogThreshold) alerts.push(`${stats.pending} 个任务积压已超过提示阈值 ${backlogThreshold}`); + document.getElementById("taskSummary").innerHTML = ` +
+
+ ${groups.map(renderTaskSummaryGroup).join("")} +
+ ${alerts.length ? `` : ""} +
`; +} + +function renderTaskSummaryGroup(group) { + return `
+ ${escapeHtml(group.eyebrow)} +
+ ${escapeHtml(group.value)} + ${group.unit ? `${escapeHtml(group.unit)}` : ""} +
+ ${escapeHtml(group.label)} + ${escapeHtml(group.caption)} +
+ ${group.facts.map(([label, value]) => `${escapeHtml(label)}${escapeHtml(value)}`).join("")} +
+
`; } function renderPipeline(report) { diff --git a/src/tianjun/interfaces/dashboard/static/js/pages/topology.js b/src/tianjun/interfaces/dashboard/static/js/pages/topology.js index 90eb517..b6e4006 100644 --- a/src/tianjun/interfaces/dashboard/static/js/pages/topology.js +++ b/src/tianjun/interfaces/dashboard/static/js/pages/topology.js @@ -1,7 +1,9 @@ import { renderTopology as renderTopologyCanvas } from "../topology.js"; import { escapeHtml, fmt } from "../utils.js"; -let activeLayer = "network"; +const topologyLayers = new Set(["network", "load", "carbon"]); +const requestedLayer = new URLSearchParams(location.search).get("topologyLayer"); +let activeLayer = topologyLayers.has(requestedLayer) ? requestedLayer : "network"; let latestReport = null; export function initTopology() { @@ -14,46 +16,217 @@ export function initTopology() {
-

交互式网络拓扑

+

交互式网络拓扑

`; + syncLayerButtons(); document.getElementById("topologyLayers").addEventListener("click", (event) => { const button = event.target.closest("[data-layer]"); if (!button) return; activeLayer = button.dataset.layer; - document.querySelectorAll("[data-layer]").forEach((item) => item.classList.toggle("active", item.dataset.layer === activeLayer)); + syncLayerButtons(); renderTopology(latestReport); }); } +function syncLayerButtons() { + document.querySelectorAll("#topologyLayers [data-layer]").forEach((item) => { + const selected = item.dataset.layer === activeLayer; + item.classList.toggle("active", selected); + item.setAttribute("aria-pressed", String(selected)); + }); +} + export function renderTopology(report) { latestReport = report; renderTopologyCanvas(report, document.getElementById("topologyCanvas")); const canvas = document.getElementById("topologyCanvas"); canvas.dataset.layer = activeLayer; - renderCarbonSites(report); + document.getElementById("pathMetrics").hidden = activeLayer !== "network"; + applyLayerVisuals(report, canvas); + renderLayerSummary(report); } -function renderCarbonSites(report) { - const target = document.getElementById("carbonSiteSummary"); +function renderLayerSummary(report) { + const target = document.getElementById("topologyLayerSummary"); if (!target || !report) return; - const nodes = report.nodes || []; + if (activeLayer === "network") { + target.innerHTML = `
NETWORK LAYER

网络状态图例

链路排障
+

保留设备角色颜色,青色高亮当前任务路径;链路标签展示时延与带宽,点击节点或链路查看详细指标。

+
+ 当前路径任务正在使用 + 拥塞带宽或风险异常 + 故障链路已隔离 +
`; + return; + } + const nodes = report.nodes ?? []; + if (activeLayer === "load") { + const groups = groupNodes(nodes, (node) => dcKey(node)); + target.innerHTML = `
RESOURCE LAYER

数据中心负载

实时遥测
+

颜色取 CPU、内存、GPU 三项中的最高利用率:低于 60% 为充足,60%–79% 需观察,80% 以上为热点。

+
${["dc1", "dc2", "dc3"].map((key) => renderLoadSummary(key, groups.get(key) ?? [])).join("")}
+ ${renderHeatLegend("负载", "%")}`; + return; + } + const sites = new Map(); for (const node of nodes) { const key = node.site_id || node.region || "unknown"; - if (!sites.has(key)) sites.set(key, { nodes: 0, pue: 0, ci: 0, power: 0 }); + if (!sites.has(key)) sites.set(key, { nodes: 0, pue: 0, ci: 0, power: 0, dc: dcKey(node) }); const item = sites.get(key); item.nodes += 1; item.pue += Number(node.carbon_profile?.pue || 1); - item.ci += Number(node.carbon_profile?.carbon_intensity_g_per_kwh || 0); + item.ci += carbonIntensity(node); item.power += Number(node.current_power_w || 0); } - target.innerHTML = `

${activeLayer === "carbon" ? "站点碳强度图层" : activeLayer === "load" ? "站点负载图层" : "站点能源画像"}

${Array.from(sites.entries()).map(([site, item]) => `
${escapeHtml(site)}PUE ${fmt(item.pue / item.nodes, 2)}CI ${fmt(item.ci / item.nodes, 1)} g/kWh${fmt(item.power, 1)} W
`).join("") || `

等待节点能源遥测

`}`; + const sortedSites = Array.from(sites.entries()).sort((left, right) => (left[1].ci / left[1].nodes) - (right[1].ci / right[1].nodes)); + target.innerHTML = `
CARBON LAYER

站点碳强度

低碳优先
+

颜色依据实时 CI:300 g/kWh 以下为低碳,301–450 为中等,超过 450 为高碳;列表按 CI 从低到高排列。

+
${sortedSites.map(([site, item], index) => renderCarbonSummary(site, item, index === 0)).join("") || `

等待节点能源遥测

`}
+ ${renderHeatLegend("CI", "g/kWh")}`; +} + +function renderLoadSummary(key, nodes) { + const metrics = aggregateNodeMetrics(nodes); + const value = Math.max(metrics.cpu, metrics.memory, metrics.gpu); + const level = heatLevel(value, 60, 80); + const state = { low: "容量充足", medium: "需观察", high: "资源热点" }[level]; + return `
+
${escapeHtml(key.toUpperCase())}${escapeHtml(state)}
+
CPU${fmt(metrics.cpu, 1)}%内存${fmt(metrics.memory, 1)}%GPU${fmt(metrics.gpu, 1)}%任务${metrics.tasks}
+
`; +} + +function renderCarbonSummary(site, item, recommended) { + const ci = item.nodes ? item.ci / item.nodes : 0; + const level = heatLevel(ci, 301, 451); + const state = { low: "低碳", medium: "中等", high: "高碳" }[level]; + const dcLabel = item.dc && item.dc !== "unknown" ? item.dc.toUpperCase() : displayDcLabel(site); + return `
+
${escapeHtml(dcLabel)}${recommended ? "推荐 · " : ""}${state}
+
CI${fmt(ci, 1)} g/kWhPUE${fmt(item.pue / item.nodes, 2)}功率${fmt(item.power, 1)} W
+
`; +} + +function displayDcLabel(site) { + const match = String(site ?? "").match(/^site[-_\s]?(\d+)$/i); + return match ? `DC${match[1]}` : String(site || "未知机房"); +} + +function renderHeatLegend(label, unit) { + return `
${escapeHtml(label)}分级 · ${escapeHtml(unit)}
`; +} + +function applyLayerVisuals(report, canvas) { + if (!canvas || activeLayer === "network") return; + const nodes = report?.nodes ?? []; + const dcGroups = groupNodes(nodes, (node) => dcKey(node)); + const locationGroups = groupNodes(nodes, (node) => String(node.location ?? "").toLowerCase()); + + canvas.querySelectorAll(".network-node.dc[data-node]").forEach((element) => { + decorateLayerTarget(element, dcGroups.get(element.dataset.node) ?? []); + }); + canvas.querySelectorAll(".compute-card[data-node]").forEach((element) => { + const location = locationFromText(element.dataset.node); + decorateLayerTarget(element, locationGroups.get(location) ?? []); + }); + canvas.querySelectorAll(".vm-node[data-vm]").forEach((element) => { + const source = actualNodeForVm(nodes, element); + decorateLayerTarget(element, source ? [source] : []); + }); +} + +function decorateLayerTarget(element, nodes) { + if (!nodes.length) return; + let value; + let label; + let level; + if (activeLayer === "load") { + const metrics = aggregateNodeMetrics(nodes); + value = Math.max(metrics.cpu, metrics.memory, metrics.gpu); + label = `负载 ${fmt(value, 0)}%`; + level = heatLevel(value, 60, 80); + } else { + value = average(nodes.map(carbonIntensity)); + label = `CI ${fmt(value, 0)}`; + level = heatLevel(value, 301, 451); + } + element.classList.add("layer-heat-target", `heat-${level}`); + element.dataset.layerValue = label; + element.title = `${element.title || element.textContent.trim()} / ${label}`; +} + +function aggregateNodeMetrics(nodes) { + return { + cpu: average(nodes.map((node) => utilizationPercent(node, "cpu"))), + memory: average(nodes.map((node) => utilizationPercent(node, "memory"))), + gpu: average(nodes.map((node) => utilizationPercent(node, "gpu"))), + tasks: nodes.reduce((sum, node) => sum + (node.active_task_ids?.length ?? node.running_tasks?.length ?? 0), 0), + }; +} + +function utilizationPercent(node, key) { + const runtime = node.runtime_utilization?.[key]; + const direct = node[`${key}_utilization`] ?? node[`${key}_used_ratio`]; + const raw = runtime ?? direct; + if (raw !== undefined && Number.isFinite(Number(raw))) { + const value = Number(raw); + return Math.max(0, Math.min(100, value <= 1 ? value * 100 : value)); + } + const capacity = Number(node.capacity?.[key] ?? 0); + const available = Number(node.available?.[key] ?? capacity); + return capacity > 0 ? Math.max(0, Math.min(100, ((capacity - available) / capacity) * 100)) : 0; +} + +function carbonIntensity(node) { + return Number(node.carbon_profile?.current_carbon_intensity_g_per_kwh ?? node.carbon_profile?.carbon_intensity_g_per_kwh ?? 0); +} + +function actualNodeForVm(nodes, element) { + if (element.dataset.nodeId) { + const exact = nodes.find((node) => node.node_id === element.dataset.nodeId); + if (exact) return exact; + } + const location = locationFromText(element.dataset.cluster); + const vmIndex = Math.max(0, Number(String(element.dataset.name ?? "VM-01").match(/\d+/)?.[0] ?? 1) - 1); + return nodes.find((node) => String(node.location ?? "").toLowerCase() === location && new RegExp(`vm[-_]${vmIndex}$`, "i").test(node.node_id ?? "")); +} + +function locationFromText(value) { + const text = String(value ?? "").toLowerCase(); + return ["beijing", "hangzhou", "chengdu", "chongqing", "guangzhou", "shenzhen"].find((location) => text.includes(location)) ?? text; +} + +function dcKey(node) { + const text = `${node.region ?? ""} ${node.node_id ?? ""}`.toLowerCase(); + return text.match(/dc[-_]?([123])/) ? `dc${text.match(/dc[-_]?([123])/)[1]}` : "unknown"; +} + +function groupNodes(nodes, keyFor) { + const groups = new Map(); + for (const node of nodes) { + const key = keyFor(node); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(node); + } + return groups; +} + +function average(values) { + const valid = values.map(Number).filter(Number.isFinite); + return valid.length ? valid.reduce((sum, value) => sum + value, 0) / valid.length : 0; +} + +function heatLevel(value, mediumAt, highAt) { + if (value >= highAt) return "high"; + if (value >= mediumAt) return "medium"; + return "low"; } diff --git a/src/tianjun/interfaces/dashboard/static/js/router.js b/src/tianjun/interfaces/dashboard/static/js/router.js index 21c66b8..e374570 100644 --- a/src/tianjun/interfaces/dashboard/static/js/router.js +++ b/src/tianjun/interfaces/dashboard/static/js/router.js @@ -21,7 +21,12 @@ function navigate(to, updateHash = true) { const current = document.querySelector(".page:not([hidden])"); const target = document.getElementById(`page-${to}`); state.activePage = to; - document.querySelectorAll(".tab-btn").forEach((button) => button.classList.toggle("active", button.dataset.page === to)); + document.querySelectorAll(".tab-btn").forEach((button) => { + const selected = button.dataset.page === to; + button.classList.toggle("active", selected); + button.setAttribute("aria-selected", String(selected)); + button.tabIndex = selected ? 0 : -1; + }); if (updateHash && location.hash.slice(1) !== to) history.replaceState(null, "", `#${to}`); if (!target) { renderActive(); @@ -41,15 +46,35 @@ function navigate(to, updateHash = true) { target.classList.add("page-enter"); renderActive(); requestAnimationFrame(() => target.classList.remove("page-enter")); + const cached = state.pageReports[to]; + if (cached) { + state.report = { ...(state.summaryReport ?? {}), ...cached }; + renderActive(); + } + void refreshDashboard({ force: true }); } function renderActive() { renderers[state.activePage]?.(state.report, state.health); } -async function refreshDashboard() { +async function refreshDashboard({ force = false } = {}) { + if (state.refreshPromise && !force) return state.refreshPromise; + if (force) state.refreshController?.abort(); + const sequence = ++state.refreshSequence; + const controller = new AbortController(); + state.refreshController = controller; + const page = state.activePage; + const pageOptions = page === "tasks" ? { limit: 50 } : {}; + state.refreshPromise = (async () => { try { - const [report, health] = await Promise.all([fetchReport(), fetchHealth()]); + const requests = [fetchReport("summary", { signal: controller.signal }), fetchHealth({ signal: controller.signal })]; + if (page !== "overview") requests.push(fetchReport(page, { ...pageOptions, signal: controller.signal })); + const [summary, health, pageReport = summary] = await Promise.all(requests); + if (sequence !== state.refreshSequence) return; + const report = { ...summary, ...pageReport }; + state.summaryReport = summary; + state.pageReports[page] = pageReport; state.report = report; state.health = health; emit("report", { report, health }); @@ -58,12 +83,22 @@ async function refreshDashboard() { updateTopnav(report, health); updateAlertBanner(health); } catch (error) { + if (error.name === "AbortError") return; const dot = document.getElementById("statusDot"); dot.className = "status-dot error"; document.getElementById("systemStatus").textContent = "系统离线"; document.getElementById("modelStatus").textContent = "连接失败"; document.getElementById("hermesLlmStatus").textContent = error.message; + document.getElementById("autoRefreshStatus").textContent = "刷新失败,正在重试"; + } finally { + if (sequence === state.refreshSequence) { + state.refreshController = null; + state.refreshPromise = null; + schedulePolling(); + } } + })(); + return state.refreshPromise; } function updateTopnav(report, health) { @@ -76,11 +111,24 @@ function updateTopnav(report, health) { const llm = health?.chat_runtime?.llm ?? {}; document.getElementById("hermesLlmStatus").className = `badge ${llm.enabled ? "badge-success" : "badge-neutral"}`; document.getElementById("hermesLlmStatus").textContent = llm.enabled ? `当前模型 ${llm.settings?.model || "LLM 已启用"}` : "本地规则"; - const mcpCall = report?.toolchain_runtime?.external_mcp_last_success; + const toolchain = report?.toolchain_runtime ?? {}; + const mcpCall = toolchain.external_mcp_last_call; + const mcpSuccess = toolchain.external_mcp_last_success; const mcpStatus = document.getElementById("mcpStatus"); - mcpStatus.className = `badge ${mcpCall ? "badge-success" : "badge-neutral"}`; - mcpStatus.textContent = mcpCall ? `外部 MCP · ${mcpCall.tool_name}` : "MCP 尚无成功调用"; - mcpStatus.title = mcpCall ? `最近成功调用 ${new Date(mcpCall.timestamp * 1000).toLocaleString("zh-CN")}` : "进程启动不等于工具已连接"; + const latestCallSucceeded = mcpCall?.result_status === "success"; + mcpStatus.className = `badge ${mcpCall ? (latestCallSucceeded ? "badge-success" : "badge-danger") : "badge-neutral"}`; + mcpStatus.textContent = mcpCall + ? `${latestCallSucceeded ? "MCP 工具成功" : "MCP 工具失败"} · ${mcpCall.tool_name}` + : "MCP 等待工具调用"; + if (mcpCall) { + const callTime = new Date(mcpCall.timestamp * 1000).toLocaleString("zh-CN"); + const lastSuccess = mcpSuccess + ? `;最近成功:${mcpSuccess.tool_name}(${new Date(mcpSuccess.timestamp * 1000).toLocaleString("zh-CN")})` + : ";尚无成功调用"; + mcpStatus.title = `最近带 MCP 标识的工具请求:${mcpCall.tool_name}(${callTime}),状态:${mcpCall.result_status}${lastSuccess};成功 ${toolchain.external_mcp_success_count ?? 0}/${toolchain.external_mcp_call_count ?? 0}`; + } else { + mcpStatus.title = "尚未收到带工具标识的 MCP 请求;MCP 进程启动本身不计为工具调用"; + } document.getElementById("autoRefreshStatus").textContent = "自动刷新中"; document.getElementById("lastSync").textContent = new Date().toLocaleTimeString("zh-CN", { hour12: false }); } @@ -97,28 +145,59 @@ function updateAlertBanner(health) { } export function schedulePolling(intervalMs = 5000) { - if (state.pollHandle) clearInterval(state.pollHandle); - state.pollHandle = setInterval(() => void refreshDashboard(), intervalMs); + if (state.pollHandle) clearTimeout(state.pollHandle); + if (document.hidden) { + state.pollHandle = null; + document.getElementById("autoRefreshStatus").textContent = "后台暂停刷新"; + return null; + } + state.pollHandle = setTimeout(() => void refreshDashboard(), intervalMs); return state.pollHandle; } +function handleTabKeydown(event) { + const tabs = Array.from(document.querySelectorAll(".tab-btn")); + const index = tabs.indexOf(event.currentTarget); + if (index < 0 || !["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + const nextIndex = event.key === "Home" + ? 0 + : event.key === "End" + ? tabs.length - 1 + : (index + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length; + tabs[nextIndex].focus(); + navigate(tabs[nextIndex].dataset.page); +} + function init() { initOverview(); initScheduling(); initTopology(); initTasks(); initModel(); - document.querySelectorAll(".tab-btn").forEach((button) => button.addEventListener("click", () => navigate(button.dataset.page))); + document.querySelectorAll(".tab-btn").forEach((button) => { + button.addEventListener("click", () => navigate(button.dataset.page)); + button.addEventListener("keydown", handleTabKeydown); + }); window.addEventListener("hashchange", () => navigate(location.hash.slice(1), false)); - document.getElementById("refreshButton").addEventListener("click", () => void refreshDashboard()); - on("report:refresh", () => void refreshDashboard()); + document.getElementById("refreshButton").addEventListener("click", () => void refreshDashboard({ force: true })); + on("report:refresh", () => void refreshDashboard({ force: true })); + document.addEventListener("visibilitychange", () => { + if (document.hidden) { + if (state.pollHandle) clearTimeout(state.pollHandle); + state.pollHandle = null; + state.refreshController?.abort(); + document.getElementById("autoRefreshStatus").textContent = "后台暂停刷新"; + } else { + void refreshDashboard({ force: true }); + } + }); const initial = PAGES.includes(location.hash.slice(1)) ? location.hash.slice(1) : "overview"; document.querySelectorAll(".page").forEach((page) => { page.hidden = page.id !== `page-${initial}`; }); navigate(initial, false); - void refreshDashboard(); - schedulePolling(); + void refreshDashboard({ force: true }); } init(); diff --git a/src/tianjun/interfaces/dashboard/static/js/state.js b/src/tianjun/interfaces/dashboard/static/js/state.js index a68aa06..6c42302 100644 --- a/src/tianjun/interfaces/dashboard/static/js/state.js +++ b/src/tianjun/interfaces/dashboard/static/js/state.js @@ -1,5 +1,7 @@ export const state = { report: null, + summaryReport: null, + pageReports: {}, health: null, activePage: "overview", hermesSessionId: null, @@ -10,6 +12,9 @@ export const state = { hermesBusy: false, abortController: null, pollHandle: null, + refreshController: null, + refreshPromise: null, + refreshSequence: 0, selectedBatch: null, selectedBatchPlan: null, selectedBatchMetrics: null, diff --git a/src/tianjun/interfaces/dashboard/static/js/topology.js b/src/tianjun/interfaces/dashboard/static/js/topology.js index c2b4167..0e4910f 100644 --- a/src/tianjun/interfaces/dashboard/static/js/topology.js +++ b/src/tianjun/interfaces/dashboard/static/js/topology.js @@ -7,12 +7,13 @@ let latestTopologyReport = null; let livePathContext = null; const schedulerStatus = { - task: "inference-task-027", + task: "暂无活动任务", source: "User-Access", - target: "DC2 / 成都资源区", - strategy: "延迟优先 + 负载均衡", - link: "正常", - gnn: "0.91", + target: "--", + strategy: "--", + link: "空闲", + gnn: "--", + activityState: "idle", }; const dcZoneModel = { @@ -307,9 +308,27 @@ function updateLiveTopology(report) { schedulerStatus.strategy = livePathContext.strategy; schedulerStatus.link = livePathContext.linkStatus; schedulerStatus.gnn = livePathContext.gnn; + schedulerStatus.activityState = livePathContext.activityState; + + if (livePathContext.activityState === "idle") { + globalTopology.currentRoute = []; + globalTopology.currentPathText = "当前无活动调度路径"; + globalTopology.footer = [ + "实时来源:在线节点 inventory", + `在线节点:${(report?.nodes ?? []).filter((node) => node.online !== false).length} 个`, + "调度状态:当前无活动任务", + ]; + for (const topology of Object.values(dcTopologies)) { + topology.currentRoute = []; + topology.currentPath = "当前无活动调度路径"; + topology.internalPath = "当前无活动调度路径"; + topology.footer = ["当前无活动调度路径", `${topology.dcName} 资源遥测保持可用`, "路径高亮将在任务调度后恢复"]; + } + return; + } const targetRoute = globalRouteForTargetDc(livePathContext.dcKey); - globalTopology.currentRoute = targetRoute.nodes; + globalTopology.currentRoute = livePathContext.activityState === "idle" ? [] : targetRoute.nodes; globalTopology.currentPathText = livePathContext.globalPathText; globalTopology.footer = [ `实时来源:${livePathContext.sourceKind} / tick ${livePathContext.tick ?? "--"}`, @@ -323,9 +342,11 @@ function updateLiveTopology(report) { dcTopology.routeCluster = livePathContext.clusterId; dcTopology.routeVm = livePathContext.vmName; dcTopology.vmId = livePathContext.vmId; - dcTopology.currentPath = livePathContext.globalPathText.replace("实时调度路径:", ""); + dcTopology.currentPath = livePathContext.globalPathText.replace(/^(当前|最近)调度路径:/, ""); dcTopology.internalPath = `${dcTopology.nodes.find((item) => item.id === "gw")?.name ?? dcTopology.dcName} → Spine-A → Fabric Bus → ${livePathContext.leafLabel} → ${livePathContext.clusterName} → ${livePathContext.vmName}`; - dcTopology.currentRoute = ["gw", "spine-a", "fabric-bus", livePathContext.leafId, livePathContext.clusterId, livePathContext.vmId]; + dcTopology.currentRoute = livePathContext.activityState === "idle" + ? [] + : ["gw", "spine-a", "fabric-bus", livePathContext.leafId, livePathContext.clusterId, livePathContext.vmId]; dcTopology.footer = [ `当前路径:${dcTopology.internalPath}`, `实时任务:${livePathContext.taskId} / 阶段 ${livePathContext.stage}`, @@ -350,7 +371,7 @@ function updateLiveTopology(report) { item.health = livePathContext.zoneStatus; } if (item.id === "scheduler" && item.scheduler) { - item.scheduler.latestPath = livePathContext.globalPathText.replace("实时调度路径:", ""); + item.scheduler.latestPath = livePathContext.globalPathText.replace(/^(当前|最近)调度路径:/, ""); item.scheduler.gnnScore = livePathContext.gnn; item.scheduler.assignedTasks = Number(report?.totals?.running ?? 0) + Number(report?.totals?.pending ?? 0); item.scheduler.avoidance = livePathContext.linkStatus === "拥塞" ? "正在规避高风险链路" : "当前路径风险可控"; @@ -395,10 +416,20 @@ function updateResourceTopology(report) { total: stats.gpuTotal, percent: stats.gpuPercent, }, + nodes: nodes + .filter((nodeItem) => { + const parsed = parseDciNode(nodeItem.node_id, nodeItem); + return parsed.dcKey === dcKey && parsed.location === zoneInfo.id; + }) + .sort((left, right) => (parseDciNode(left.node_id, left).vmIndex ?? 0) - (parseDciNode(right.node_id, right).vmIndex ?? 0)), scheduleState: stats.cpuPercent >= 75 || stats.gpuPercent >= 80 ? "高负载" : "可调度", status: stats.cpuPercent >= 75 || stats.gpuPercent >= 80 ? "congested" : "ok", }; }); + for (const item of topology.nodes) { + if (item.id === "cluster-a") item.metrics = topology.zones[0] ?? item.metrics; + if (item.id === "cluster-b") item.metrics = topology.zones[1] ?? item.metrics; + } } } @@ -417,8 +448,9 @@ function buildLivePathContext(report) { const decision = latestBy([...(report.recent_decisions ?? [])], "tick"); const record = [...(report.execution_records ?? report.recent_records ?? [])].at(-1); const pending = [...(report.pending_task_queue ?? [])].at(-1); - const source = active ? "active_run" : progress ? "progress" : decision ? "decision" : record ? "record" : pending ? "pending" : "inventory"; - const payload = active ?? progress ?? decision ?? record ?? pending ?? {}; + const latestHistorical = Number(record?.tick ?? -1) >= Number(decision?.tick ?? -1) ? record : decision; + const source = active ? "active_run" : progress ? "progress" : latestHistorical === record && record ? "record" : decision ? "decision" : pending ? "pending" : "inventory"; + const payload = active ?? progress ?? latestHistorical ?? pending ?? {}; const task = payload.task ?? pending ?? {}; const nodeId = payload.node_id ?? payload.target_node_id ?? task.target_node_id ?? task.last_scheduled_node ?? firstOnlineNodeId(nodes); const node = nodeByNodeId.get(nodeId) ?? {}; @@ -447,8 +479,10 @@ function buildLivePathContext(report) { if (runningTaskIds.has(payload.task_id)) { zoneTaskCounts.set(parsed.location, Math.max(1, zoneTaskCounts.get(parsed.location) ?? 0)); } - const running = source === "active_run" || source === "progress"; - const linkStatus = risk != null && risk > 0.28 ? "拥塞" : running ? "调度中" : "正常"; + const taskStatus = report.task_statuses?.[payload.task_id ?? task.task_id]; + const running = source === "active_run" || source === "progress" || ["assigned", "running", "scheduling"].includes(taskStatus); + const activityState = running ? "current" : source === "decision" || source === "record" ? "recent" : "idle"; + const linkStatus = activityState === "idle" ? "空闲" : risk != null && risk > 0.28 ? "拥塞" : running ? "调度中" : "正常"; const sourceKind = { active_run: "正在执行", progress: "最新进度", @@ -457,11 +491,12 @@ function buildLivePathContext(report) { pending: "待调度任务", inventory: "在线拓扑", }[source]; - const taskId = payload.task_id ?? task.task_id ?? "等待任务"; + const taskId = activityState === "idle" ? "暂无活动任务" : payload.task_id ?? task.task_id ?? "等待任务"; const dcName = dcZoneModel[parsed.dcKey]?.dcName ?? parsed.dcKey.toUpperCase(); const targetLabel = `${dcName} / ${zoneModel.label} / ${vmName}`; return { sourceKind, + activityState, taskId, nodeId, dcKey: parsed.dcKey, @@ -474,15 +509,19 @@ function buildLivePathContext(report) { vmId, tick: payload.tick ?? report.tick, stage, - source: task.source_region ?? task.data_region ?? "User-Access", - targetLabel, - strategy: decision?.policy_name ?? task.task_type ?? payload.task_type ?? "实时租约调度", + source: activityState === "idle" ? "--" : task.source_region ?? task.data_region ?? "User-Access", + targetLabel: activityState === "idle" ? "--" : targetLabel, + strategy: activityState === "idle" ? "--" : decision?.policy_name ?? task.task_type ?? payload.task_type ?? "实时租约调度", linkStatus, gnn: gnn == null ? "--" : `${Math.round(gnn * 100)}%`, riskText: risk == null ? "--" : `${Math.round(risk * 100)}%`, latencyText: latency == null ? "--" : `${Number(latency).toFixed(1)}ms`, bandwidthText: bandwidth == null ? "--" : `${Math.round(Number(bandwidth))}Mbps`, - globalPathText: `实时调度路径:User-Access → ${dcName} → ${zoneModel.clusterName} / ${vmName}`, + globalPathText: activityState === "current" + ? `当前调度路径:User-Access → ${dcName} → ${zoneModel.clusterName} / ${vmName}` + : activityState === "recent" + ? `最近调度路径:User-Access → ${dcName} → ${zoneModel.clusterName} / ${vmName}` + : "当前无活动调度路径", zoneState: running ? "正在调度" : "可调度", zoneStatus: running ? "scheduling" : "ok", zoneTaskCounts, @@ -498,6 +537,7 @@ function latestBy(items, key) { function firstNumber(...values) { for (const value of values) { + if (value === null || value === undefined || value === "") continue; const numeric = Number(value); if (Number.isFinite(numeric)) return numeric; } @@ -536,6 +576,12 @@ function aggregateResources(nodes, scope) { memoryTotal: 0, gpuUsed: 0, gpuTotal: 0, + cpuTelemetry: 0, + cpuSamples: 0, + memoryTelemetry: 0, + memorySamples: 0, + gpuTelemetry: 0, + gpuSamples: 0, tasks: 0, }; bucket.nodes += 1; @@ -545,15 +591,30 @@ function aggregateResources(nodes, scope) { bucket.memoryUsed += resourceUsed(node, "memory"); bucket.gpuTotal += Number(node?.capacity?.gpu ?? 0); bucket.gpuUsed += resourceUsed(node, "gpu"); - bucket.tasks += Array.isArray(node.running_tasks) ? node.running_tasks.length : 0; + const cpuTelemetry = percentFrom(node.runtime_utilization?.cpu, node.runtime_telemetry?.cpu); + const memoryTelemetry = percentFrom(node.runtime_utilization?.memory, node.runtime_telemetry?.memory); + const gpuTelemetry = percentFrom(node.runtime_utilization?.gpu, node.runtime_telemetry?.gpu); + if (cpuTelemetry != null) { + bucket.cpuTelemetry += cpuTelemetry; + bucket.cpuSamples += 1; + } + if (memoryTelemetry != null) { + bucket.memoryTelemetry += memoryTelemetry; + bucket.memorySamples += 1; + } + if (gpuTelemetry != null) { + bucket.gpuTelemetry += gpuTelemetry; + bucket.gpuSamples += 1; + } + bucket.tasks += node.active_task_ids?.length ?? (Array.isArray(node.running_tasks) ? node.running_tasks.length : 0); result.set(key, bucket); } for (const bucket of result.values()) { - bucket.cpuPercent = ratioPercent(bucket.cpuUsed, bucket.cpuTotal); - bucket.memoryPercent = ratioPercent(bucket.memoryUsed, bucket.memoryTotal); + bucket.cpuPercent = bucket.cpuSamples ? Math.round(bucket.cpuTelemetry / bucket.cpuSamples) : ratioPercent(bucket.cpuUsed, bucket.cpuTotal); + bucket.memoryPercent = bucket.memorySamples ? Math.round(bucket.memoryTelemetry / bucket.memorySamples) : ratioPercent(bucket.memoryUsed, bucket.memoryTotal); bucket.gpuUsed = Math.round(bucket.gpuUsed); bucket.gpuTotal = Math.round(bucket.gpuTotal); - bucket.gpuPercent = ratioPercent(bucket.gpuUsed, bucket.gpuTotal); + bucket.gpuPercent = bucket.gpuSamples ? Math.round(bucket.gpuTelemetry / bucket.gpuSamples) : ratioPercent(bucket.gpuUsed, bucket.gpuTotal); } return result; } @@ -583,13 +644,13 @@ function zoneAggregate(nodes, report, metric) { const parsed = parseDciNode(node.node_id, node); if (!parsed.location) continue; if (metric === "tasks") { - const count = Array.isArray(node.running_tasks) ? node.running_tasks.length : 0; + const count = node.active_task_ids?.length ?? (Array.isArray(node.running_tasks) ? node.running_tasks.length : 0); result.set(parsed.location, (result.get(parsed.location) ?? 0) + count); } else if (metric === "cpu") { - const value = percentFrom(node.cpu_utilization, node.used_cpu_ratio); + const value = percentFrom(node.runtime_utilization?.cpu, node.runtime_telemetry?.cpu, node.cpu_utilization, node.used_cpu_ratio); if (value != null) result.set(parsed.location, Math.max(result.get(parsed.location) ?? 0, value)); } else if (metric === "memory") { - const value = percentFrom(node.memory_utilization, node.used_memory_ratio); + const value = percentFrom(node.runtime_utilization?.memory, node.runtime_telemetry?.memory, node.memory_utilization, node.used_memory_ratio); if (value != null) result.set(parsed.location, Math.max(result.get(parsed.location) ?? 0, value)); } else if (metric === "gpu") { const current = result.get(parsed.location) ?? { used: 0, total: 0, percent: 0 }; @@ -625,19 +686,6 @@ function gpuSummary(value) { return gpu.total > 0 ? `${gpu.used}/${gpu.total} (${gpu.percent}%)` : "0/0"; } -function vmGpuFor(zoneInfo, index) { - const gpu = normalizeGpu(zoneInfo.gpu); - const vmCount = Math.max(1, Number(zoneInfo.vmCount ?? 1)); - if (gpu.total <= 0) return { used: 0, total: 0, percent: 0 }; - const baseTotal = Math.max(1, Math.floor(gpu.total / vmCount)); - const remainder = gpu.total % vmCount; - const total = baseTotal + (index < remainder ? 1 : 0); - const baseUsed = Math.floor(gpu.used / vmCount); - const usedRemainder = gpu.used % vmCount; - const used = Math.min(total, baseUsed + (index < usedRemainder ? 1 : 0)); - return { used, total, percent: ratioPercent(used, total) }; -} - function nodeName(leafId, location) { const suffix = leafId.endsWith("a") || leafId.endsWith("b") ? "1" : "2"; return `Leaf-${String(location || "zone").toUpperCase()}-${suffix}`; @@ -697,10 +745,12 @@ function iconFor(type) { } function renderStatusBar() { - return `
- ${statusBadge("当前任务", schedulerStatus.task, "scheduling")} + const taskLabel = schedulerStatus.activityState === "current" ? "当前任务" : schedulerStatus.activityState === "recent" ? "最近任务" : "调度状态"; + const tone = schedulerStatus.activityState === "current" ? "scheduling" : "neutral"; + return `
+ ${statusBadge(taskLabel, schedulerStatus.task, tone)} ${statusBadge("源区域", schedulerStatus.source, "neutral")} - ${statusBadge("目标区域", schedulerStatus.target, "scheduling")} + ${statusBadge("目标区域", schedulerStatus.target, tone)} ${statusBadge("调度策略", schedulerStatus.strategy, "neutral")} ${statusBadge("链路状态", schedulerStatus.link, "ok")} ${statusBadge("GNN 稳定性评分", schedulerStatus.gnn, "ok")} @@ -978,31 +1028,56 @@ function renderClusterCard(topology, id, zoneInfo) { } function renderVmNode(topology, clusterId, zoneInfo, index, routeCluster) { - const vmName = `VM-${String(index + 1).padStart(2, "0")}`; + const actualNode = zoneInfo.nodes?.[index] ?? null; + const parsedNode = actualNode ? parseDciNode(actualNode.node_id, actualNode) : null; + const vmNumber = parsedNode?.vmIndex == null ? index + 1 : parsedNode.vmIndex + 1; + const vmName = `VM-${String(vmNumber).padStart(2, "0")}`; const vmId = `${clusterId}-vm-${String(index + 1).padStart(2, "0")}`; const active = topology.key === currentTargetDcKey() && routeCluster && vmId === topology.vmId; const selected = selectedDetail?.kind === "vm" && selectedDetail.id === vmId; - const cpu = Math.min(92, Math.max(12, zoneInfo.cpu + (index - 1) * 6)); - const memory = Math.min(90, Math.max(18, zoneInfo.memory + (index % 2 === 0 ? -4 : 5))); - const gpu = vmGpuFor(zoneInfo, index); - const state = active ? "正在调度" : cpu > 78 ? "高负载" : "可调度"; + const cpu = utilizationOf(actualNode, "cpu"); + const memory = utilizationOf(actualNode, "memory"); + const gpuUtilization = utilizationOf(actualNode, "gpu"); + const gpuTotal = Number(actualNode?.capacity?.gpu ?? 0); + const gpuUsed = Math.max(0, gpuTotal - Number(actualNode?.available?.gpu ?? gpuTotal)); + const gpu = { used: gpuUsed, total: gpuTotal, percent: gpuUtilization }; + const taskCount = actualNode?.active_task_ids?.length ?? actualNode?.running_tasks?.length ?? 0; + const peak = Math.max(cpu ?? 0, memory ?? 0, gpuUtilization ?? 0); + const hasTelemetry = cpu != null || memory != null || gpuUtilization != null; + const state = active ? "正在调度" : !hasTelemetry ? "遥测待上报" : peak > 78 ? "高负载" : "可调度"; + const cpuText = formatUtilization(cpu); + const memoryText = formatUtilization(memory); + const gpuPercentText = formatUtilization(gpuUtilization); return ``; } +function utilizationOf(node, key) { + if (!node) return null; + const value = firstNumber(node.runtime_utilization?.[key], node.runtime_telemetry?.[key]); + if (value == null) return null; + return Math.round(Math.max(0, Math.min(100, value <= 1 ? value * 100 : value))); +} + +function formatUtilization(value) { + return value == null ? "--" : `${value}%`; +} + function renderSupportBus(topology) { const target = topology.key === currentTargetDcKey(); return `
@@ -1112,29 +1187,34 @@ function renderDetails(topology) { function renderVmDetails(vm) { const cluster = nodeById(currentTopology(), vm.clusterId); - const gpuDetail = detailRow("GPU", `${vm.gpuUsed ?? 0}/${vm.gpuTotal ?? 0} (${vm.gpuPercent ?? 0}%)`); + const gpuPercent = vm.gpuPercent === "--" ? "利用率 --" : `利用率 ${vm.gpuPercent}`; + const gpuDetail = detailRow("GPU", `${vm.gpuUsed ?? 0}/${vm.gpuTotal ?? 0}(${gpuPercent})`); return `

VM 节点详情

${detailRow("节点名称", vm.name)} ${detailRow("所属集群", cluster?.name ?? vm.clusterId)} ${detailRow("所属资源区", vm.zone)} ${detailRow("节点职责", "任务执行 / 算力资源实例")} - ${detailRow("CPU 使用率", `${vm.cpu}%`)} - ${detailRow("内存使用率", `${vm.memory}%`)} + ${detailRow("节点 ID", vm.nodeId || "--")} + ${detailRow("CPU 使用率", vm.cpu)} + ${detailRow("内存使用率", vm.memory)} ${gpuDetail} ${detailRow("当前任务数", `${vm.taskCount} 个`)} + ${detailRow("遥测来源", vm.telemetrySource)} ${detailRow("调度状态", vm.state)} ${detailRow("推荐状态", vm.state === "高负载" ? "暂不推荐" : "可作为候选执行节点")}
`; } function renderOverviewDetails(topology) { + const taskLabel = schedulerStatus.activityState === "current" ? "当前任务" : schedulerStatus.activityState === "recent" ? "最近任务" : "调度状态"; + const pathLabel = schedulerStatus.activityState === "recent" ? "最近路径" : "当前路径"; return `

当前拓扑概览

${detailRow("拓扑视图", topology.title)} - ${detailRow("当前任务", schedulerStatus.task)} + ${detailRow(taskLabel, schedulerStatus.task)} ${detailRow("调度策略", schedulerStatus.strategy)} - ${detailRow("当前路径", topology.kind === "global" ? globalTopology.currentPathText.replace("当前任务调度路径:", "") : topology.currentPath)} + ${detailRow(pathLabel, topology.kind === "global" ? globalTopology.currentPathText.replace(/^(当前|最近)调度路径:/, "") : topology.currentPath)} ${detailRow("链路状态", schedulerStatus.link)} ${detailRow("GNN 稳定性评分", schedulerStatus.gnn)}

点击 DC1 / DC2 / DC3 可进入内部拓扑;点击节点或链路查看更细指标。

@@ -1143,6 +1223,10 @@ function renderOverviewDetails(topology) { function renderPathMetrics() { if (!livePathContext) return ""; + if (livePathContext.activityState === "idle") { + return `
数据来源${escapeHtml(livePathContext.sourceKind)}
+
调度状态当前无活动任务
`; + } const items = [ ["数据来源", livePathContext.sourceKind], ["目标节点", livePathContext.nodeId], @@ -1176,8 +1260,20 @@ function renderMetricRows(item) { if (!item?.metrics) return ""; const metrics = item.metrics; const gpuDetail = detailRow("GPU", gpuSummary(metrics.gpu)); - const available = Math.max(0, metrics.vmCount - Math.ceil(metrics.tasks / 5)); - const highLoad = metrics.cpu > 70 ? 2 : metrics.cpu > 55 ? 1 : 0; + const observedNodes = (metrics.nodes ?? []).map((node) => ({ + node, + peak: Math.max(utilizationOf(node, "cpu") ?? 0, utilizationOf(node, "memory") ?? 0, utilizationOf(node, "gpu") ?? 0), + observed: [utilizationOf(node, "cpu"), utilizationOf(node, "memory"), utilizationOf(node, "gpu")].some((value) => value != null), + })); + const available = observedNodes.filter((item) => item.node.online !== false && item.observed && item.peak < 80).length; + const highLoad = observedNodes.filter((item) => item.observed && item.peak >= 80).length; + const recommended = observedNodes + .filter((item) => item.node.online !== false && item.observed && item.peak < 80) + .sort((left, right) => left.peak - right.peak)[0]?.node; + const parsedRecommended = recommended ? parseDciNode(recommended.node_id, recommended) : null; + const recommendedLabel = parsedRecommended?.vmIndex == null + ? "遥测不足" + : `VM-${String(parsedRecommended.vmIndex + 1).padStart(2, "0")}`; return `${gpuDetail} ${detailRow("VM 数量", `${metrics.vmCount} 个`)} ${detailRow("CPU 使用率", `${metrics.cpu}%`)} @@ -1185,7 +1281,7 @@ function renderMetricRows(item) { ${detailRow("当前任务数", `${metrics.tasks} 个`)} ${detailRow("可调度 VM", `${available} 个`)} ${detailRow("高负载 VM", `${highLoad} 个`)} - ${detailRow("推荐调度目标", metrics.status === "congested" ? "暂不推荐" : "VM-02")}`; + ${detailRow("推荐调度目标", metrics.status === "congested" ? "暂不推荐" : recommendedLabel)}`; } function renderSchedulerRows(item) { @@ -1275,6 +1371,7 @@ function bindInteractions(topology, container, detailPanel) { clusterId: element.dataset.cluster, zone: element.dataset.zone, name: element.dataset.name, + nodeId: element.dataset.nodeId, cpu: element.dataset.cpu, memory: element.dataset.memory, gpuUsed: element.dataset.gpuUsed, @@ -1282,6 +1379,7 @@ function bindInteractions(topology, container, detailPanel) { gpuPercent: element.dataset.gpuPercent, state: element.dataset.state, taskCount: element.dataset.taskCount, + telemetrySource: element.dataset.telemetrySource, }; renderTopology(null, container); }, { capture: true }); @@ -1308,6 +1406,13 @@ function bindInteractions(topology, container, detailPanel) { selectedDetail = { kind: "link", id: element.dataset.link }; renderTopology(null, container); }); + if (element.getAttribute("role") === "button") { + element.addEventListener("keydown", (event) => { + if (!['Enter', ' '].includes(event.key)) return; + event.preventDefault(); + element.click(); + }); + } }); container.querySelector("[data-back-global]")?.addEventListener("click", (event) => { diff --git a/src/tianjun/interfaces/dashboard/static/js/utils.js b/src/tianjun/interfaces/dashboard/static/js/utils.js index 62ce60d..c28ebdf 100644 --- a/src/tianjun/interfaces/dashboard/static/js/utils.js +++ b/src/tianjun/interfaces/dashboard/static/js/utils.js @@ -262,6 +262,11 @@ export function stableLatencyOf(node) { } export function resourceUtil(node, key) { + const runtime = node?.runtime_utilization?.[key] ?? node?.runtime_telemetry?.[key]; + if (runtime !== null && runtime !== undefined && Number.isFinite(Number(runtime))) { + const value = Number(runtime); + return Math.max(0, Math.min(1, value > 1 ? value / 100 : value)); + } const total = Number(node?.capacity?.[key] ?? 0); const available = Number(node?.available?.[key] ?? 0); if (total <= 0) return 0; diff --git a/src/tianjun/interfaces/http/server.py b/src/tianjun/interfaces/http/server.py index 1f4c0e9..5243d60 100644 --- a/src/tianjun/interfaces/http/server.py +++ b/src/tianjun/interfaces/http/server.py @@ -1,8 +1,10 @@ from __future__ import annotations import json +import logging import mimetypes import time +import uuid from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any @@ -10,12 +12,43 @@ from ...application.control_plane import CentralControlPlane from ...application.batch_scheduling_service import BatchRequestError, MAX_BATCH_BYTES +from ...application.dashboard_reporting import dashboard_report_view from ...chat import ChatRuntime from ...scenarios import node_from_dict, task_from_dict from ..dashboard.page import render_dashboard_html from .legacy_routes import handle_legacy_get, handle_legacy_post STATIC_DASHBOARD_DIR = Path(__file__).resolve().parents[1] / "dashboard" / "static" +LOGGER = logging.getLogger(__name__) + + +def _public_health_payload(control_plane: CentralControlPlane, chat: ChatRuntime) -> dict[str, Any]: + model_runtime = dict(control_plane.scheduler.model_runtime.describe()) + model_runtime.pop("model_dir", None) + trained_models = model_runtime.pop("trained_models", {}) or {} + model_runtime["trained_models"] = sorted(trained_models) + + chat_runtime = chat.describe() + llm = dict(chat_runtime.get("llm") or {}) + settings = dict(llm.get("settings") or {}) + settings.pop("api_key_fingerprint", None) + settings.pop("api_key_source", None) + llm["settings"] = settings + chat_runtime = {**chat_runtime, "llm": llm} + + issues: list[str] = [] + if model_runtime.get("status") in {"error", "missing", "unavailable"}: + issues.append("模型运行时不可用") + if settings.get("required") and not llm.get("enabled"): + issues.append("必需的 LLM 未启用") + return { + "status": "ok" if not issues else "degraded", + "ready": not issues, + "issues": issues, + "model_runtime": model_runtime, + "chat_runtime": chat_runtime, + "persistence": {"enabled": control_plane.state_store is not None}, + } def build_http_server( @@ -27,7 +60,10 @@ def build_http_server( ) -> ThreadingHTTPServer: chat = chat_runtime or ChatRuntime(control_plane) class ControlPlaneHandler(BaseHTTPRequestHandler): - server_version = "TianjunControlPlane/0.2" + server_version = "TianjunControlPlane/0.3" + + def version_string(self) -> str: + return self.server_version def do_GET(self) -> None: # noqa: N802 path = urlparse(self.path).path @@ -40,29 +76,40 @@ def do_GET(self) -> None: # noqa: N802 if path == "/report": self._write_json(200, control_plane.build_report()) return - if path == "/health": + if path.startswith("/report/"): + view = path.removeprefix("/report/").strip("/") + query = parse_qs(urlparse(self.path).query) + cursor = int(query.get("cursor", ["0"])[0]) + limit = int(query.get("limit", ["50"])[0]) self._write_json( 200, - { - "status": "ok", - "model_runtime": control_plane.scheduler.model_runtime.describe(), - "chat_runtime": chat.describe(), - }, + dashboard_report_view( + control_plane.build_report(), + view, + cursor=cursor, + limit=limit, + ), ) return + if path == "/health": + payload = _public_health_payload(control_plane, chat) + self._write_json(200, payload) + return + if path == "/ready": + payload = _public_health_payload(control_plane, chat) + self._write_json(200 if payload["ready"] else 503, payload) + return if handle_legacy_get(self, path, control_plane, chat): return if path.startswith("/task-batches/"): if path.endswith("/metrics"): batch_id = path.removeprefix("/task-batches/").removesuffix("/metrics").strip("/") result = control_plane.get_task_batch_actual_metrics(batch_id) - self._record_external_tool("get_batch_actual_metrics", result, batch_id=batch_id) self._write_json(200, result) return batch_id = path.removeprefix("/task-batches/").strip("/") if batch_id: result = control_plane.get_task_batch(batch_id) - self._record_external_tool("get_task_batch", result, batch_id=batch_id) self._write_json(200, result) return if path.startswith("/policies/"): @@ -84,7 +131,7 @@ def do_GET(self) -> None: # noqa: N802 except BatchRequestError as exc: self._write_json(exc.status_code, exc.payload) except Exception as exc: # noqa: BLE001 - self._write_json(400, {"error": str(exc)}) + self._write_exception(exc) def do_POST(self) -> None: # noqa: N802 path = urlparse(self.path).path @@ -97,7 +144,6 @@ def do_POST(self) -> None: # noqa: N802 result = control_plane.import_task_batch_csv(raw.decode("utf-8"), batch_name=name) else: result = control_plane.import_task_batch(json.loads(raw.decode("utf-8") or "{}")) - self._record_external_tool("import_task_batch", result, batch_id=result.get("batch_id")) self._write_json(201, result) return payload = self._read_json() @@ -125,6 +171,9 @@ def do_POST(self) -> None: # noqa: N802 operational_carbon_g_delta=payload.get("operational_carbon_g_delta"), carbon_intensity_g_per_kwh=payload.get("carbon_intensity_g_per_kwh"), carbon_signal_timestamp=payload.get("carbon_signal_timestamp"), + runtime_telemetry=payload.get("telemetry"), + telemetry_source=("cloudsim" if payload.get("simulated") else "node_agent"), + simulation_tick=payload.get("sim_tick"), ) self._write_json(200, result) return @@ -135,14 +184,10 @@ def do_POST(self) -> None: # noqa: N802 batch_id = path.removeprefix("/task-batches/").removesuffix(suffix).strip("/") if suffix == "/preview": result = control_plane.preview_batch_schedule(batch_id, payload) - tool_name = "preview_batch_schedule" elif suffix == "/compare": result = control_plane.compare_batch_strategies(batch_id, payload) - tool_name = "compare_batch_strategies" else: result = control_plane.commit_batch_schedule(batch_id, payload) - tool_name = "commit_batch_schedule" - self._record_external_tool(tool_name, result, batch_id=batch_id, plan_id=result.get("plan_id")) self._write_json(200, result) return if path == "/schedule/preview": @@ -341,7 +386,7 @@ def do_POST(self) -> None: # noqa: N802 except BatchRequestError as exc: self._write_json(exc.status_code, exc.payload) except Exception as exc: # noqa: BLE001 - self._write_json(400, {"error": str(exc)}) + self._write_exception(exc) def log_message(self, format: str, *args: Any) -> None: # noqa: A003 return @@ -357,10 +402,13 @@ def _read_body(self, max_bytes: int) -> bytes: return self.rfile.read(length) if length else b"" def _write_json(self, status: int, payload: Any) -> None: + self._record_external_tool_response(status, payload) body = json.dumps(payload, ensure_ascii=False).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self._write_security_headers() self.end_headers() self.wfile.write(body) @@ -378,6 +426,8 @@ def _write_dashboard_static(self, path: str) -> bool: self.send_response(200) self.send_header("Content-Type", f"{content_type}; charset=utf-8") self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-cache") + self._write_security_headers() self.end_headers() self.wfile.write(body) return True @@ -427,22 +477,17 @@ def _dashboard_payload_from_chat_result(self, result: dict[str, Any]) -> dict[st "policy": policy, } - def _record_external_tool( - self, - fallback_tool_name: str, - result: dict[str, Any], - *, - batch_id: str | None = None, - plan_id: str | None = None, - ) -> None: - if self.headers.get("X-Tianjun-Caller") != "external_mcp": + def _record_external_tool_response(self, status: int, payload: Any) -> None: + tool_name = self.headers.get("X-Tianjun-Tool") + if self.headers.get("X-Tianjun-Caller") != "external_mcp" or not tool_name: return + result = payload if isinstance(payload, dict) else {} control_plane.record_tool_call( - tool_name=self.headers.get("X-Tianjun-Tool") or fallback_tool_name, + tool_name=tool_name, actor="external_mcp", - result_status="success", - batch_id=batch_id, - plan_id=plan_id, + result_status="success" if 200 <= status < 400 else "error", + batch_id=result.get("batch_id"), + plan_id=result.get("plan_id"), session_id=self.headers.get("X-Tianjun-Session"), request_id=self.headers.get("X-Request-ID"), ) @@ -453,6 +498,7 @@ def _write_chat_event_stream(self, runner) -> None: self.send_header("Cache-Control", "no-cache, no-transform") self.send_header("Connection", "close") self.send_header("X-Accel-Buffering", "no") + self._write_security_headers() self.end_headers() def emit(event: dict[str, Any]) -> None: @@ -474,7 +520,34 @@ def _write_html(self, status: int, payload: str) -> None: self.send_response(status) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-cache") + self._write_security_headers() self.end_headers() self.wfile.write(body) + def _write_security_headers(self) -> None: + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("X-Frame-Options", "DENY") + self.send_header("Referrer-Policy", "no-referrer") + self.send_header( + "Content-Security-Policy", + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; connect-src 'self'; object-src 'none'; " + "base-uri 'none'; frame-ancestors 'none'", + ) + + def _write_exception(self, exc: Exception) -> None: + if isinstance(exc, KeyError): + self._write_json(404, {"error": "not_found", "detail": str(exc).strip("'")}) + return + if isinstance(exc, PermissionError): + self._write_json(403, {"error": "forbidden", "detail": str(exc)}) + return + if isinstance(exc, (ValueError, TypeError, json.JSONDecodeError)): + self._write_json(400, {"error": "invalid_request", "detail": str(exc)}) + return + request_id = self.headers.get("X-Request-ID") or uuid.uuid4().hex[:12] + LOGGER.exception("Unhandled HTTP request error request_id=%s", request_id, exc_info=exc) + self._write_json(500, {"error": "internal_error", "request_id": request_id}) + return ThreadingHTTPServer((host, port), ControlPlaneHandler) diff --git a/src/tianjun/scenarios/fixtures.py b/src/tianjun/scenarios/fixtures.py index e40d050..e0822d1 100644 --- a/src/tianjun/scenarios/fixtures.py +++ b/src/tianjun/scenarios/fixtures.py @@ -52,6 +52,12 @@ def node_from_dict(data: dict[str, Any]) -> Node: task_energy_kwh_total=float(data.get("task_energy_kwh_total", 0.0)), task_operational_carbon_g_total=float(data.get("task_operational_carbon_g_total", 0.0)), carbon_signal_timestamp=data.get("carbon_signal_timestamp"), + runtime_telemetry={ + str(key): float(value) + for key, value in dict(data.get("runtime_telemetry", {})).items() + }, + telemetry_source=data.get("telemetry_source"), + simulation_tick=data.get("simulation_tick"), ) diff --git a/src/tianjun/storage/sqlite_state_store.py b/src/tianjun/storage/sqlite_state_store.py index 635cb54..62f9d06 100644 --- a/src/tianjun/storage/sqlite_state_store.py +++ b/src/tianjun/storage/sqlite_state_store.py @@ -9,12 +9,18 @@ class SQLiteStateStore: + MAX_HEARTBEATS = 10_000 + MAX_EXECUTION_RECORDS = 2_000 + MAX_DECISIONS = 2_000 + MAX_POLICY_ADJUSTMENTS = 1_000 + def __init__(self, path: str | Path) -> None: self.path = Path(path) self.path.parent.mkdir(parents=True, exist_ok=True) self.lock = threading.RLock() self.conn = sqlite3.connect(str(self.path), check_same_thread=False) self.conn.row_factory = sqlite3.Row + self._heartbeat_writes = 0 self._initialize_schema() def close(self) -> None: @@ -47,19 +53,22 @@ def load_state(self) -> dict[str, Any]: execution_records = [ json.loads(row["payload_json"]) for row in self.conn.execute( - "SELECT payload_json FROM execution_records ORDER BY id" + "SELECT payload_json FROM (SELECT id, payload_json FROM execution_records ORDER BY id DESC LIMIT ?) ORDER BY id", + (self.MAX_EXECUTION_RECORDS,), ).fetchall() ] decisions = [ json.loads(row["payload_json"]) for row in self.conn.execute( - "SELECT payload_json FROM decisions ORDER BY id" + "SELECT payload_json FROM (SELECT id, payload_json FROM decisions ORDER BY id DESC LIMIT ?) ORDER BY id", + (self.MAX_DECISIONS,), ).fetchall() ] policy_adjustments = [ json.loads(row["payload_json"]) for row in self.conn.execute( - "SELECT payload_json FROM policy_adjustments ORDER BY id" + "SELECT payload_json FROM (SELECT id, payload_json FROM policy_adjustments ORDER BY id DESC LIMIT ?) ORDER BY id", + (self.MAX_POLICY_ADJUSTMENTS,), ).fetchall() ] control_state = { @@ -128,6 +137,9 @@ def record_heartbeat(self, node_id: str, payload: dict[str, Any]) -> None: """, (node_id, payload_json, now), ) + self._heartbeat_writes += 1 + if self._heartbeat_writes % 256 == 0: + self._prune_table_locked("heartbeats", self.MAX_HEARTBEATS) self.conn.commit() def save_lease(self, lease_payload: dict[str, Any]) -> None: @@ -169,6 +181,7 @@ def append_execution_record(self, record_payload: dict[str, Any]) -> None: now, ), ) + self._prune_table_locked("execution_records", self.MAX_EXECUTION_RECORDS) self.conn.commit() def append_decision(self, decision_payload: dict[str, Any]) -> None: @@ -182,6 +195,7 @@ def append_decision(self, decision_payload: dict[str, Any]) -> None: """, (decision_payload["task_id"], decision_payload["node_id"], payload_json, now), ) + self._prune_table_locked("decisions", self.MAX_DECISIONS) self.conn.commit() def append_policy_adjustment(self, adjustment_payload: dict[str, Any]) -> None: @@ -195,6 +209,7 @@ def append_policy_adjustment(self, adjustment_payload: dict[str, Any]) -> None: """, (adjustment_payload["tick"], payload_json, now), ) + self._prune_table_locked("policy_adjustments", self.MAX_POLICY_ADJUSTMENTS) self.conn.commit() def set_control_value(self, key: str, value: Any) -> None: @@ -279,4 +294,18 @@ def _initialize_schema(self) -> None: ); """ ) + self.conn.execute("PRAGMA user_version = 1") + self._prune_table_locked("heartbeats", self.MAX_HEARTBEATS) + self._prune_table_locked("execution_records", self.MAX_EXECUTION_RECORDS) + self._prune_table_locked("decisions", self.MAX_DECISIONS) + self._prune_table_locked("policy_adjustments", self.MAX_POLICY_ADJUSTMENTS) self.conn.commit() + + def _prune_table_locked(self, table: str, keep: int) -> None: + allowed = {"heartbeats", "execution_records", "decisions", "policy_adjustments"} + if table not in allowed: + raise ValueError(f"unsupported retention table: {table}") + self.conn.execute( + f"DELETE FROM {table} WHERE id NOT IN (SELECT id FROM {table} ORDER BY id DESC LIMIT ?)", + (max(1, int(keep)),), + ) diff --git a/tests/test_control_plane_services.py b/tests/test_control_plane_services.py index fefc664..1298dd5 100644 --- a/tests/test_control_plane_services.py +++ b/tests/test_control_plane_services.py @@ -7,6 +7,7 @@ from tianjun.application.task_lease_service import TaskLeaseService from tianjun.core import UserRequirement from tianjun.domain import NetworkPathProfile, Node, ResourceVector, Task, TaskStatus +from tianjun.storage.sqlite_state_store import SQLiteStateStore def test_control_plane_exposes_service_boundaries() -> None: @@ -20,6 +21,46 @@ def test_control_plane_exposes_service_boundaries() -> None: assert control_plane.task_lease_service.active_lease_count == 0 +def test_external_mcp_audit_survives_control_plane_restart(tmp_path) -> None: + database_path = tmp_path / "control-plane.db" + store = SQLiteStateStore(database_path) + control_plane = CentralControlPlane(state_store=store) + control_plane.record_tool_call( + tool_name="get_cluster_state", + actor="external_mcp", + result_status="success", + request_id="req-persisted", + ) + store.close() + + restored_store = SQLiteStateStore(database_path) + try: + restored = CentralControlPlane(state_store=restored_store) + runtime = restored.build_report()["toolchain_runtime"] + assert runtime["external_mcp_call_count"] == 1 + assert runtime["external_mcp_success_count"] == 1 + assert runtime["external_mcp_last_success"]["request_id"] == "req-persisted" + finally: + restored_store.close() + + +def test_sqlite_history_retention_keeps_latest_records(tmp_path) -> None: + store = SQLiteStateStore(tmp_path / "retention.db") + store.MAX_EXECUTION_RECORDS = 2 + try: + for index in range(3): + store.append_execution_record({ + "task_id": f"task-{index}", + "node_id": "node-a", + "success": True, + }) + + records = store.load_state()["execution_records"] + assert [record["task_id"] for record in records] == ["task-1", "task-2"] + finally: + store.close() + + def test_control_plane_facade_delegates_migrated_service_boundaries(monkeypatch) -> None: calls: list[str] = [] @@ -65,6 +106,33 @@ def test_node_registry_handles_registration_and_heartbeat_through_facade() -> No assert heartbeat["node_id"] == "node-a" +def test_cloudsim_heartbeat_telemetry_survives_into_node_report() -> None: + control_plane = CentralControlPlane() + control_plane.register_node(Node(node_id="dci-dc1-beijing-vm-0", region="dc1", capacity=ResourceVector(cpu=4, memory=8))) + + control_plane.record_heartbeat( + "dci-dc1-beijing-vm-0", + runtime_telemetry={ + "cpu_utilization": 0.42, + "ram_utilization": 0.31, + "bandwidth_utilization": 0.18, + }, + telemetry_source="cloudsim", + simulation_tick=12.5, + ) + + node = control_plane.build_report()["nodes"][0] + assert node["runtime_utilization"] == { + "cpu": 0.42, + "memory": 0.31, + "gpu": None, + "storage": None, + "bandwidth": 0.18, + } + assert node["telemetry_source"] == "cloudsim" + assert node["simulation_tick"] == 12.5 + + def test_task_lease_service_handles_task_lifecycle_through_facade() -> None: control_plane = CentralControlPlane() control_plane.register_node( diff --git a/tests/test_dashboard_contract.py b/tests/test_dashboard_contract.py index 0c812d3..bb913a4 100644 --- a/tests/test_dashboard_contract.py +++ b/tests/test_dashboard_contract.py @@ -29,7 +29,33 @@ def test_topology_displays_gpu_capacity() -> None: assert "GPU ${escapeHtml(gpu)}" in topology assert "node-gpu" not in topology assert ".node-gpu" not in styles - assert topology.index('${detailRow("内存使用率", `${vm.memory}%`)}') < topology.index("${gpuDetail}") + assert topology.index('${detailRow("内存使用率", vm.memory)}') < topology.index("${gpuDetail}") + assert 'const cpu = utilizationOf(actualNode, "cpu")' in topology + assert "Math.min(92, Math.max(12" not in topology + assert 'recommendedLabel = parsedRecommended?.vmIndex' in topology + + +def test_dashboard_tabs_expose_selected_state_and_controlled_panels() -> None: + index = Path("src/tianjun/interfaces/dashboard/static/index.html").read_text(encoding="utf-8") + router = Path("src/tianjun/interfaces/dashboard/static/js/router.js").read_text(encoding="utf-8") + + assert 'aria-selected="true" aria-controls="page-overview"' in index + assert 'role="tabpanel" aria-labelledby="tab-overview"' in index + assert 'button.setAttribute("aria-selected", String(selected))' in router + assert "handleTabKeydown" in router + + +def test_dashboard_uses_bounded_views_and_non_overlapping_polling() -> None: + api = Path("src/tianjun/interfaces/dashboard/static/js/api.js").read_text(encoding="utf-8") + router = Path("src/tianjun/interfaces/dashboard/static/js/router.js").read_text(encoding="utf-8") + + assert 'fetchReport("summary"' in router + assert 'fetchReport(page' in router + assert 'setTimeout(() => void refreshDashboard()' in router + assert "setInterval" not in router + assert 'new AbortController()' in router + assert 'document.addEventListener("visibilitychange"' in router + assert '`/report/${encodeURIComponent(view)}${suffix}`' in api def test_dashboard_exposes_hierarchical_batch_strategy_and_group_weights() -> None: diff --git a/tests/test_http_routes.py b/tests/test_http_routes.py index 8b4b512..7b89c50 100644 --- a/tests/test_http_routes.py +++ b/tests/test_http_routes.py @@ -9,6 +9,7 @@ from tianjun.application.bootstrap import build_control_plane from tianjun.chat import ChatRuntime +from tianjun.integrations.mcp_server import TianjunHttpClient from tianjun.interfaces.http.server import build_http_server from tianjun.llm import LLMSettings @@ -77,7 +78,11 @@ def post_raw(base_url: str, path: str, body: bytes, content_type: str, headers: def test_official_health_report_dashboard_routes() -> None: with running_server() as base_url: - assert get_json(base_url, "/health")["status"] == "ok" + health = get_json(base_url, "/health") + assert health["status"] == "ok" + assert "issues" in health + assert "model_dir" not in health["model_runtime"] + assert "api_key_fingerprint" not in health["chat_runtime"]["llm"]["settings"] assert isinstance(get_json(base_url, "/report")["nodes"], list) with urllib.request.urlopen(f"{base_url}/dashboard", timeout=5) as response: body = response.read().decode("utf-8").lower() @@ -85,6 +90,53 @@ def test_official_health_report_dashboard_routes() -> None: assert "" in body +def test_dashboard_report_views_are_bounded_and_security_headers_are_present() -> None: + with running_server() as base_url: + summary = get_json(base_url, "/report/summary") + topology = get_json(base_url, "/report/topology") + tasks = get_json(base_url, "/report/tasks?limit=10") + + assert summary["view"] == "summary" + assert "execution_records" not in summary + assert topology["view"] == "topology" + assert "policy_history" not in topology + assert tasks["view"] == "tasks" + assert tasks["pagination"]["limit"] == 10 + + with urllib.request.urlopen(f"{base_url}/dashboard", timeout=5) as response: + assert response.headers["X-Content-Type-Options"] == "nosniff" + assert response.headers["X-Frame-Options"] == "DENY" + assert "frame-ancestors 'none'" in response.headers["Content-Security-Policy"] + assert "Python/" not in response.headers["Server"] + + +def test_cloudsim_heartbeat_route_preserves_vm_telemetry() -> None: + with running_server() as base_url: + post_json(base_url, "/nodes/register", { + "node_id": "dci-dc1-beijing-vm-0", + "region": "dc1", + "location": "beijing", + "capacity": {"cpu": 4, "memory": 8, "bandwidth": 1000}, + }) + post_json(base_url, "/nodes/heartbeat", { + "node_id": "dci-dc1-beijing-vm-0", + "simulated": True, + "sim_tick": 4.0, + "telemetry": { + "cpu_utilization": 0.6, + "ram_utilization": 0.4, + "bandwidth_utilization": 0.2, + }, + }) + + topology = get_json(base_url, "/report/topology") + node = topology["nodes"][0] + assert node["runtime_utilization"]["cpu"] == 0.6 + assert node["runtime_utilization"]["memory"] == 0.4 + assert node["runtime_utilization"]["bandwidth"] == 0.2 + assert node["telemetry_source"] == "cloudsim" + + def test_official_chat_session_route_starts_session() -> None: with running_server() as base_url: result = post_json(base_url, "/chat/sessions", {"message": "hello"}) @@ -166,6 +218,8 @@ def test_batch_json_csv_routes_and_external_mcp_audit() -> None: assert unconfirmed_status == 403 report = get_json(base_url, "/report") assert report["toolchain_runtime"]["external_mcp_last_success"]["tool_name"] == "import_task_batch" + assert report["toolchain_runtime"]["external_mcp_call_count"] == 1 + assert report["toolchain_runtime"]["external_mcp_success_count"] == 1 csv_body = ( "task_id,task_type,cpu,memory,gpu,storage,estimated_duration,priority,allow_region_shift\n" @@ -174,3 +228,28 @@ def test_batch_json_csv_routes_and_external_mcp_audit() -> None: csv_status, csv_error = post_raw(base_url, "/task-batches/import?name=CSV", csv_body, "text/csv; charset=utf-8") assert csv_status == 422 assert csv_error["validation"]["errors"][0]["field"] == "allow_region_shift" + + +def test_external_mcp_audit_records_all_tagged_successes_and_errors() -> None: + with running_server() as base_url: + client = TianjunHttpClient(base_url) + + client.get("/report", tool_name="get_cluster_state") + try: + client.get("/missing", tool_name="missing_test_tool") + except RuntimeError: + pass + else: + raise AssertionError("expected missing MCP endpoint to fail") + + report = get_json(base_url, "/report") + runtime = report["toolchain_runtime"] + assert runtime["external_mcp_call_count"] == 2 + assert runtime["external_mcp_success_count"] == 1 + assert runtime["external_mcp_last_success"]["tool_name"] == "get_cluster_state" + assert runtime["external_mcp_last_call"]["tool_name"] == "missing_test_tool" + assert runtime["external_mcp_last_call"]["result_status"] == "error" + assert [item["tool_name"] for item in runtime["recent_calls"]] == [ + "get_cluster_state", + "missing_test_tool", + ] From bb87b02db43466f71f9e7bb09a532dff1c9c7ca1 Mon Sep 17 00:00:00 2001 From: Yu <1305203710@qq.com> Date: Sun, 26 Jul 2026 09:35:53 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20CI=20=E5=AE=9E?= =?UTF-8?q?=E9=AA=8C=E4=BE=9D=E8=B5=96=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1702a12..f1019e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ jobs: verify: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: python-version: ["3.11", "3.12"] steps: @@ -20,7 +21,7 @@ jobs: with: node-version: "22" - name: Install test dependencies - run: python -m pip install -e ".[dev]" + run: python -m pip install -e ".[dev,experiments]" - name: Compile Python run: python -m compileall -q src tests - name: Check Dashboard JavaScript syntax From 277e610be46851eb17684aa8e5101f2b5c7824ce Mon Sep 17 00:00:00 2001 From: Yu <1305203710@qq.com> Date: Sun, 26 Jul 2026 16:39:49 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E5=AE=8C=E5=96=84=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=E6=80=A7=E4=B8=8E=E7=AB=AF=E5=88=B0=E7=AB=AF?= =?UTF-8?q?=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 41 ++- .gitignore | 5 + README.md | 21 +- configs/tianjun.example.toml | 4 +- docs/api.md | 9 +- docs/architecture.md | 8 +- docs/dashboard-test-checklist.md | 6 +- .../examples/tianjun/TianjunHttpBridge.java | 24 ++ package-lock.json | 76 +++++ package.json | 12 + playwright.config.js | 33 ++ pyproject.toml | 9 + scripts/browser_test_server.py | 36 ++ scripts/smoke_test.py | 5 + scripts/wheel_install_smoke_test.py | 103 ++++++ src/tianjun/application/batch_input.py | 114 +++++++ .../application/batch_scheduling_service.py | 319 +++++++++--------- src/tianjun/application/bootstrap.py | 2 + src/tianjun/application/control_plane.py | 183 +++++----- .../application/control_plane_state.py | 132 ++++++++ .../application/dashboard_reporting.py | 246 +++++++++++++- src/tianjun/application/lifecycle.py | 83 +++++ src/tianjun/application/node_registry.py | 5 + src/tianjun/application/task_lease_service.py | 77 ++++- src/tianjun/chat/__init__.py | 3 +- src/tianjun/chat/constants.py | 28 ++ src/tianjun/chat/models.py | 58 ++++ src/tianjun/chat/runtime.py | 123 +------ src/tianjun/cli/commands/serve.py | 19 +- src/tianjun/config/schema.py | 4 +- src/tianjun/domain/batch.py | 74 ++++ src/tianjun/domain/decision.py | 16 + .../interfaces/dashboard/static/css/nav.css | 4 - .../interfaces/dashboard/static/js/api.js | 25 +- .../dashboard/static/js/pages/topology.js | 21 +- .../interfaces/dashboard/static/js/request.js | 52 +++ .../dashboard/static/js/topology-data.js | 29 ++ .../dashboard/static/js/topology-geometry.js | 94 ++++++ .../dashboard/static/js/topology-resource.js | 154 +++++++++ .../dashboard/static/js/topology.js | 252 +------------- src/tianjun/interfaces/http/server.py | 78 ++++- src/tianjun/policy/constants.py | 42 +++ src/tianjun/policy/generator.py | 99 +----- src/tianjun/scheduling/engine.py | 15 +- src/tianjun/storage/sqlite_schema.py | 234 +++++++++++++ src/tianjun/storage/sqlite_state_store.py | 254 +++++++++----- tests/browser/dashboard.spec.js | 117 +++++++ .../TianjunBridgeIntegrationProbe.java | 31 ++ tests/frontend/request.test.js | 31 ++ tests/frontend/topology-data.test.js | 44 +++ tests/test_batch_carbon_scheduling.py | 41 ++- tests/test_cloudsim_java_integration.py | 71 ++++ tests/test_control_plane_services.py | 38 +++ tests/test_lease_protocol.py | 186 ++++++++++ tests/test_lifecycle_sweeper.py | 75 ++++ tests/test_mcp_stdio_e2e.py | 79 +++++ tests/test_state_store_v2.py | 227 +++++++++++++ 57 files changed, 3337 insertions(+), 834 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.js create mode 100644 scripts/browser_test_server.py create mode 100644 scripts/wheel_install_smoke_test.py create mode 100644 src/tianjun/application/batch_input.py create mode 100644 src/tianjun/application/control_plane_state.py create mode 100644 src/tianjun/application/lifecycle.py create mode 100644 src/tianjun/chat/constants.py create mode 100644 src/tianjun/chat/models.py create mode 100644 src/tianjun/interfaces/dashboard/static/js/request.js create mode 100644 src/tianjun/interfaces/dashboard/static/js/topology-data.js create mode 100644 src/tianjun/interfaces/dashboard/static/js/topology-geometry.js create mode 100644 src/tianjun/interfaces/dashboard/static/js/topology-resource.js create mode 100644 src/tianjun/policy/constants.py create mode 100644 src/tianjun/storage/sqlite_schema.py create mode 100644 tests/browser/dashboard.spec.js create mode 100644 tests/cloudsim/TianjunBridgeIntegrationProbe.java create mode 100644 tests/frontend/request.test.js create mode 100644 tests/frontend/topology-data.test.js create mode 100644 tests/test_cloudsim_java_integration.py create mode 100644 tests/test_lease_protocol.py create mode 100644 tests/test_lifecycle_sweeper.py create mode 100644 tests/test_mcp_stdio_e2e.py create mode 100644 tests/test_state_store_v2.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1019e6..575ec2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,15 +20,54 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "22" + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + - name: Install Node dependencies + run: npm ci - name: Install test dependencies - run: python -m pip install -e ".[dev,experiments]" + run: python -m pip install -e ".[dev,experiments,mcp]" + - name: Build and smoke-test installed wheel + run: | + python -m pip wheel . --no-deps --wheel-dir wheelhouse + python scripts/wheel_install_smoke_test.py wheelhouse/*.whl - name: Compile Python run: python -m compileall -q src tests - name: Check Dashboard JavaScript syntax run: find src/tianjun/interfaces/dashboard/static/js -name '*.js' -print0 | xargs -0 -n1 node --check + - name: Run executable frontend tests + run: npm run test:frontend - name: Run tests run: python -m pytest - name: Run convergence checks run: python scripts/convergence_check.py - name: Run offline smoke test run: python scripts/smoke_test.py --port 8136 + + browser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + - name: Install Tianjun and browser test dependencies + run: | + python -m pip install -e . + npm ci + - name: Install Chromium + run: npx playwright install --with-deps chromium + - name: Run browser tests + run: npm run test:browser + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index 3e06908..1b0a032 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,11 @@ __pycache__/ .mypy_cache/ .ruff_cache/ *.egg-info/ +build/ +wheelhouse/ +node_modules/ +playwright-report/ +test-results/ .env configs/secrets.toml diff --git a/README.md b/README.md index 9886555..efdcd76 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,13 @@ CloudSimPlus 示例节点会随注册请求上报 CPU、内存、GPU、存储、 任务下发后的状态流转如下: 1. `/tasks` 或策略提交只把任务写入控制平面并加入 `pending_queue`,此时任务会显示为“待调度”,还不会出现在执行记录。 -2. CloudSimPlus 桥接器或真实节点代理持续心跳并请求 `/leases/next`,控制平面才会做调度决策并把任务租约发给目标节点。 -3. 节点执行过程中回传 `/task-runs/progress`,Dashboard 的拓扑路径会根据 `/report` 中的 `active_runs`、最新进度、调度决策和节点 inventory 实时切换。 -4. 节点最终回传 `/task-runs/result` 后,控制平面才写入执行记录;因此“执行记录没有这个任务”通常表示任务还在待调度队列或已发租约但尚未回传结果。 +2. CloudSimPlus 桥接器或真实节点代理持续心跳并请求 `/leases/next`,取得带唯一 `lease_id` 和 TTL 的任务租约,再通过 `/leases/ack` 确认领取。 +3. 节点执行过程中回传 `/task-runs/progress`,进度会续期租约;超时且没有续期的租约会释放资源并按重试策略重新排队。 +4. 节点最终通过 `/task-runs/result` 携带 `lease_id` 与稳定的 `result_id` 回传结果;相同结果重试会返回同一收据,不会重复写执行记录。 + +控制平面默认把 SQLite v2 状态库写入平台用户状态目录(`${TIANJUN_STATE_DIR}/tianjun-state.sqlite`)。节点最后在线时间、任务、批次、预演计划、预留账本、幂等映射与结果收据均可跨进程重启恢复;可用 `--state-db` 覆盖路径。数据库升级前会在原目录创建 `*.pre-v2-from-v*.bak` 完整备份;高于程序支持版本的数据库会拒绝打开,迁移失败会整体回滚。`/ready` 会执行完整性检查和可逆写入探测。Dashboard 中“实时遥测”“CloudSim 模拟”“配置曲线”和“分配估算”是不同来源,不应互相替代解读。 + +HTTP 服务内置独立生命周期清理器,不依赖 Dashboard 轮询即可回收过期节点和租约。默认每秒检查一次,可通过 `server.lifecycle_sweep_interval_seconds` 调整;服务关闭时会等待清理线程安全退出。 ### 5. 打开 Dashboard @@ -114,6 +118,8 @@ python -B main.py mcp-server ` MCP server 会把 Tianjun HTTP API 包装为工具,包括读取集群状态、开始/继续聊天会话、起草/比较/仿真/解释策略,以及带确认边界的策略提交和任务调度。 +顶部的 MCP 状态只统计真正带 MCP 工具标识的 HTTP 请求,启动 MCP 进程本身不算成功调用。测试套件会启动真实 stdio MCP 子进程并调用 `get_cluster_state`,验证 Dashboard 的最近调用和成功计数同步更新。 + ### 7. 可选:真实节点代理 如需使用真实节点遥测代理,而不是模拟节点: @@ -145,6 +151,15 @@ python scripts\smoke_test.py --port 8135 该脚本会启动离线控制平面,检查 `/health`、`/report`、`/dashboard`,并验证 MCP 工具契约可导入。它用于快速验证,不代表完整启动。 +前端逻辑和真实浏览器回归分别运行: + +```powershell +npm run test:frontend +npm run test:browser +``` + +浏览器回归使用 Chromium 覆盖 1366 和 1920 两档 PC 视口,包括标签页键盘语义、轮询竞态、拓扑图层、空态/错误态和拓扑几何边界。 + ## LLM 配置 diff --git a/configs/tianjun.example.toml b/configs/tianjun.example.toml index b6fca05..f9ca62b 100644 --- a/configs/tianjun.example.toml +++ b/configs/tianjun.example.toml @@ -6,8 +6,10 @@ host = "127.0.0.1" port = 8024 heartbeat_timeout_seconds = 15 +lease_timeout_seconds = 60 +lifecycle_sweep_interval_seconds = 1 policy_update_interval = 2 -state_db = "${TIANJUN_CONFIG_DIR}/tianjun-state.sqlite" +state_db = "${TIANJUN_STATE_DIR}/tianjun-state.sqlite" [model] dir = "${TIANJUN_HOME}/data/trained_models" diff --git a/docs/api.md b/docs/api.md index eec0b6a..ec9f9a3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -41,9 +41,10 @@ | POST | `/nodes/heartbeat` | 更新节点心跳和遥测数据 | | POST | `/tasks` | 提交任务 | | POST | `/tasks/{task_id}/schedule` | 调度待处理任务;需要明确确认 | -| POST | `/leases/next` | 节点代理租约轮询 | -| POST | `/task-runs/progress` | 报告任务进度 | -| POST | `/task-runs/result` | 报告最终任务结果 | +| POST | `/leases/next` | 节点代理租约轮询;返回 `lease_id`、签发时间与到期时间 | +| POST | `/leases/ack` | 使用 `node_id`、`task_id`、`lease_id` 确认领取并续期 | +| POST | `/task-runs/progress` | 报告任务进度并续期租约;新客户端应携带 `lease_id` | +| POST | `/task-runs/result` | 幂等报告最终结果;新客户端应携带 `lease_id` 和稳定 `result_id` | | POST | `/task-runs/cancel` | 取消活动任务运行 | | POST | `/schedule/preview` | CloudSimPlus 兼容的调度预览 | | POST | `/schedule/commit` | CloudSimPlus 兼容的直接提交 | @@ -78,3 +79,5 @@ HTTP 路由调用 `CentralControlPlane` facade。facade 将已迁移的行为转 Dashboard 只轮询按页面拆分的报告。所有报告视图都带有 `report_version`、`resource_snapshot_version` 和 `generated_at`,客户端可据此识别跨请求快照差异。完整 `/report` 不再用于浏览器高频轮询。 CloudSimPlus 心跳中的 `telemetry.cpu_utilization`、`ram_utilization` 和 `bandwidth_utilization` 会规范化为节点的 `runtime_utilization`。未上报的指标保持 `null`,Dashboard 显示为 `--`,不会生成伪实时值。 + +租约默认 TTL 为 60 秒,可通过 `server.lease_timeout_seconds` 配置。相同 `result_id` 的重试返回已保存的结果收据并标记 `idempotent_replay=true`,不会重复累计执行、能耗或碳数据。 diff --git a/docs/architecture.md b/docs/architecture.md index 41bd152..a60e007 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,12 +28,13 @@ Tianjun Engine 围绕公共适配器、中央控制平面门面和可测试的 | 服务 | 当前职责 | | --- | --- | | `NodeRegistry` | 节点注册、心跳、节点遥测变更、节点持久化 | -| `TaskLeaseService` | 任务提交、预览、pending 调度、agent 租约轮询、租约激活 | +| `TaskLeaseService` | 任务提交、预览、pending 调度、租约发放、ACK、TTL 续期/回收和并发幂等 | +| `LifecycleSweeper` | 与用户流量解耦地回收过期节点和租约,并随 HTTP 服务安全启停 | | `RequirementDialogueService` | 需求解析、需求会话开始/继续/读取、地域可用性载荷 | | `PolicyWorkflowService` | 策略起草、候选比较、模拟、提交、反馈解析、反馈记录、反馈优化 | | `src/tianjun/cli/commands/` | 所有 CLI 命令处理器 | -`CentralControlPlane` 保留 facade 方法、共享状态、报表组装、恢复/持久化协调、拓扑注册、策略权重更新以及执行进度/结果回报等跨领域逻辑。已经迁移到服务中的业务流程不应复制回门面类。 +`CentralControlPlane` 保留 facade 方法、共享状态、拓扑注册、策略权重更新以及执行进度/结果回报等跨领域逻辑。数据库模式/迁移、控制面恢复、批输入校验、聊天模型/常量与拓扑几何已拆为独立模块;已经迁移到服务中的业务流程不应复制回门面类。 ## CloudSimPlus 仿真链路 @@ -43,7 +44,8 @@ Tianjun Engine 围绕公共适配器、中央控制平面门面和可测试的 - 调用 `/nodes/register` 注册 CloudSimPlus 仿真 VM 节点。 - 持续调用 `/nodes/heartbeat` 上报在线状态。 - 通过 `/schedule/commit` 请求 Tianjun 控制平面做调度决策。 -- 在 CloudSimPlus 仿真完成后通过 `/task-runs/result` 回报执行结果。 +- 通过 `/leases/next` 和 `/leases/ack` 领取并确认带 TTL 的任务租约。 +- 在 CloudSimPlus 仿真完成后通过携带 `lease_id`/`result_id` 的 `/task-runs/result` 幂等回报执行结果。 完整启动命令见 [README.md](../README.md)。 diff --git a/docs/dashboard-test-checklist.md b/docs/dashboard-test-checklist.md index 156e3b8..7d57a3b 100644 --- a/docs/dashboard-test-checklist.md +++ b/docs/dashboard-test-checklist.md @@ -1,6 +1,6 @@ # Dashboard 测试清单 -Dashboard 是静态 HTML/CSS/JS,没有构建步骤。在演示前,运行冒烟测试,然后手动验证以下内容: +Dashboard 是静态 HTML/CSS/JS,没有生产构建步骤。`npm run test:frontend` 执行纯逻辑测试,`npm run test:browser` 启动真实 Python 控制面和 Chromium,覆盖 1366 与 1920 两档 PC 视口。以下项目均应由自动化覆盖,并可在演示前抽查: - `/dashboard` 加载时浏览器控制台没有错误。 - 顶部导航显示来自 `/health` 的系统状态。 @@ -17,3 +17,7 @@ Dashboard 是静态 HTML/CSS/JS,没有构建步骤。在演示前,运行冒 - 页面隐藏时自动刷新暂停,返回前台后恢复且不会出现重叠请求。 - CloudSimPlus VM 心跳遥测在拓扑节点详情中显示;未上报指标显示 `--`。 - 总览、调度、拓扑、任务和模型页面分别读取对应的精简报告视图。 +- 网络、资源负载、碳强度按钮同步 `aria-pressed`,并切换对应语义摘要。 +- 空拓扑、健康检查失败和请求超时均呈现明确状态。 +- 页面主体和拓扑画布在支持的 PC 视口内没有横向溢出。 +- 拓扑 SVG、节点和链路标签保持在拓扑内容边界内。 diff --git a/examples/cloudsimplus/src/main/java/org/cloudsimplus/examples/tianjun/TianjunHttpBridge.java b/examples/cloudsimplus/src/main/java/org/cloudsimplus/examples/tianjun/TianjunHttpBridge.java index f1258a8..7c74469 100644 --- a/examples/cloudsimplus/src/main/java/org/cloudsimplus/examples/tianjun/TianjunHttpBridge.java +++ b/examples/cloudsimplus/src/main/java/org/cloudsimplus/examples/tianjun/TianjunHttpBridge.java @@ -21,6 +21,7 @@ public class TianjunHttpBridge { private static final Pattern NODE_ID_PATTERN = Pattern.compile("\"node_id\"\\s*:\\s*\"([^\"]+)\""); private static final Pattern TASK_ID_PATTERN = Pattern.compile("\"task_id\"\\s*:\\s*\"([^\"]+)\""); + private static final Pattern LEASE_ID_PATTERN = Pattern.compile("\"lease_id\"\\s*:\\s*\"([^\"]+)\""); private static final Pattern STATUS_PATTERN = Pattern.compile("\"status\"\\s*:\\s*\"([^\"]+)\""); private static final Pattern SCORE_PATTERN = Pattern.compile("\"total_score\"\\s*:\\s*([0-9.]+)"); private static final Pattern LEASE_TASK_PATTERN = Pattern.compile("\"lease\"\\s*:\\s*\\{\\s*\"task_id\"\\s*:\\s*\"([^\"]+)\""); @@ -33,6 +34,7 @@ public class TianjunHttpBridge { private final HttpClient client; private final String server; private final Map lastHeartbeatTickByNode = new ConcurrentHashMap<>(); + private final Map leaseIdByTask = new ConcurrentHashMap<>(); public TianjunHttpBridge(final String server) { this.server = stripTrailingSlash(server); @@ -216,6 +218,13 @@ public LeaseResult requestLease(final String nodeId) { if (taskId.isBlank()) { return null; } + final String leaseId = matchString(LEASE_ID_PATTERN, response, ""); + if (!leaseId.isBlank()) { + post("/leases/ack", """ + {"node_id": "%s", "task_id": "%s", "lease_id": "%s"} + """.formatted(escapeJson(nodeId), escapeJson(taskId), escapeJson(leaseId))); + leaseIdByTask.put(taskId, leaseId); + } return new LeaseResult( taskId, matchString(NODE_ID_PATTERN, response, nodeId), @@ -231,6 +240,7 @@ public void reportProgress(final SimTaskProgress progress) { public void reportResult(final SimTaskResult result) { post("/task-runs/result", resultJson(result)); + leaseIdByTask.remove(result.taskId()); } private String get(final String path) throws IOException, InterruptedException { @@ -457,10 +467,17 @@ private String taskJson(final SimTask task) { } private String resultJson(final SimTaskResult result) { + final String leaseId = leaseIdByTask.getOrDefault(result.taskId(), ""); + final String leaseFields = leaseId.isBlank() + ? "" + : "\"lease_id\": \"%s\",\n \"result_id\": \"cloudsim-%s-%s\",".formatted( + escapeJson(leaseId), escapeJson(result.taskId()), escapeJson(leaseId) + ); return """ { "node_id": "%s", "task_id": "%s", + %s "success": %s, "duration_seconds": %.4f, "stdout": "%s", @@ -484,6 +501,7 @@ private String resultJson(final SimTaskResult result) { """.formatted( result.nodeId(), result.taskId(), + leaseFields, result.success() ? "true" : "false", result.durationSeconds(), escapeJson(result.stdout()), @@ -532,10 +550,15 @@ private String batchJson(final String clientBatchId, final String batchName, fin } private String progressJson(final SimTaskProgress progress) { + final String leaseId = leaseIdByTask.getOrDefault(progress.taskId(), ""); + final String leaseField = leaseId.isBlank() + ? "" + : "\"lease_id\": \"%s\",".formatted(escapeJson(leaseId)); return """ { "node_id": "%s", "task_id": "%s", + %s "stage": "%s", "status": "%s", "progress": %.4f, @@ -553,6 +576,7 @@ private String progressJson(final SimTaskProgress progress) { """.formatted( progress.nodeId(), progress.taskId(), + leaseField, escapeJson(progress.stage()), escapeJson(progress.status()), clamp(progress.progress(), 0.0, 1.0), diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5e9efb8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,76 @@ +{ + "name": "tianjun-dashboard-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tianjun-dashboard-tests", + "devDependencies": { + "@playwright/test": "^1.62.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz", + "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..18c1adf --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "tianjun-dashboard-tests", + "private": true, + "type": "module", + "scripts": { + "test:frontend": "node --test tests/frontend/*.test.js", + "test:browser": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.62.0" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..13f61bf --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,33 @@ +import { defineConfig } from "@playwright/test"; + +const baseURL = process.env.PLAYWRIGHT_TEST_BASE_URL ?? "http://127.0.0.1:8137"; + +export default defineConfig({ + testDir: "tests/browser", + fullyParallel: false, + workers: process.env.CI ? 1 : 2, + timeout: 30_000, + expect: { timeout: 8_000 }, + reporter: process.env.CI + ? [["github"], ["html", { open: "never" }]] + : [["line"]], + use: { + baseURL, + browserName: "chromium", + headless: true, + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + webServer: process.env.PLAYWRIGHT_TEST_BASE_URL + ? undefined + : { + command: "python scripts/browser_test_server.py", + url: `${baseURL}/health`, + timeout: 30_000, + reuseExistingServer: !process.env.CI, + }, + projects: [ + { name: "desktop-1366", use: { viewport: { width: 1366, height: 768 } } }, + { name: "desktop-1920", use: { viewport: { width: 1920, height: 1080 } } }, + ], +}); diff --git a/pyproject.toml b/pyproject.toml index e71d1dd..64b1dd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,15 @@ package-dir = {"" = "src"} [tool.setuptools.packages.find] where = ["src"] +[tool.setuptools.package-data] +"tianjun.interfaces.dashboard" = [ + "static/*.html", + "static/css/*.css", + "static/css/pages/*.css", + "static/js/*.js", + "static/js/pages/*.js", +] + [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] diff --git a/scripts/browser_test_server.py b/scripts/browser_test_server.py new file mode 100644 index 0000000..c07850d --- /dev/null +++ b/scripts/browser_test_server.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import os + +from tianjun.application.bootstrap import build_control_plane +from tianjun.chat import ChatRuntime +from tianjun.interfaces.http.server import build_http_server +from tianjun.llm import LLMSettings +from tianjun.scenarios import scenario_nodes, scenario_tasks + + +def main() -> None: + control = build_control_plane(heartbeat_timeout_seconds=3600.0) + for node in scenario_nodes(): + control.register_node(node) + for task in scenario_tasks(): + control.submit_task(task) + chat = ChatRuntime.with_llm_settings(control, LLMSettings(offline=True)) + port = int(os.environ.get("PLAYWRIGHT_TEST_PORT", "8137")) + server = build_http_server( + control, + "127.0.0.1", + port, + chat_runtime=chat, + lifecycle_sweep_interval_seconds=5.0, + ) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/scripts/smoke_test.py b/scripts/smoke_test.py index 3c9a317..0629393 100644 --- a/scripts/smoke_test.py +++ b/scripts/smoke_test.py @@ -4,6 +4,7 @@ import json import subprocess import sys +import tempfile import time import urllib.error import urllib.request @@ -46,6 +47,7 @@ def main() -> int: args = parser.parse_args() base_url = f"http://{args.host}:{args.port}" + state_directory = tempfile.TemporaryDirectory(prefix="tianjun-smoke-state-") command = [ sys.executable, "-B", @@ -54,6 +56,8 @@ def main() -> int: "--config", "configs/tianjun.example.toml", "--offline", + "--state-db", + str(Path(state_directory.name) / "smoke.sqlite"), "--host", args.host, "--port", @@ -88,6 +92,7 @@ def main() -> int: except subprocess.TimeoutExpired: process.kill() process.wait(timeout=5) + state_directory.cleanup() if __name__ == "__main__": diff --git a/scripts/wheel_install_smoke_test.py b/scripts/wheel_install_smoke_test.py new file mode 100644 index 0000000..abd1c19 --- /dev/null +++ b/scripts/wheel_install_smoke_test.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import tempfile +import venv +import zipfile +from pathlib import Path + + +REQUIRED_ASSETS = { + "tianjun/interfaces/dashboard/static/index.html", + "tianjun/interfaces/dashboard/static/css/base.css", + "tianjun/interfaces/dashboard/static/js/router.js", + "tianjun/interfaces/dashboard/static/js/request.js", + "tianjun/interfaces/dashboard/static/js/topology-data.js", + "tianjun/interfaces/dashboard/static/js/topology-resource.js", +} + + +INSTALLED_SMOKE_PROGRAM = r""" +import json +import threading +import urllib.request + +from tianjun.application.bootstrap import build_control_plane +from tianjun.interfaces.http.server import build_http_server + +control_plane = build_control_plane() +server = build_http_server(control_plane, "127.0.0.1", 0) +thread = threading.Thread(target=server.serve_forever, daemon=True) +thread.start() +base_url = f"http://127.0.0.1:{server.server_address[1]}" +try: + with urllib.request.urlopen(base_url + "/dashboard", timeout=5) as response: + dashboard = response.read().decode("utf-8") + with urllib.request.urlopen(base_url + "/css/base.css", timeout=5) as response: + stylesheet = response.read().decode("utf-8") + with urllib.request.urlopen(base_url + "/css/tokens.css", timeout=5) as response: + tokens = response.read().decode("utf-8") + with urllib.request.urlopen(base_url + "/js/router.js", timeout=5) as response: + javascript = response.read().decode("utf-8") + assert "" in dashboard.lower() + assert "box-sizing" in stylesheet + assert ":root" in tokens + assert "navigate" in javascript.lower() + print(json.dumps({"status": "ok", "dashboard_bytes": len(dashboard)})) +finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) +""" + + +def _venv_python(environment: Path) -> Path: + if sys.platform == "win32": + return environment / "Scripts" / "python.exe" + return environment / "bin" / "python" + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Verify dashboard assets in a wheel and start the installed HTTP server." + ) + parser.add_argument("wheel", type=Path) + args = parser.parse_args() + wheel = args.wheel.resolve() + if not wheel.is_file(): + raise FileNotFoundError(wheel) + + with zipfile.ZipFile(wheel) as archive: + names = set(archive.namelist()) + missing = sorted(REQUIRED_ASSETS - names) + if missing: + raise RuntimeError(f"wheel is missing dashboard assets: {missing}") + + with tempfile.TemporaryDirectory(prefix="tianjun-wheel-smoke-") as temp_dir: + environment = Path(temp_dir) / "venv" + venv.EnvBuilder(with_pip=True).create(environment) + python = _venv_python(environment) + subprocess.run( + [str(python), "-m", "pip", "install", "--no-deps", str(wheel)], + check=True, + ) + completed = subprocess.run( + [str(python), "-c", INSTALLED_SMOKE_PROGRAM], + capture_output=True, + text=True, + ) + if completed.returncode: + raise RuntimeError( + "installed wheel smoke test failed\n" + f"stdout:\n{completed.stdout}\n" + f"stderr:\n{completed.stderr}" + ) + print(completed.stdout.strip()) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/tianjun/application/batch_input.py b/src/tianjun/application/batch_input.py new file mode 100644 index 0000000..ac2dafe --- /dev/null +++ b/src/tianjun/application/batch_input.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from typing import Any + +from ..domain import BatchValidationIssue, BatchValidationReport, Task + + +class BatchRequestError(ValueError): + def __init__(self, status_code: int, payload: dict[str, Any]) -> None: + super().__init__(str(payload.get("error") or payload)) + self.status_code = status_code + self.payload = payload + + +def validated_objectives(value: Any, allowed: tuple[str, ...], field: str) -> tuple[str, ...] | None: + if value is None: + return None + if not isinstance(value, (list, tuple)): + raise BatchRequestError(422, {"error": f"{field} must be an array"}) + unknown = [str(item) for item in value if str(item) not in allowed] + if unknown: + raise BatchRequestError(422, {"error": f"unknown {field}: {', '.join(unknown)}"}) + unique = tuple(dict.fromkeys(str(item) for item in value)) + if not unique: + raise BatchRequestError(422, {"error": f"{field} cannot be empty"}) + return unique + + +def validated_weights(value: Any, allowed: tuple[str, ...], field: str) -> dict[str, float] | None: + if value is None: + return None + if not isinstance(value, dict): + raise BatchRequestError(422, {"error": f"{field} must be an object"}) + unknown = [str(key) for key in value if str(key) not in allowed] + if unknown: + raise BatchRequestError(422, {"error": f"unknown {field}: {', '.join(unknown)}"}) + weights = {str(key): float(weight) for key, weight in value.items()} + if any(weight < 0.0 for weight in weights.values()) or sum(weights.values()) <= 0.0: + raise BatchRequestError(422, {"error": f"{field} values must be non-negative with a positive sum"}) + return weights + + +def rejection_reason(task: Task, nodes: Any) -> str: + nodes_list = list(nodes) + if task.demand.gpu > 0 and all(node.available().gpu + 1e-9 < task.demand.gpu for node in nodes_list): + return "INSUFFICIENT_GPU" + if task.allowed_regions and all( + not any(node.matches_deployment_region(region) for region in task.allowed_regions) + for node in nodes_list + ): + return "REGION_FORBIDDEN" + if task.carbon_budget_g is not None: + return "CARBON_BUDGET_EXCEEDED" + if task.deadline is not None: + return "DEADLINE_INFEASIBLE" + return "NO_FEASIBLE_NODE" + + +def csv_row(row: dict[str, str], row_number: int) -> dict[str, Any]: + def validation_error(field: str, code: str, message: str) -> BatchRequestError: + report = BatchValidationReport( + 0, + errors=[BatchValidationIssue(row_number, field, code, message)], + ) + return BatchRequestError(422, {"error": "batch validation failed", "validation": report.to_dict()}) + + def number(name: str, default: float = 0.0) -> float: + text = str(row.get(name, "")).strip() + if text == "": + return default + try: + return float(text) + except ValueError as exc: + raise validation_error(name, "INVALID_NUMBER", f"{name} must be numeric") from exc + + def boolean(name: str, default: bool) -> bool: + text = str(row.get(name, "")).strip().lower() + if text == "": + return default + if text not in {"true", "false"}: + raise validation_error(name, "INVALID_BOOLEAN", f"{name} must be true or false") + return text == "true" + + payload: dict[str, Any] = { + "task_id": str(row.get("task_id", "")).strip(), + "task_type": str(row.get("task_type", "batch")).strip() or "batch", + "demand": {key: number(key) for key in ("cpu", "memory", "gpu", "storage")}, + "estimated_duration": int(number("estimated_duration", 0)), + "priority": int(number("priority", 5)), + "security_level": str(row.get("security_level", "medium")).strip() or "medium", + "isolation_level": str(row.get("isolation_level", "process")).strip() or "process", + "allowed_regions": [item for item in str(row.get("allowed_regions", "")).split("|") if item], + "forbidden_nodes": [item for item in str(row.get("forbidden_nodes", "")).split("|") if item], + "require_encrypted_transport": boolean("require_encrypted_transport", True), + "allow_region_shift": boolean("allow_region_shift", True), + "allow_time_shift": boolean("allow_time_shift", False), + "carbon_priority": number("carbon_priority", 0.0), + } + region = str(row.get("region", "")).strip() + if region and not payload["allowed_regions"]: + payload["allowed_regions"] = [region] + optional_numbers = ( + "budget", "deadline", "input_size_gb", "max_latency_ms", + "min_bandwidth_mbps", "carbon_budget_g", "deferrable_until_tick", + ) + for key in optional_numbers: + text = str(row.get(key, "")).strip() + if text: + payload[key] = float(text) if key not in {"deadline", "deferrable_until_tick"} else int(float(text)) + for key in ("data_region", "source_region"): + text = str(row.get(key, "")).strip() + if text: + payload[key] = text + return payload diff --git a/src/tianjun/application/batch_scheduling_service.py b/src/tianjun/application/batch_scheduling_service.py index ce47886..60ec089 100644 --- a/src/tianjun/application/batch_scheduling_service.py +++ b/src/tianjun/application/batch_scheduling_service.py @@ -6,6 +6,7 @@ import io import json import time +from contextlib import nullcontext from dataclasses import dataclass from statistics import mean from typing import TYPE_CHECKING, Any @@ -30,6 +31,13 @@ ) from ..scenarios import task_from_dict from ..experiments import AssignmentCandidate, milp_oracle, nsga2_assignments +from .batch_input import ( + BatchRequestError, + csv_row, + rejection_reason, + validated_objectives, + validated_weights, +) if TYPE_CHECKING: from .control_plane import CentralControlPlane @@ -74,13 +82,6 @@ def _percentile(values: list[float], percentile: float) -> float: return ordered[lower] + fraction * (ordered[upper] - ordered[lower]) -class BatchRequestError(ValueError): - def __init__(self, status_code: int, payload: dict[str, Any]) -> None: - super().__init__(str(payload.get("error") or payload)) - self.status_code = status_code - self.payload = payload - - @dataclass(slots=True) class BatchSchedulingService: control_plane: CentralControlPlane @@ -113,7 +114,7 @@ def import_csv(self, text: str, *, batch_name: str = "CSV批次") -> dict[str, A csv_issues: list[BatchValidationIssue] = [] for index, row in enumerate(rows, start=2): try: - raw_tasks.append(self._csv_row(row, index)) + raw_tasks.append(csv_row(row, index)) except BatchRequestError as exc: for item in exc.payload.get("validation", {}).get("errors", []): csv_issues.append(BatchValidationIssue( @@ -226,16 +227,23 @@ def _import( ) control.task_batches[batch_id] = batch control.batch_idempotency[client_id] = batch_id + if control.state_store is not None: + control.state_store.save_task_batch(batch.to_dict()) return {**batch.to_dict(include_tasks=False), "validation": BatchValidationReport(len(tasks)).to_dict()} def get_batch(self, batch_id: str) -> dict[str, Any]: - batch = self._batch(batch_id) - payload = batch.to_dict() - if batch.latest_plan_id and batch.latest_plan_id in self.control_plane.batch_plans: - payload["latest_plan"] = self.control_plane.batch_plans[batch.latest_plan_id].to_dict() - return payload + with self.control_plane.lock: + batch = self._batch(batch_id) + payload = batch.to_dict() + if batch.latest_plan_id and batch.latest_plan_id in self.control_plane.batch_plans: + payload["latest_plan"] = self.control_plane.batch_plans[batch.latest_plan_id].to_dict() + return payload def actual_metrics(self, batch_id: str) -> dict[str, Any]: + with self.control_plane.lock: + return self._actual_metrics_locked(batch_id) + + def _actual_metrics_locked(self, batch_id: str) -> dict[str, Any]: """Return measured execution outcomes, distinct from preview predictions.""" batch = self._batch(batch_id) committed_plans = [ @@ -255,6 +263,7 @@ def actual_metrics(self, batch_id: str) -> dict[str, Any]: succeeded = sum(1 for record in records if record.success) failed = len(records) - succeeded unassigned_count = len(plan.unassigned_tasks) if plan else 0 + previous_status = batch.status if assigned_ids and len(records) >= len(assigned_ids): if failed >= len(assigned_ids): batch.status = BatchStatus.FAILED @@ -262,6 +271,8 @@ def actual_metrics(self, batch_id: str) -> dict[str, Any]: batch.status = BatchStatus.PARTIAL_FAILED else: batch.status = BatchStatus.COMPLETED + if batch.status != previous_status and self.control_plane.state_store is not None: + self.control_plane.state_store.save_task_batch(batch.to_dict()) return { "batch_id": batch_id, "status": batch.status.value, @@ -312,23 +323,36 @@ def preview(self, batch_id: str, payload: dict[str, Any] | None = None) -> dict[ control = self.control_plane with control.lock: control._expire_stale_nodes() - batch = self._batch(batch_id) - active_metrics = self._validated_objectives(options.get("active_metrics"), METRIC_KEYS, "active_metrics") - active_groups = self._validated_objectives(options.get("active_groups"), GROUP_KEYS, "active_groups") - group_weight_overrides = self._validated_weights( + batch_snapshot = copy.deepcopy(self._batch(batch_id)) + nodes_snapshot = copy.deepcopy(control.nodes) + snapshot_version = control.resource_snapshot_version + snapshot_tick = control.current_tick() + active_metrics = validated_objectives(options.get("active_metrics"), METRIC_KEYS, "active_metrics") + active_groups = validated_objectives(options.get("active_groups"), GROUP_KEYS, "active_groups") + group_weight_overrides = validated_weights( options.get("group_weights"), GROUP_KEYS, "group_weights" ) + with control.planning_lock: plan = self._build_plan( - batch, + batch_snapshot, strategy=strategy, active_metrics=active_metrics, active_groups=active_groups, group_weight_overrides=group_weight_overrides, + nodes_snapshot=nodes_snapshot, + snapshot_version=snapshot_version, + snapshot_tick=snapshot_tick, ) - plan.strategy = requested_strategy + plan.strategy = requested_strategy + with control.lock: + batch = self._batch(batch_id) control.batch_plans[plan.plan_id] = plan batch.latest_plan_id = plan.plan_id batch.status = BatchStatus.PREVIEWED + if control.state_store is not None: + with control.state_store.transaction(): + control.state_store.save_batch_plan(plan.to_dict()) + control.state_store.save_task_batch(batch.to_dict()) return plan.to_dict() def compare(self, batch_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: @@ -378,32 +402,64 @@ def commit(self, batch_id: str, payload: dict[str, Any]) -> dict[str, Any]: if node is None or not demand.fits_in(node.available()): raise BatchRequestError(409, {"error": "SNAPSHOT_CONFLICT", "node_id": node_id}) - ledger = ReservationLedger(plan_id=plan.plan_id, resource_snapshot_version=plan.resource_snapshot_version) - for node_id, demand in demand_by_node.items(): - ledger.reserve(node_id, demand) - control.reservation_ledgers[plan.plan_id] = ledger - tick = control.current_tick() - for task in batch.tasks: - if task.task_id not in control.tasks: - task.submit_tick = tick - control.tasks[task.task_id] = task - control.pending_queue.append(task.task_id) - control._persist_task(task) - leases = [] - for assignment in plan.assignments: - task = task_by_id[assignment.task_id] - task.status = TaskStatus.RESERVED - lease = control.task_lease_service.activate_task_lease( - task=task, - node=control.nodes[assignment.node_id], - decision=assignment.decision, - tick=tick, - remove_from_pending=True, - ) - leases.append(lease.to_dict()) - control.resource_snapshot_version += 1 - batch.status = BatchStatus.RUNNING if leases else BatchStatus.COMMITTED - plan.status = "committed" + memory_snapshot = { + "nodes": copy.deepcopy(control.nodes), + "tasks": copy.deepcopy(control.tasks), + "pending_queue": list(control.pending_queue), + "leases": copy.deepcopy(control.leases), + "decision_log": copy.deepcopy(control.decision_log), + "reservation_ledgers": copy.deepcopy(control.reservation_ledgers), + "resource_snapshot_version": control.resource_snapshot_version, + "batch_tasks": copy.deepcopy(batch.tasks), + "batch_status": batch.status, + "plan_status": plan.status, + } + transaction = control.state_store.transaction() if control.state_store is not None else nullcontext() + try: + with transaction: + ledger = ReservationLedger(plan_id=plan.plan_id, resource_snapshot_version=plan.resource_snapshot_version) + for node_id, demand in demand_by_node.items(): + ledger.reserve(node_id, demand) + control.reservation_ledgers[plan.plan_id] = ledger + tick = control.current_tick() + for task in batch.tasks: + if task.task_id not in control.tasks: + task.submit_tick = tick + control.tasks[task.task_id] = task + control.pending_queue.append(task.task_id) + control._persist_task(task) + leases = [] + for assignment in plan.assignments: + task = task_by_id[assignment.task_id] + task.status = TaskStatus.RESERVED + lease = control.task_lease_service.activate_task_lease( + task=task, + node=control.nodes[assignment.node_id], + decision=assignment.decision, + tick=tick, + remove_from_pending=True, + ) + leases.append(lease.to_dict()) + control.resource_snapshot_version += 1 + batch.status = BatchStatus.RUNNING if leases else BatchStatus.COMMITTED + plan.status = "committed" + if control.state_store is not None: + control.state_store.save_reservation_ledger(ledger.to_dict()) + control.state_store.save_task_batch(batch.to_dict()) + control.state_store.save_batch_plan(plan.to_dict()) + control.state_store.set_control_value("resource_snapshot_version", control.resource_snapshot_version) + except Exception: + control.nodes = memory_snapshot["nodes"] + control.tasks = memory_snapshot["tasks"] + control.pending_queue = memory_snapshot["pending_queue"] + control.leases = memory_snapshot["leases"] + control.decision_log = memory_snapshot["decision_log"] + control.reservation_ledgers = memory_snapshot["reservation_ledgers"] + control.resource_snapshot_version = memory_snapshot["resource_snapshot_version"] + batch.tasks = memory_snapshot["batch_tasks"] + batch.status = memory_snapshot["batch_status"] + plan.status = memory_snapshot["plan_status"] + raise return { "status": "committed", "batch_id": batch_id, @@ -416,16 +472,17 @@ def commit(self, batch_id: str, payload: dict[str, Any]) -> dict[str, Any]: def report(self) -> dict[str, Any]: control = self.control_plane - batches = list(control.task_batches.values()) - assigned = sum(len(plan.assignments) for plan in control.batch_plans.values() if plan.status == "committed") - total = sum(len(batch.tasks) for batch in batches) - return { - "total_batches": len(batches), - "total_batch_tasks": total, - "committed_assignments": assigned, - "batch_acceptance_rate": round(assigned / total, 4) if total else 0.0, - "recent_batches": [batch.to_dict(include_tasks=False) for batch in batches[-8:]], - } + with control.lock: + batches = list(control.task_batches.values()) + assigned = sum(len(plan.assignments) for plan in control.batch_plans.values() if plan.status == "committed") + total = sum(len(batch.tasks) for batch in batches) + return { + "total_batches": len(batches), + "total_batch_tasks": total, + "committed_assignments": assigned, + "batch_acceptance_rate": round(assigned / total, 4) if total else 0.0, + "recent_batches": [batch.to_dict(include_tasks=False) for batch in batches[-8:]], + } def _build_plan( self, @@ -435,18 +492,33 @@ def _build_plan( active_metrics: tuple[str, ...] | None = None, active_groups: tuple[str, ...] | None = None, group_weight_overrides: dict[str, float] | None = None, + nodes_snapshot: dict[str, Any] | None = None, + snapshot_version: int | None = None, + snapshot_tick: int | None = None, ) -> BatchSchedulingPlan: control = self.control_plane started = time.perf_counter() - shadow_nodes = {node_id: copy.deepcopy(node) for node_id, node in control.nodes.items()} + source_nodes = nodes_snapshot if nodes_snapshot is not None else control.nodes + shadow_nodes = {node_id: copy.deepcopy(node) for node_id, node in source_nodes.items()} snapshot = ResourceSnapshot( - version=control.resource_snapshot_version, - tick=control.current_tick(), + version=control.resource_snapshot_version if snapshot_version is None else snapshot_version, + tick=control.current_tick() if snapshot_tick is None else snapshot_tick, available_by_node={node_id: node.available() for node_id, node in shadow_nodes.items()}, ) # B0 preserves the legacy submission order. Joint strategies use the # deterministic urgency/priority/scarcity ordering defined for batches. - ordered = list(batch.tasks) if strategy == "B0-current" else sorted(batch.tasks, key=self._task_sort_key) + ordered = ( + list(batch.tasks) + if strategy == "B0-current" + else sorted( + batch.tasks, + key=lambda task: self._task_sort_key( + task, + tick=snapshot.tick, + nodes=shadow_nodes.values(), + ), + ) + ) assignments: list[BatchAssignment] = [] unassigned: list[UnassignedTask] = [] if strategy == "B6-hierarchical-batch": @@ -485,7 +557,7 @@ def _build_plan( future_tasks=future_tasks_for_scoring, ) if decision is None: - unassigned.append(UnassignedTask(task.task_id, self._rejection_reason(task, shadow_nodes.values()))) + unassigned.append(UnassignedTask(task.task_id, rejection_reason(task, shadow_nodes.values()))) continue node = shadow_nodes[decision.node_id] carbon = dict(decision.network_snapshot.get("carbon") or {}) @@ -522,7 +594,7 @@ def _build_plan( ) task_samples = batch.tasks[: min(64, len(batch.tasks))] - future_before = self._future_fit(control.nodes.values(), task_samples) + future_before = self._future_fit(source_nodes.values(), task_samples, tick=snapshot.tick) summary = self._plan_hierarchical_summary( batch, assignments, @@ -609,7 +681,7 @@ def _experimental_assign( solution = max(front, key=lambda item: (item.assigned_count, item.utility), default=None) if solution is None: selected_ids: set[str] = set() - return [], [UnassignedTask(task.task_id, self._rejection_reason(task, nodes)) for task in ordered if task.task_id not in selected_ids] + return [], [UnassignedTask(task.task_id, rejection_reason(task, nodes)) for task in ordered if task.task_id not in selected_ids] assignments: list[BatchAssignment] = [] selected_ids = {item.task_id for item in solution.selected} for item in solution.selected: @@ -627,7 +699,7 @@ def _experimental_assign( assignments.append(assignment) shadow_nodes[item.node_id].running_tasks[f"__batch__{item.task_id}"] = self._shadow_running_task(task, decision, tick) unassigned = [ - UnassignedTask(task.task_id, self._rejection_reason(task, nodes)) + UnassignedTask(task.task_id, rejection_reason(task, nodes)) for task in ordered if task.task_id not in selected_ids ] @@ -878,7 +950,7 @@ def _plan_hierarchical_summary( ) should_calculate_future_fit = calculate_future_fit or "resource_efficiency" in selected_groups task_samples = batch.tasks[: min(64, len(batch.tasks))] - future_fit_after = self._future_fit(nodes, task_samples) if should_calculate_future_fit else 0.0 + future_fit_after = self._future_fit(nodes, task_samples, tick=tick) if should_calculate_future_fit else 0.0 if count and should_calculate_future_fit: group_scores["resource_efficiency"] = clamp( 0.60 * group_scores["resource_efficiency"] + 0.40 * future_fit_after @@ -939,54 +1011,28 @@ def _shadow_running_task(task: Task, decision: Any, tick: int) -> RunningTask: success_probability=1.0, ) - @staticmethod - def _validated_objectives(value: Any, allowed: tuple[str, ...], field: str) -> tuple[str, ...] | None: - if value is None: - return None - if not isinstance(value, (list, tuple)): - raise BatchRequestError(422, {"error": f"{field} must be an array"}) - unknown = [str(item) for item in value if str(item) not in allowed] - if unknown: - raise BatchRequestError(422, {"error": f"unknown {field}: {', '.join(unknown)}"}) - unique = tuple(dict.fromkeys(str(item) for item in value)) - if not unique: - raise BatchRequestError(422, {"error": f"{field} cannot be empty"}) - return unique - - @staticmethod - def _validated_weights( - value: Any, - allowed: tuple[str, ...], - field: str, - ) -> dict[str, float] | None: - if value is None: - return None - if not isinstance(value, dict): - raise BatchRequestError(422, {"error": f"{field} must be an object"}) - unknown = [str(key) for key in value if str(key) not in allowed] - if unknown: - raise BatchRequestError(422, {"error": f"unknown {field}: {', '.join(unknown)}"}) - weights = {str(key): float(weight) for key, weight in value.items()} - if any(weight < 0.0 for weight in weights.values()) or sum(weights.values()) <= 0.0: - raise BatchRequestError(422, {"error": f"{field} values must be non-negative with a positive sum"}) - return weights - def _batch(self, batch_id: str) -> TaskBatch: batch = self.control_plane.task_batches.get(batch_id) if batch is None: raise BatchRequestError(404, {"error": f"unknown batch {batch_id}"}) return batch - def _task_sort_key(self, task: Task) -> tuple[float, int, float, int, str]: - tick = self.control_plane.current_tick() + def _task_sort_key( + self, + task: Task, + *, + tick: int | None = None, + nodes: Any | None = None, + ) -> tuple[float, int, float, int, str]: + current_tick = self.control_plane.current_tick() if tick is None else tick fleet = ResourceVector() - for node in self.control_plane.nodes.values(): + for node in (self.control_plane.nodes.values() if nodes is None else nodes): fleet = fleet + node.capacity scarcity = task.demand.dominant_share_against(fleet) deadline = task.effective_deadline_tick() if task.effective_deadline_tick() is not None else 10**12 - return (float(deadline - tick - task.estimated_duration), -task.priority, -scarcity, task.submit_tick, task.task_id) + return (float(deadline - current_tick - task.estimated_duration), -task.priority, -scarcity, task.submit_tick, task.task_id) - def _future_fit(self, nodes: Any, tasks: list[Task]) -> float: + def _future_fit(self, nodes: Any, tasks: list[Task], *, tick: int | None = None) -> float: nodes_list = list(nodes) if not nodes_list or not tasks: return 0.0 @@ -998,11 +1044,11 @@ def _future_fit(self, nodes: Any, tasks: list[Task]) -> float: 1 for node in nodes_list for task in tasks - if self._future_task_fits(node, task) + if self._future_task_fits(node, task, tick=tick) ) return feasible_pairs / (len(nodes_list) * len(tasks)) - def _future_task_fits(self, node: Any, task: Task) -> bool: + def _future_task_fits(self, node: Any, task: Task, *, tick: int | None = None) -> bool: if not node.can_host_now(task): return False path = node.path_profile_for(task.network_source()) @@ -1011,68 +1057,11 @@ def _future_task_fits(self, node: Any, task: Task) -> bool: if task.min_bandwidth_mbps is not None and path.guaranteed_bandwidth_mbps() < task.min_bandwidth_mbps: return False if task.carbon_budget_g is not None: - predicted = node.predict_operational_carbon(task, node.predict_duration(task), self.control_plane.current_tick()) + predicted = node.predict_operational_carbon( + task, + node.predict_duration(task), + self.control_plane.current_tick() if tick is None else tick, + ) if float(predicted["operational_carbon_g"]) > task.carbon_budget_g: return False return True - - @staticmethod - def _rejection_reason(task: Task, nodes: Any) -> str: - nodes_list = list(nodes) - if task.demand.gpu > 0 and all(node.available().gpu + 1e-9 < task.demand.gpu for node in nodes_list): - return "INSUFFICIENT_GPU" - if task.allowed_regions and all(not any(node.matches_deployment_region(region) for region in task.allowed_regions) for node in nodes_list): - return "REGION_FORBIDDEN" - if task.carbon_budget_g is not None: - return "CARBON_BUDGET_EXCEEDED" - if task.deadline is not None: - return "DEADLINE_INFEASIBLE" - return "NO_FEASIBLE_NODE" - - @staticmethod - def _csv_row(row: dict[str, str], row_number: int) -> dict[str, Any]: - def number(name: str, default: float = 0.0) -> float: - text = str(row.get(name, "")).strip() - if text == "": - return default - try: - return float(text) - except ValueError as exc: - raise BatchRequestError(422, {"error": "batch validation failed", "validation": BatchValidationReport(0, errors=[BatchValidationIssue(row_number, name, "INVALID_NUMBER", f"{name} must be numeric")]).to_dict()}) from exc - - def boolean(name: str, default: bool) -> bool: - text = str(row.get(name, "")).strip().lower() - if text == "": - return default - if text not in {"true", "false"}: - raise BatchRequestError(422, {"error": "batch validation failed", "validation": BatchValidationReport(0, errors=[BatchValidationIssue(row_number, name, "INVALID_BOOLEAN", f"{name} must be true or false")]).to_dict()}) - return text == "true" - - payload: dict[str, Any] = { - "task_id": str(row.get("task_id", "")).strip(), - "task_type": str(row.get("task_type", "batch")).strip() or "batch", - "demand": {key: number(key) for key in ("cpu", "memory", "gpu", "storage")}, - "estimated_duration": int(number("estimated_duration", 0)), - "priority": int(number("priority", 5)), - "security_level": str(row.get("security_level", "medium")).strip() or "medium", - "isolation_level": str(row.get("isolation_level", "process")).strip() or "process", - "allowed_regions": [item for item in str(row.get("allowed_regions", "")).split("|") if item], - "forbidden_nodes": [item for item in str(row.get("forbidden_nodes", "")).split("|") if item], - "require_encrypted_transport": boolean("require_encrypted_transport", True), - "allow_region_shift": boolean("allow_region_shift", True), - "allow_time_shift": boolean("allow_time_shift", False), - "carbon_priority": number("carbon_priority", 0.0), - } - region = str(row.get("region", "")).strip() - if region and not payload["allowed_regions"]: - payload["allowed_regions"] = [region] - optional_numbers = ("budget", "deadline", "input_size_gb", "max_latency_ms", "min_bandwidth_mbps", "carbon_budget_g", "deferrable_until_tick") - for key in optional_numbers: - text = str(row.get(key, "")).strip() - if text: - payload[key] = float(text) if key not in {"deadline", "deferrable_until_tick"} else int(float(text)) - for key in ("data_region", "source_region"): - text = str(row.get(key, "")).strip() - if text: - payload[key] = text - return payload diff --git a/src/tianjun/application/bootstrap.py b/src/tianjun/application/bootstrap.py index f43b112..c6db03b 100644 --- a/src/tianjun/application/bootstrap.py +++ b/src/tianjun/application/bootstrap.py @@ -14,6 +14,7 @@ def build_control_plane( policy_state: PolicyState | None = None, policy_update_interval: int = 2, heartbeat_timeout_seconds: float = 15.0, + lease_timeout_seconds: float = 60.0, state_store: SQLiteStateStore | None = None, model_dir: str | Path | None = None, require_model: bool = False, @@ -31,6 +32,7 @@ def build_control_plane( policy_state=policy, policy_update_interval=policy_update_interval, heartbeat_timeout_seconds=heartbeat_timeout_seconds, + lease_timeout_seconds=lease_timeout_seconds, state_store=state_store, scheduler=scheduler, ) diff --git a/src/tianjun/application/control_plane.py b/src/tianjun/application/control_plane.py index 13fbbef..6218e3d 100644 --- a/src/tianjun/application/control_plane.py +++ b/src/tianjun/application/control_plane.py @@ -7,19 +7,19 @@ from typing import Any from ..core import ComputeNetworkPolicy, UserFeedback, UserRequirement -from ..domain import BatchStatus, ExecutionRecord, Node, PhysicalTopology, PolicyAdjustment, PolicyState, ResourceVector, SchedulingDecision, Task, TaskStatus, clamp, normalize_weights +from ..domain import BatchSchedulingPlan, BatchStatus, ExecutionRecord, Node, PhysicalTopology, PolicyState, ReservationLedger, ResourceVector, SchedulingDecision, Task, TaskBatch, TaskStatus, clamp, normalize_weights from ..policy.optimizer import PolicyOptimizer from ..policy.clarifier import RequirementSession from ..policy.generator import ComputeNetworkPolicyGenerator from ..storage.sqlite_state_store import SQLiteStateStore from ..scheduling.engine import ClosedLoopAdaptiveScheduler from ..ml.runtime import TrainedModelRuntime -from ..scenarios import node_from_dict, task_from_dict from .node_registry import NodeRegistry from .policy_workflow import PolicyWorkflowService from .requirement_dialogue import RequirementDialogueService from .task_lease_service import TaskLease, TaskLeaseService from .batch_scheduling_service import BatchSchedulingService +from .control_plane_state import restore_control_plane def _truncate(text: str, limit: int = 400) -> str: @@ -48,6 +48,7 @@ def __init__( policy_state: PolicyState | None = None, policy_update_interval: int = 2, heartbeat_timeout_seconds: float = 15.0, + lease_timeout_seconds: float = 60.0, state_store: SQLiteStateStore | None = None, scheduler: ClosedLoopAdaptiveScheduler | None = None, model_runtime: TrainedModelRuntime | None = None, @@ -61,9 +62,11 @@ def __init__( self.policy_generator = ComputeNetworkPolicyGenerator() self.policy_update_interval = policy_update_interval self.heartbeat_timeout_seconds = heartbeat_timeout_seconds + self.lease_timeout_seconds = max(1.0, float(lease_timeout_seconds)) self.state_store = state_store self.lock = threading.RLock() + self.planning_lock = threading.Lock() self.started_at = time.monotonic() self.nodes: dict[str, Node] = {} self.tasks: dict[str, Task] = {} @@ -74,6 +77,7 @@ def __init__( self.task_progress: dict[str, dict[str, Any]] = {} self.progress_events: list[dict[str, Any]] = [] self.last_heartbeat_at: dict[str, float] = {} + self.last_heartbeat_epoch: dict[str, float] = {} self.policies: dict[str, ComputeNetworkPolicy] = {} self.policy_tasks: dict[str, Task] = {} self.user_feedback: list[UserFeedback] = [] @@ -84,6 +88,8 @@ def __init__( self.batch_plans: dict[str, Any] = {} self.batch_idempotency: dict[str, str] = {} self.reservation_ledgers: dict[str, Any] = {} + self.task_result_receipts: dict[str, dict[str, Any]] = {} + self.latest_task_result_receipts: dict[str, dict[str, Any]] = {} self.tool_audit_log: list[dict[str, Any]] = [] self.node_registry = NodeRegistry(self) self.task_lease_service = TaskLeaseService(self) @@ -340,6 +346,13 @@ def record_heartbeat( def request_lease(self, node_id: str) -> dict[str, Any] | None: return self.task_lease_service.request_lease(node_id) + def acknowledge_lease(self, *, node_id: str, task_id: str, lease_id: str) -> dict[str, Any]: + return self.task_lease_service.acknowledge_lease( + node_id=node_id, + task_id=task_id, + lease_id=lease_id, + ) + def report_task_progress( self, *, @@ -350,6 +363,7 @@ def report_task_progress( progress: float | None = None, message: str | None = None, metrics: dict[str, Any] | None = None, + lease_id: str | None = None, ) -> dict[str, Any]: """Record an in-flight task lifecycle update from a real or simulated agent.""" with self.lock: @@ -359,6 +373,9 @@ def report_task_progress( raise ValueError(f"Task {task_id} does not have an active lease.") if lease.node_id != node_id: raise ValueError(f"Task {task_id} is leased to {lease.node_id}, not {node_id}.") + if lease_id is not None and lease.lease_id != lease_id: + raise ValueError("Lease identity does not match the active task lease.") + self.task_lease_service.renew_lease(lease) tick = self.current_tick() payload = { "task_id": task_id, @@ -379,6 +396,7 @@ def report_task_progress( if node is not None: node.telemetry_tick = tick self.last_heartbeat_at[node_id] = time.monotonic() + self.last_heartbeat_epoch[node_id] = time.time() self._persist_node(node) return payload @@ -395,14 +413,28 @@ def report_task_result( returncode: int | None = None, cost: float | None = None, metadata: dict[str, Any] | None = None, + lease_id: str | None = None, + result_id: str | None = None, ) -> dict[str, Any]: with self.lock: self._expire_stale_nodes() + if result_id and result_id in self.task_result_receipts: + receipt = self.task_result_receipts[result_id] + if receipt.get("task_id") != task_id or receipt.get("node_id") != node_id: + raise ValueError("Result identity belongs to a different task or node.") + if lease_id is not None and receipt.get("lease_id") != lease_id: + raise ValueError("Result identity belongs to a different lease.") + return {**receipt, "idempotent_replay": True} lease = self.leases.get(task_id) if lease is None: + previous = self.latest_task_result_receipts.get(task_id) + if previous is not None and previous.get("node_id") == node_id and (lease_id is None or previous.get("lease_id") == lease_id): + return {**previous, "idempotent_replay": True} raise ValueError(f"Task {task_id} does not have an active lease.") if lease.node_id != node_id: raise ValueError(f"Task {task_id} is leased to {lease.node_id}, not {node_id}.") + if lease_id is not None and lease.lease_id != lease_id: + raise ValueError("Lease identity does not match the active task lease.") if node_id not in self.nodes: raise ValueError(f"Unknown node {node_id}.") self.leases.pop(task_id) @@ -513,7 +545,20 @@ def report_task_result( latest_adjustment = self.policy_state.adjustment_history[-1] self.state_store.append_policy_adjustment(latest_adjustment.to_dict()) self.state_store.set_control_value("policy_weights", self.policy_state.current_weights()) - return record.to_dict() + receipt = { + **record.to_dict(), + "lease_id": lease.lease_id, + "result_id": result_id or f"result-{lease.lease_id}", + "idempotent_replay": False, + } + self.task_result_receipts[receipt["result_id"]] = receipt + self.latest_task_result_receipts[task_id] = receipt + if len(self.task_result_receipts) > SQLiteStateStore.MAX_EXECUTION_RECORDS: + oldest = next(iter(self.task_result_receipts)) + self.task_result_receipts.pop(oldest, None) + if self.state_store is not None: + self.state_store.set_control_value("task_result_receipts", list(self.task_result_receipts.values())) + return receipt def cancel_task_run(self, *, task_id: str, requeue: bool = False) -> dict[str, Any]: with self.lock: @@ -821,9 +866,13 @@ def build_report(self) -> dict[str, Any]: def _node_report_payload(self, node: Node) -> dict[str, Any]: + heartbeat_age = max( + 0.0, + time.monotonic() - self.last_heartbeat_at.get(node.node_id, self.started_at), + ) payload = { **node.to_dict(), - "last_heartbeat_age": round(time.monotonic() - self.last_heartbeat_at.get(node.node_id, self.started_at), 3), + "last_heartbeat_age": round(heartbeat_age, 3), } runtime_utilization: dict[str, float | None] = { "cpu": node.runtime_telemetry.get("cpu"), @@ -858,6 +907,47 @@ def _node_report_payload(self, node: Node) -> dict[str, Any]: payload["runtime_telemetry_available"] = any(value is not None for value in runtime_utilization.values()) payload["active_task_ids"] = active_task_ids payload["active_stages"] = active_stages + telemetry_source = str(node.telemetry_source or "").strip().lower() + telemetry_is_current = node.online and heartbeat_age <= self.heartbeat_timeout_seconds + if telemetry_source in {"cloudsim", "cloudsimplus", "simulator"}: + load_source = "simulated_telemetry" + load_source_label = "CloudSim Plus 模拟遥测" + elif telemetry_source: + load_source = "live_telemetry" + load_source_label = "节点实时遥测" + elif payload["runtime_telemetry_available"]: + load_source = "task_progress_estimate" + load_source_label = "任务进度估算" + elif node.running_tasks or active_task_ids: + load_source = "allocation_estimate" + load_source_label = "任务分配估算" + else: + load_source = "unavailable" + load_source_label = "暂无负载遥测" + payload["resource_load_source"] = load_source + payload["resource_load_source_label"] = load_source_label + payload["telemetry_freshness"] = ( + "current" if telemetry_is_current and load_source != "unavailable" else + "stale" if load_source != "unavailable" else + "unavailable" + ) + + carbon_version = str(node.carbon_profile.source_version or "").strip().lower() + if node.carbon_signal_timestamp is not None: + carbon_source = "simulated_signal" if load_source == "simulated_telemetry" else "live_signal" + carbon_source_label = "CloudSim Plus 模拟碳信号" if carbon_source == "simulated_signal" else "节点实时碳信号" + carbon_freshness = "current" if telemetry_is_current else "stale" + elif any(marker in carbon_version for marker in ("synthetic", "simulated", "trace")): + carbon_source = "simulated_profile" + carbon_source_label = "模拟碳强度曲线" + carbon_freshness = "profile" + else: + carbon_source = "configured_profile" + carbon_source_label = "配置碳强度" + carbon_freshness = "profile" + payload["carbon_data_source"] = carbon_source + payload["carbon_data_source_label"] = carbon_source_label + payload["carbon_data_freshness"] = carbon_freshness return payload def current_tick(self) -> int: @@ -931,12 +1021,6 @@ def _expire_stale_nodes(self) -> None: now = time.monotonic() stale_node_ids: set[str] = set() for node_id, node in self.nodes.items(): - if self._is_cloudsim_snapshot_node(node): - if not node.online: - node.online = True - self.last_heartbeat_at[node_id] = now - self._persist_node(node) - continue last_seen = self.last_heartbeat_at.get(node_id, self.started_at) if now - last_seen > self.heartbeat_timeout_seconds: stale_node_ids.add(node_id) @@ -945,11 +1029,7 @@ def _expire_stale_nodes(self) -> None: self._persist_node(node) if stale_node_ids: self._recover_leases_for_stale_nodes(stale_node_ids) - - @staticmethod - def _is_cloudsim_snapshot_node(node: Node) -> bool: - labels = {str(label).lower() for label in node.labels} - return "cloudsim" in labels or "cloudsimplus" in labels + self.task_lease_service.expire_stale_leases() def _recover_leases_for_stale_nodes(self, stale_node_ids: set[str]) -> None: """Release leases held by offline agents so tasks can be retried elsewhere.""" @@ -1042,8 +1122,8 @@ def _activate_task_lease( def _persist_node(self, node: Node) -> None: if self.state_store is None: return - last_seen = self.last_heartbeat_at.get(node.node_id, time.monotonic()) - self.state_store.save_node(node.to_dict(), last_seen) + last_seen_epoch = self.last_heartbeat_epoch.get(node.node_id, time.time()) + self.state_store.save_node(node.to_dict(), last_seen_epoch) def _persist_task(self, task: Task) -> None: if self.state_store is None: @@ -1051,71 +1131,4 @@ def _persist_task(self, task: Task) -> None: self.state_store.save_task(task.to_dict()) def _restore_from_store(self) -> None: - if self.state_store is None: - return - snapshot = self.state_store.load_state() - - restored_weights = snapshot["control_state"].get("policy_weights") - if restored_weights: - self.policy_state.weights = restored_weights - restored_group_weights = snapshot["control_state"].get("policy_group_weights") - if restored_group_weights: - self.policy_state.group_weights = restored_group_weights - - restored_tool_audit_log = snapshot["control_state"].get("tool_audit_log") - if isinstance(restored_tool_audit_log, list): - self.tool_audit_log = [ - dict(item) for item in restored_tool_audit_log if isinstance(item, dict) - ][-200:] - - restored_topology = snapshot["control_state"].get("physical_topology") - if restored_topology: - self.physical_topology = PhysicalTopology.from_dict(restored_topology) - self.scheduler.set_physical_topology(self.physical_topology) - - self.policy_state.adjustment_history = [ - PolicyAdjustment( - tick=int(payload["tick"]), - weights={str(key): float(value) for key, value in payload["weights"].items()}, - reasons=list(payload["reasons"]), - affected_records=int(payload.get("affected_records", 0)), - metrics={str(key): float(value) for key, value in dict(payload.get("metrics") or {}).items()}, - ) - for payload in snapshot["policy_adjustments"] - ] - - for node_entry in snapshot["nodes"]: - node = node_from_dict(node_entry["payload"]) - node.running_tasks = {} - self.nodes[node.node_id] = node - self.last_heartbeat_at[node.node_id] = float(node_entry["last_heartbeat_at"]) - - for payload in snapshot["tasks"]: - task = task_from_dict(payload) - if task.status in {TaskStatus.RUNNING, TaskStatus.RESERVED, TaskStatus.LEASED}: - task.status = TaskStatus.PENDING - self.tasks[task.task_id] = task - if task.status == TaskStatus.PENDING and task.task_id not in self.pending_queue: - self.pending_queue.append(task.task_id) - - self.decision_log = [ - SchedulingDecision(**payload) - for payload in snapshot["decisions"] - ] - self.execution_history = [ - ExecutionRecord(**payload) - for payload in snapshot["execution_records"] - ] - - for lease_payload in snapshot["leases"]: - task_id = lease_payload["task_id"] - if task_id in self.tasks and self.tasks[task_id].status != TaskStatus.SUCCEEDED: - self.tasks[task_id].status = TaskStatus.PENDING - if task_id not in self.pending_queue: - self.pending_queue.append(task_id) - self.state_store.delete_lease(task_id) - - for task in self.tasks.values(): - self._persist_task(task) - for node in self.nodes.values(): - self._persist_node(node) + restore_control_plane(self) diff --git a/src/tianjun/application/control_plane_state.py b/src/tianjun/application/control_plane_state.py new file mode 100644 index 0000000..369e562 --- /dev/null +++ b/src/tianjun/application/control_plane_state.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +from ..domain import ( + BatchSchedulingPlan, + ExecutionRecord, + PhysicalTopology, + PolicyAdjustment, + ReservationLedger, + SchedulingDecision, + TaskBatch, + TaskStatus, +) +from ..scenarios import node_from_dict, task_from_dict +from ..storage.sqlite_state_store import SQLiteStateStore + +if TYPE_CHECKING: + from .control_plane import CentralControlPlane + + +def restore_control_plane(control: CentralControlPlane) -> None: + """Restore durable state while deliberately invalidating in-flight leases.""" + if control.state_store is None: + return + snapshot = control.state_store.load_state() + + restored_weights = snapshot["control_state"].get("policy_weights") + if restored_weights: + control.policy_state.weights = restored_weights + restored_group_weights = snapshot["control_state"].get("policy_group_weights") + if restored_group_weights: + control.policy_state.group_weights = restored_group_weights + + restored_tool_audit_log = snapshot["control_state"].get("tool_audit_log") + if isinstance(restored_tool_audit_log, list): + control.tool_audit_log = [ + dict(item) for item in restored_tool_audit_log if isinstance(item, dict) + ][-200:] + + restored_receipts = snapshot["control_state"].get("task_result_receipts") + if isinstance(restored_receipts, list): + for receipt in restored_receipts[-SQLiteStateStore.MAX_EXECUTION_RECORDS:]: + if not isinstance(receipt, dict): + continue + result_id = str(receipt.get("result_id") or "") + task_id = str(receipt.get("task_id") or "") + if result_id: + control.task_result_receipts[result_id] = dict(receipt) + if task_id: + control.latest_task_result_receipts[task_id] = dict(receipt) + + restored_topology = snapshot["control_state"].get("physical_topology") + if restored_topology: + control.physical_topology = PhysicalTopology.from_dict(restored_topology) + control.scheduler.set_physical_topology(control.physical_topology) + + control.policy_state.adjustment_history = [ + PolicyAdjustment( + tick=int(payload["tick"]), + weights={str(key): float(value) for key, value in payload["weights"].items()}, + reasons=list(payload["reasons"]), + affected_records=int(payload.get("affected_records", 0)), + metrics={ + str(key): float(value) + for key, value in dict(payload.get("metrics") or {}).items() + }, + ) + for payload in snapshot["policy_adjustments"] + ] + + for node_entry in snapshot["nodes"]: + node = node_from_dict(node_entry["payload"]) + node.running_tasks = {} + control.nodes[node.node_id] = node + last_seen_epoch = float(node_entry["last_seen_epoch"]) + heartbeat_age = max(0.0, time.time() - last_seen_epoch) + control.last_heartbeat_epoch[node.node_id] = last_seen_epoch + control.last_heartbeat_at[node.node_id] = time.monotonic() - heartbeat_age + + for payload in snapshot["tasks"]: + task = task_from_dict(payload) + if task.status in {TaskStatus.RUNNING, TaskStatus.RESERVED, TaskStatus.LEASED}: + task.status = TaskStatus.PENDING + control.tasks[task.task_id] = task + if task.status == TaskStatus.PENDING and task.task_id not in control.pending_queue: + control.pending_queue.append(task.task_id) + + control.decision_log = [ + SchedulingDecision.from_dict(payload) for payload in snapshot["decisions"] + ] + control.execution_history = [ + ExecutionRecord(**payload) for payload in snapshot["execution_records"] + ] + + for lease_payload in snapshot["leases"]: + task_id = lease_payload["task_id"] + if task_id in control.tasks and control.tasks[task_id].status != TaskStatus.SUCCEEDED: + control.tasks[task_id].status = TaskStatus.PENDING + if task_id not in control.pending_queue: + control.pending_queue.append(task_id) + control.state_store.delete_lease(task_id) + + for payload in snapshot.get("task_batches", []): + batch_tasks = [] + for task_payload in payload.get("tasks", []): + task_id = str(task_payload.get("task_id") or "") + batch_tasks.append(control.tasks.get(task_id) or task_from_dict(task_payload)) + batch = TaskBatch.from_dict(payload, tasks=batch_tasks) + control.task_batches[batch.batch_id] = batch + control.batch_idempotency[batch.client_batch_id] = batch.batch_id + + for payload in snapshot.get("batch_plans", []): + plan = BatchSchedulingPlan.from_dict(payload) + control.batch_plans[plan.plan_id] = plan + + for payload in snapshot.get("reservation_ledgers", []): + ledger = ReservationLedger.from_dict(payload) + control.reservation_ledgers[ledger.plan_id] = ledger + + restored_version = int(snapshot["control_state"].get("resource_snapshot_version", 0)) + control.resource_snapshot_version = max( + restored_version, + *(node.resource_version for node in control.nodes.values()), + 0, + ) + + for task in control.tasks.values(): + control._persist_task(task) + for node in control.nodes.values(): + control._persist_node(node) diff --git a/src/tianjun/application/dashboard_reporting.py b/src/tianjun/application/dashboard_reporting.py index b0e4fae..d2f8376 100644 --- a/src/tianjun/application/dashboard_reporting.py +++ b/src/tianjun/application/dashboard_reporting.py @@ -1,6 +1,13 @@ from __future__ import annotations -from typing import Any +import time +from statistics import mean +from typing import TYPE_CHECKING, Any + +from ..domain import ResourceVector, Task, TaskStatus + +if TYPE_CHECKING: + from .control_plane import CentralControlPlane COMMON_FIELDS = { @@ -83,6 +90,12 @@ "runtime_utilization", "runtime_telemetry_available", "telemetry_source", + "resource_load_source", + "resource_load_source_label", + "telemetry_freshness", + "carbon_data_source", + "carbon_data_source_label", + "carbon_data_freshness", "simulation_tick", "active_task_ids", "active_stages", @@ -91,6 +104,237 @@ SCHEDULING_NODE_FIELDS = SUMMARY_NODE_FIELDS | {"network_paths"} +def build_dashboard_report( + control: "CentralControlPlane", + view: str, + *, + cursor: int = 0, + limit: int = 50, +) -> dict[str, Any]: + """Build one dashboard page from a short, consistent control-plane snapshot.""" + normalized_view = view if view in VIEW_FIELDS else "summary" + requested_fields = VIEW_FIELDS[normalized_view] + with control.lock: + control._expire_stale_nodes() + tick = control.current_tick() + records = list(control.execution_history) + decisions = list(control.decision_log) + tasks = dict(control.tasks) + pending_ids = list(control.pending_queue) + lease_count = len(control.leases) + snapshot_version = control.resource_snapshot_version + audit_log = list(control.tool_audit_log) + report: dict[str, Any] = { + "tick": tick, + "generated_at": time.time(), + "report_version": f"{snapshot_version}:{tick}", + "resource_snapshot_version": snapshot_version, + } + if "nodes" in requested_fields: + node_fields = ( + SCHEDULING_NODE_FIELDS + if normalized_view == "scheduling" + else SUMMARY_NODE_FIELDS + if normalized_view == "summary" + else None + ) + node_payloads = [control._node_report_payload(node) for node in control.nodes.values()] + report["nodes"] = ( + [ + {key: value for key, value in node.items() if key in node_fields} + for node in node_payloads + ] + if node_fields is not None + else node_payloads + ) + if "physical_topology" in requested_fields: + report["physical_topology"] = ( + None if control.physical_topology is None else control.physical_topology.to_dict() + ) + if "recent_decisions" in requested_fields: + decision_limit = 3 if normalized_view in {"summary", "topology"} else 8 + report["recent_decisions"] = [item.to_dict() for item in decisions[-decision_limit:]] + if "active_runs" in requested_fields: + report["active_runs"] = control._active_runs_payload() + if "recent_progress_events" in requested_fields: + report["recent_progress_events"] = list(control.progress_events[-16:]) + if "recent_records" in requested_fields: + report["recent_records"] = [record.to_dict() for record in records[-8:]] + if "task_statuses" in requested_fields: + report["task_statuses"] = { + task_id: task.status.value for task_id, task in sorted(tasks.items()) + } + if "pending_task_queue" in requested_fields: + report["pending_task_queue"] = [ + tasks[task_id].to_dict() + for task_id in pending_ids + if task_id in tasks and tasks[task_id].status == TaskStatus.PENDING + ] + if "execution_records" in requested_fields: + safe_cursor = max(0, int(cursor)) + safe_limit = max(1, min(200, int(limit))) + end = max(0, len(records) - safe_cursor) + start = max(0, end - safe_limit) + report["execution_records"] = [record.to_dict() for record in records[start:end]] + report["pagination"] = { + "cursor": safe_cursor, + "limit": safe_limit, + "total": len(records), + "next_cursor": None if start == 0 else safe_cursor + (end - start), + } + if "batch_scheduling" in requested_fields: + report["batch_scheduling"] = control.batch_scheduling_service.report() + if "policy_history" in requested_fields: + report["policy_history"] = [ + entry.to_dict() for entry in control.policy_state.adjustment_history[-50:] + ] + policy_weights = control.policy_state.current_weights() + group_weights = control.policy_state.current_group_weights() + topology_available = control.physical_topology is not None + + succeeded = [record for record in records if record.success] + failed = [record for record in records if not record.success] + report["totals"] = { + "tasks": len(tasks), + "completed_attempts": len(records), + "succeeded_attempts": len(succeeded), + "failed_attempts": len(failed), + "completed": len(records), + "succeeded": len(succeeded), + "failed": len(failed), + "pending_tasks": len(pending_ids), + "leased_tasks": lease_count, + "running_tasks": lease_count, + "pending": len(pending_ids), + "running": lease_count, + "sla_met": sum(1 for record in records if record.sla_met), + "sla_missed": sum(1 for record in records if not record.sla_met), + } + report["metrics"] = _report_metrics(records, decisions, tasks) + external_calls = [item for item in audit_log if item.get("actor") == "external_mcp"] + external_successes = [item for item in external_calls if item.get("result_status") == "success"] + report["toolchain_runtime"] = { + "external_mcp_last_call": external_calls[-1] if external_calls else None, + "external_mcp_last_success": external_successes[-1] if external_successes else None, + "external_mcp_call_count": len(external_calls), + "external_mcp_success_count": len(external_successes), + "recent_calls": audit_log[-20:], + } + + model_runtime = control.scheduler.model_runtime.describe() + loaded_models = set(model_runtime.get("loaded_models", [])) + model_predictions = [ + item.network_snapshot.get("model_prediction", {}) + for item in decisions + if item.network_snapshot.get("model_prediction") + ] + if "model_runtime" in requested_fields: + report["model_runtime"] = { + **model_runtime, + "latest_prediction": model_predictions[-1] if model_predictions else {}, + } + if "policy_weights" in requested_fields: + report["policy_weights"] = {key: round(value, 4) for key, value in policy_weights.items()} + if "policy_group_weights" in requested_fields: + report["policy_group_weights"] = {key: round(value, 4) for key, value in group_weights.items()} + if "weight_sources" in requested_fields or "group_weight_sources" in requested_fields: + reference = Task( + task_id="__dashboard_weight_reference__", + task_type="batch_cpu", + demand=ResourceVector(cpu=1, memory=1, storage=1), + estimated_duration=10, + ) + if "weight_sources" in requested_fields: + report["weight_sources"] = control.scheduler.weight_components(reference, tick) + if "group_weight_sources" in requested_fields: + report["group_weight_sources"] = control.scheduler.group_weight_components(reference, tick) + if "algorithm_profile" in requested_fields: + report["algorithm_profile"] = { + "name": "deterministic_compute_network_policy_engine", + "model_status": model_runtime["status"], + "features": [ + "resource_fit", + "deadline_completion", + "network_stability", + "operational_carbon", + "batch_joint_allocation", + "hierarchical_objective_fusion", + *(("lstm_latency_prediction",) if "lstm" in loaded_models else ()), + *(("graphsage_topology_score",) if "gnn" in loaded_models else ()), + ], + } + report["data_gaps"] = { + "latency_history": "链路序列当前可能来自画像合成;具体来源以节点与链路数据来源字段为准。", + "bandwidth_utilization": "无端口遥测时使用链路画像估算,不标记为实时数据。", + "gnn_topology_embedding": ( + "GraphSAGE 已加载并使用物理拓扑。" + if "gnn" in loaded_models and topology_available + else "GraphSAGE 未加载或缺少物理拓扑,使用确定性兜底。" + ), + } + report["view"] = normalized_view + return report + + +def _report_metrics(records: list[Any], decisions: list[Any], tasks: dict[str, Any]) -> dict[str, Any]: + values = lambda attribute: [float(getattr(record, attribute, 0.0)) for record in records] + actual_jct = [record.jct_seconds for record in records if record.jct_seconds > 0.0] + queue_wait = values("queue_wait_seconds") + carbon = values("operational_carbon_g") + stable_latency = [ + float(item.network_snapshot.get("stable_latency_ms", item.network_snapshot.get("robust_latency_ms", 0.0))) + for item in decisions + ] + fusion = [float(item.network_snapshot.get("feature_fusion_score", 0.0)) for item in decisions] + confidences = [float(item.network_snapshot.get("deterministic_confidence", 0.0)) for item in decisions] + batch_makespans: dict[str, float] = {} + for record in records: + if record.batch_id and record.jct_seconds > 0.0: + batch_makespans[record.batch_id] = max(batch_makespans.get(record.batch_id, 0.0), record.jct_seconds) + first_start: dict[str, int] = {} + for record in records: + first_start.setdefault(record.task_id, record.start_tick) + waits = [first_start[task_id] - tasks[task_id].submit_tick for task_id in first_start if task_id in tasks] + return { + "success_rate": round(sum(1 for record in records if record.success) / len(records), 4) if records else 0.0, + "average_wait_ticks": round(mean(waits), 4) if waits else 0.0, + "average_cost": round(mean(values("cost")), 4) if records else 0.0, + "average_network_delay_ticks": round(mean(values("network_delay_ticks")), 4) if records else 0.0, + "average_network_risk": round(mean(values("network_risk")), 4) if records else 0.0, + "total_energy_kwh": round(sum(values("energy_kwh")), 8), + "total_operational_carbon_g": round(sum(carbon), 6), + "average_operational_carbon_g_per_task": round(mean(carbon), 6) if carbon else 0.0, + "average_actual_jct_seconds": round(mean(actual_jct), 6) if actual_jct else 0.0, + "p95_actual_jct_seconds": round(_percentile(actual_jct, 0.95), 6), + "average_queue_wait_seconds": round(mean(queue_wait), 6) if queue_wait else 0.0, + "p95_queue_wait_seconds": round(_percentile(queue_wait, 0.95), 6), + "actual_makespan_seconds": round(max(actual_jct), 6) if actual_jct else 0.0, + "average_cpu_utilization": round(mean(values("cpu_utilization")), 6) if records else 0.0, + "average_memory_utilization": round(mean(values("memory_utilization")), 6) if records else 0.0, + "average_bandwidth_utilization": round(mean(values("bandwidth_utilization")), 6) if records else 0.0, + "average_storage_utilization": round(mean(values("storage_utilization")), 6) if records else 0.0, + "completed_batch_count": len(batch_makespans), + "batch_makespan_seconds": {key: round(value, 6) for key, value in sorted(batch_makespans.items())}, + "average_stable_latency_ms": round(mean(stable_latency), 4) if stable_latency else 0.0, + "average_fusion_score": round(mean(fusion), 4) if fusion else 0.0, + "average_deterministic_confidence": round(mean(confidences), 4) if confidences else 0.0, + "sla_rate": round(mean(1.0 if record.sla_met else 0.0 for record in records), 4) if records else 0.0, + } + + +def _percentile(values: list[float], percentile: float) -> float: + if not values: + return 0.0 + ordered = sorted(float(value) for value in values) + if len(ordered) == 1: + return ordered[0] + position = max(0.0, min(1.0, percentile)) * (len(ordered) - 1) + lower = int(position) + upper = min(lower + 1, len(ordered) - 1) + fraction = position - lower + return ordered[lower] + fraction * (ordered[upper] - ordered[lower]) + + def dashboard_report_view( report: dict[str, Any], view: str, diff --git a/src/tianjun/application/lifecycle.py b/src/tianjun/application/lifecycle.py new file mode 100644 index 0000000..dfeaa77 --- /dev/null +++ b/src/tianjun/application/lifecycle.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import logging +import threading +import time +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .control_plane import CentralControlPlane + + +LOGGER = logging.getLogger(__name__) + + +@dataclass(slots=True) +class LifecycleSweeper: + """Expire stale nodes and leases independently from user traffic.""" + + control_plane: CentralControlPlane + interval_seconds: float = 1.0 + _stop_event: threading.Event = field(default_factory=threading.Event, init=False) + _thread: threading.Thread | None = field(default=None, init=False) + run_count: int = field(default=0, init=False) + failure_count: int = field(default=0, init=False) + last_run_epoch: float | None = field(default=None, init=False) + last_error: str | None = field(default=None, init=False) + + def __post_init__(self) -> None: + if self.interval_seconds <= 0: + raise ValueError("lifecycle sweep interval must be positive") + + @property + def running(self) -> bool: + return self._thread is not None and self._thread.is_alive() + + def start(self) -> None: + if self.running: + return + self._stop_event.clear() + self._thread = threading.Thread( + target=self._run, + name="tianjun-lifecycle-sweeper", + daemon=True, + ) + self._thread.start() + + def stop(self, timeout: float = 5.0) -> None: + thread = self._thread + if thread is None: + return + self._stop_event.set() + if thread is not threading.current_thread(): + thread.join(timeout=timeout) + if thread.is_alive(): + raise RuntimeError("lifecycle sweeper did not stop before timeout") + self._thread = None + + def sweep_once(self) -> None: + try: + with self.control_plane.lock: + self.control_plane._expire_stale_nodes() + self.run_count += 1 + self.last_run_epoch = time.time() + self.last_error = None + except Exception as exc: # keep maintenance alive; surface state via snapshot + self.failure_count += 1 + self.last_error = f"{type(exc).__name__}: {exc}" + LOGGER.exception("Lifecycle sweep failed") + + def snapshot(self) -> dict[str, Any]: + return { + "running": self.running, + "interval_seconds": self.interval_seconds, + "run_count": self.run_count, + "failure_count": self.failure_count, + "last_run_epoch": self.last_run_epoch, + "last_error": self.last_error, + } + + def _run(self) -> None: + while not self._stop_event.wait(self.interval_seconds): + self.sweep_once() diff --git a/src/tianjun/application/node_registry.py b/src/tianjun/application/node_registry.py index cf21c94..b9681f4 100644 --- a/src/tianjun/application/node_registry.py +++ b/src/tianjun/application/node_registry.py @@ -35,7 +35,10 @@ def register_node(self, node: Node) -> dict[str, Any]: node.resource_version += 1 control.resource_snapshot_version += 1 control.last_heartbeat_at[node.node_id] = time.monotonic() + control.last_heartbeat_epoch[node.node_id] = time.time() control._persist_node(node) + if control.state_store is not None: + control.state_store.set_control_value("resource_snapshot_version", control.resource_snapshot_version) return node.to_dict() def record_heartbeat( @@ -126,6 +129,7 @@ def record_heartbeat( node.resource_version += 1 control.resource_snapshot_version += 1 control.last_heartbeat_at[node_id] = time.monotonic() + control.last_heartbeat_epoch[node_id] = time.time() heartbeat_payload = { "node_id": node_id, "tick": node.telemetry_tick, @@ -150,5 +154,6 @@ def record_heartbeat( if node.online is False: control._recover_leases_for_stale_nodes({node_id}) if control.state_store is not None: + control.state_store.set_control_value("resource_snapshot_version", control.resource_snapshot_version) control.state_store.record_heartbeat(node_id, heartbeat_payload) return heartbeat_payload diff --git a/src/tianjun/application/task_lease_service.py b/src/tianjun/application/task_lease_service.py index 0917c13..5c4edbd 100644 --- a/src/tianjun/application/task_lease_service.py +++ b/src/tianjun/application/task_lease_service.py @@ -1,5 +1,7 @@ from __future__ import annotations +import time +import uuid from dataclasses import dataclass from typing import TYPE_CHECKING, Any @@ -12,6 +14,7 @@ @dataclass(slots=True) class TaskLease: + lease_id: str task_id: str node_id: str issued_tick: int @@ -20,9 +23,13 @@ class TaskLease: explanation: str task: Task decision: SchedulingDecision + issued_at_epoch: float + expires_at_epoch: float + acknowledged_at_epoch: float | None = None def to_dict(self) -> dict[str, Any]: return { + "lease_id": self.lease_id, "task_id": self.task_id, "node_id": self.node_id, "issued_tick": self.issued_tick, @@ -31,6 +38,11 @@ def to_dict(self) -> dict[str, Any]: "explanation": self.explanation, "task": self.task.to_dict(), "decision": self.decision.to_dict(), + "issued_at_epoch": round(self.issued_at_epoch, 6), + "expires_at_epoch": round(self.expires_at_epoch, 6), + "acknowledged_at_epoch": ( + None if self.acknowledged_at_epoch is None else round(self.acknowledged_at_epoch, 6) + ), } @dataclass(slots=True) @@ -133,7 +145,7 @@ def request_lease(self, node_id: str) -> dict[str, Any] | None: return None for lease in list(control.leases.values()): - if lease.node_id == node_id and lease.task_id not in control.task_progress: + if lease.node_id == node_id and lease.acknowledged_at_epoch is None: return lease.to_dict() tick = control.current_tick() @@ -167,6 +179,65 @@ def request_lease(self, node_id: str) -> dict[str, Any] | None: return lease.to_dict() return None + def acknowledge_lease(self, *, node_id: str, task_id: str, lease_id: str) -> dict[str, Any]: + control = self.control_plane + with control.lock: + lease = control.leases.get(task_id) + if lease is None: + raise ValueError(f"Task {task_id} does not have an active lease.") + if lease.node_id != node_id or lease.lease_id != lease_id: + raise ValueError("Lease identity does not match the active task lease.") + now = time.time() + if lease.acknowledged_at_epoch is None: + lease.acknowledged_at_epoch = now + lease.expires_at_epoch = self._renewed_expiry(lease, now) + if control.state_store is not None: + control.state_store.save_lease(lease.to_dict()) + return {**lease.to_dict(), "status": "acknowledged"} + + def renew_lease(self, lease: TaskLease) -> None: + now = time.time() + if lease.acknowledged_at_epoch is None: + lease.acknowledged_at_epoch = now + lease.expires_at_epoch = self._renewed_expiry(lease, now) + if self.control_plane.state_store is not None: + self.control_plane.state_store.save_lease(lease.to_dict()) + + def _renewed_expiry(self, lease: TaskLease, now: float) -> float: + timeout = self.control_plane.lease_timeout_seconds + initial_expiry = lease.issued_at_epoch + timeout + minimum_visible_renewal = round(initial_expiry, 6) + 0.000001 + return max(now + timeout, minimum_visible_renewal) + + def expire_stale_leases(self) -> list[str]: + control = self.control_plane + now = time.time() + expired: list[str] = [] + with control.lock: + for task_id, lease in list(control.leases.items()): + if lease.expires_at_epoch > now: + continue + expired.append(task_id) + control.leases.pop(task_id, None) + node = control.nodes.get(lease.node_id) + if node is not None: + node.running_tasks.pop(task_id, None) + node.resource_version += 1 + control._persist_node(node) + task = control.tasks.get(task_id) + if task is not None and task.status == TaskStatus.RUNNING: + task.status = TaskStatus.PENDING if task.attempts <= task.max_retries else TaskStatus.FAILED + if task.status == TaskStatus.PENDING and task_id not in control.pending_queue: + control.pending_queue.append(task_id) + control._persist_task(task) + control.task_progress.pop(task_id, None) + control.resource_snapshot_version += 1 + if control.state_store is not None: + control.state_store.delete_lease(task_id) + if expired and control.state_store is not None: + control.state_store.set_control_value("resource_snapshot_version", control.resource_snapshot_version) + return expired + @staticmethod def task_sort_key(task: Task) -> tuple[float, int, int, str]: deadline_sort = task.deadline if task.deadline is not None else 10**9 @@ -210,7 +281,9 @@ def activate_task_lease( control.decision_log.append(decision) control.decision_log = control.decision_log[-2000:] + issued_at_epoch = time.time() lease = TaskLease( + lease_id=f"lease-{uuid.uuid4().hex}", task_id=task.task_id, node_id=node.node_id, issued_tick=tick, @@ -219,6 +292,8 @@ def activate_task_lease( explanation=decision.explanation, task=task, decision=decision, + issued_at_epoch=issued_at_epoch, + expires_at_epoch=issued_at_epoch + control.lease_timeout_seconds, ) control.leases[task.task_id] = lease control._persist_task(task) diff --git a/src/tianjun/chat/__init__.py b/src/tianjun/chat/__init__.py index c186fa9..de47a7b 100644 --- a/src/tianjun/chat/__init__.py +++ b/src/tianjun/chat/__init__.py @@ -1,3 +1,4 @@ -from .runtime import ChatRuntime, ChatSession, ChatTurn +from .models import ChatSession, ChatTurn +from .runtime import ChatRuntime __all__ = ["ChatRuntime", "ChatSession", "ChatTurn"] diff --git a/src/tianjun/chat/constants.py b/src/tianjun/chat/constants.py new file mode 100644 index 0000000..f48c820 --- /dev/null +++ b/src/tianjun/chat/constants.py @@ -0,0 +1,28 @@ +from __future__ import annotations + + +CONFIRM_WORDS = ( + "确认", "提交", "同意", "批准", "可以执行", "开始执行", + "commit", "approve", "submit", "yes", +) +CANCEL_WORDS = ("取消", "先不", "不要提交", "别提交", "stop", "cancel") +FEEDBACK_WORDS = ( + "太高", "太慢", "太贵", "不满意", "优化", "调整", "换", "降低", "提高", + "成本", "预算", "延迟", "时延", "安全", "sla", "qos", "反馈", +) +REGION_LABELS = { + "east": "东部区域", "west": "西部区域", "south": "华南区域", "dc1": "DC1", + "dc2": "DC2", "dc3": "DC3", "shanghai": "上海", "beijing": "北京", + "hangzhou": "杭州", "shenzhen": "深圳", "guangzhou": "广州", "dongguan": "东莞", + "chengdu": "成都", "chongqing": "重庆", "wuhan": "武汉", "huizhou": "惠州", + "zhuhai": "珠海", "foshan": "佛山", "zhongshan": "中山", +} +WORKLOAD_LABELS = { + "inference": "推理", "training": "训练", "streaming": "流式处理", + "analytics": "分析", "batch": "批处理", +} +FACTOR_LABELS = { + "network": "网络质量", "completion": "任务完成能力", "performance": "算力性能", + "security": "安全匹配度", "cost": "成本表现", "load": "负载余量", + "availability": "可用性", +} diff --git a/src/tianjun/chat/models.py b/src/tianjun/chat/models.py new file mode 100644 index 0000000..55513bb --- /dev/null +++ b/src/tianjun/chat/models.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(slots=True) +class ChatTurn: + role: str + content: str + created_at: float = field(default_factory=time.time) + tool_name: str | None = None + tool_payload: dict[str, Any] | None = None + + def to_dict(self, *, include_tool_payload: bool = True) -> dict[str, Any]: + payload = { + "role": self.role, + "content": self.content, + "created_at": round(self.created_at, 4), + } + if self.tool_name: + payload["tool_name"] = self.tool_name + if include_tool_payload and self.tool_payload is not None: + payload["tool_payload"] = self.tool_payload + return payload + + +@dataclass(slots=True) +class ChatSession: + session_id: str + status: str = "active" + requirement_session_id: str | None = None + policy_id: str | None = None + pending_confirmation: bool = False + pending_option_selection: bool = False + policy_options: dict[str, str] = field(default_factory=dict) + turns: list[ChatTurn] = field(default_factory=list) + tool_trace: list[dict[str, Any]] = field(default_factory=list) + created_at: float = field(default_factory=time.time) + updated_at: float = field(default_factory=time.time) + + def to_dict(self, *, include_tool_payload: bool = True) -> dict[str, Any]: + return { + "session_id": self.session_id, + "status": self.status, + "requirement_session_id": self.requirement_session_id, + "policy_id": self.policy_id, + "pending_confirmation": self.pending_confirmation, + "pending_option_selection": self.pending_option_selection, + "policy_options": dict(self.policy_options), + "turns": [ + turn.to_dict(include_tool_payload=include_tool_payload) for turn in self.turns + ], + "tool_trace": list(self.tool_trace), + "created_at": round(self.created_at, 4), + "updated_at": round(self.updated_at, 4), + } diff --git a/src/tianjun/chat/runtime.py b/src/tianjun/chat/runtime.py index a312b30..ea262e1 100644 --- a/src/tianjun/chat/runtime.py +++ b/src/tianjun/chat/runtime.py @@ -4,126 +4,23 @@ import re import time import uuid -from dataclasses import dataclass, field from typing import Any, Callable from ..application.control_plane import CentralControlPlane from ..llm import LLMSettings, OpenAICompatibleClient from ..tools import TianjunToolService -from ..policy.generator import REGION_ALIASES - -StreamEmit = Callable[[dict[str, Any]], None] - -_CONFIRM_WORDS = ("确认", "提交", "同意", "批准", "可以执行", "开始执行", "commit", "approve", "submit", "yes") -_CANCEL_WORDS = ("取消", "先不", "不要提交", "别提交", "stop", "cancel") -_FEEDBACK_WORDS = ( - "太高", - "太慢", - "太贵", - "不满意", - "优化", - "调整", - "换", - "降低", - "提高", - "成本", - "预算", - "延迟", - "时延", - "安全", - "sla", - "qos", - "反馈", +from ..policy.constants import REGION_ALIASES +from .constants import ( + CANCEL_WORDS as _CANCEL_WORDS, + CONFIRM_WORDS as _CONFIRM_WORDS, + FACTOR_LABELS as _FACTOR_LABELS, + FEEDBACK_WORDS as _FEEDBACK_WORDS, + REGION_LABELS as _REGION_LABELS, + WORKLOAD_LABELS as _WORKLOAD_LABELS, ) -_REGION_LABELS = { - "east": "东部区域", - "west": "西部区域", - "south": "华南区域", - "dc1": "DC1", - "dc2": "DC2", - "dc3": "DC3", - "shanghai": "上海", - "beijing": "北京", - "hangzhou": "杭州", - "shenzhen": "深圳", - "guangzhou": "广州", - "dongguan": "东莞", - "chengdu": "成都", - "chongqing": "重庆", - "wuhan": "武汉", - "huizhou": "惠州", - "zhuhai": "珠海", - "foshan": "佛山", - "zhongshan": "中山", -} -_WORKLOAD_LABELS = { - "inference": "推理", - "training": "训练", - "streaming": "流式处理", - "analytics": "分析", - "batch": "批处理", -} -_FACTOR_LABELS = { - "network": "网络质量", - "completion": "任务完成能力", - "performance": "算力性能", - "security": "安全匹配度", - "cost": "成本表现", - "load": "负载余量", - "availability": "可用性", -} - - -@dataclass(slots=True) -class ChatTurn: - role: str - content: str - created_at: float = field(default_factory=time.time) - tool_name: str | None = None - tool_payload: dict[str, Any] | None = None - - def to_dict(self, *, include_tool_payload: bool = True) -> dict[str, Any]: - payload = { - "role": self.role, - "content": self.content, - "created_at": round(self.created_at, 4), - } - if self.tool_name: - payload["tool_name"] = self.tool_name - if include_tool_payload and self.tool_payload is not None: - payload["tool_payload"] = self.tool_payload - return payload - - -@dataclass(slots=True) -class ChatSession: - session_id: str - status: str = "active" - requirement_session_id: str | None = None - policy_id: str | None = None - pending_confirmation: bool = False - pending_option_selection: bool = False - policy_options: dict[str, str] = field(default_factory=dict) - turns: list[ChatTurn] = field(default_factory=list) - tool_trace: list[dict[str, Any]] = field(default_factory=list) - created_at: float = field(default_factory=time.time) - updated_at: float = field(default_factory=time.time) - - def to_dict(self, *, include_tool_payload: bool = True) -> dict[str, Any]: - return { - "session_id": self.session_id, - "status": self.status, - "requirement_session_id": self.requirement_session_id, - "policy_id": self.policy_id, - "pending_confirmation": self.pending_confirmation, - "pending_option_selection": self.pending_option_selection, - "policy_options": dict(self.policy_options), - "turns": [turn.to_dict(include_tool_payload=include_tool_payload) for turn in self.turns], - "tool_trace": list(self.tool_trace), - "created_at": round(self.created_at, 4), - "updated_at": round(self.updated_at, 4), - } +from .models import ChatSession, ChatTurn +StreamEmit = Callable[[dict[str, Any]], None] class ChatRuntime: """Conversation orchestrator for Tianjun's controlled LLM/chat boundary. diff --git a/src/tianjun/cli/commands/serve.py b/src/tianjun/cli/commands/serve.py index cf01640..e2ac857 100644 --- a/src/tianjun/cli/commands/serve.py +++ b/src/tianjun/cli/commands/serve.py @@ -24,6 +24,16 @@ def handle(args: Namespace, app_config: TianjunConfig) -> None: app_config.get("control_plane.heartbeat_timeout_seconds"), default=15.0, )) + lease_timeout = float(first_present( + app_config.get("server.lease_timeout_seconds"), + app_config.get("control_plane.lease_timeout_seconds"), + default=60.0, + )) + lifecycle_sweep_interval = float(first_present( + app_config.get("server.lifecycle_sweep_interval_seconds"), + app_config.get("control_plane.lifecycle_sweep_interval_seconds"), + default=1.0, + )) policy_update_interval = int(first_present( args.policy_update_interval, app_config.get("server.policy_update_interval"), @@ -34,6 +44,7 @@ def handle(args: Namespace, app_config: TianjunConfig) -> None: control_plane = build_control_plane( state_store=state_store, heartbeat_timeout_seconds=heartbeat_timeout, + lease_timeout_seconds=lease_timeout, policy_update_interval=policy_update_interval, model_dir=resolved_model_dir(args, app_config), require_model=require_model(args, app_config), @@ -53,7 +64,13 @@ def handle(args: Namespace, app_config: TianjunConfig) -> None: for task in scenario_tasks(): control_plane.submit_task(task) chat_runtime = ChatRuntime.with_llm_settings(control_plane, resolved_llm_settings(args, app_config)) - server = build_http_server(control_plane, host, port, chat_runtime=chat_runtime) + server = build_http_server( + control_plane, + host, + port, + chat_runtime=chat_runtime, + lifecycle_sweep_interval_seconds=lifecycle_sweep_interval, + ) print(f"Control plane listening on http://{host}:{port}") print(f"Dashboard available at http://{host}:{port}/dashboard") try: diff --git a/src/tianjun/config/schema.py b/src/tianjun/config/schema.py index 37ddf39..f57a49e 100644 --- a/src/tianjun/config/schema.py +++ b/src/tianjun/config/schema.py @@ -9,8 +9,10 @@ "server": { "host": "127.0.0.1", "port": 8024, - "state_db": None, + "state_db": "${TIANJUN_STATE_DIR}/tianjun-state.sqlite", "heartbeat_timeout_seconds": 15.0, + "lease_timeout_seconds": 60.0, + "lifecycle_sweep_interval_seconds": 1.0, "policy_update_interval": 2, }, "scenario": { diff --git a/src/tianjun/domain/batch.py b/src/tianjun/domain/batch.py index 60b7afb..05db974 100644 --- a/src/tianjun/domain/batch.py +++ b/src/tianjun/domain/batch.py @@ -65,6 +65,21 @@ class TaskBatch: created_tick: int = 0 latest_plan_id: str | None = None + @classmethod + def from_dict(cls, payload: dict[str, Any], *, tasks: list[Task]) -> "TaskBatch": + return cls( + batch_id=str(payload["batch_id"]), + client_batch_id=str(payload["client_batch_id"]), + batch_name=str(payload.get("batch_name") or "未命名批次"), + tasks=tasks, + defaults=dict(payload.get("defaults") or {}), + batch_preferences=dict(payload.get("batch_preferences") or {}), + status=BatchStatus(str(payload.get("status") or BatchStatus.VALIDATED.value)), + content_hash=str(payload.get("content_hash") or ""), + created_tick=int(payload.get("created_tick", 0)), + latest_plan_id=payload.get("latest_plan_id"), + ) + def to_dict(self, *, include_tasks: bool = True) -> dict[str, Any]: return { "batch_id": self.batch_id, @@ -106,6 +121,16 @@ class BatchAssignment: predicted_energy_kwh: float = 0.0 predicted_carbon_g: float = 0.0 + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> "BatchAssignment": + return cls( + task_id=str(payload["task_id"]), + node_id=str(payload["node_id"]), + decision=SchedulingDecision.from_dict(dict(payload["decision"])), + predicted_energy_kwh=float(payload.get("predicted_energy_kwh", 0.0)), + predicted_carbon_g=float(payload.get("predicted_carbon_g", 0.0)), + ) + def to_dict(self) -> dict[str, Any]: return { "task_id": self.task_id, @@ -122,6 +147,14 @@ class UnassignedTask: reason: str detail: str = "" + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> "UnassignedTask": + return cls( + task_id=str(payload["task_id"]), + reason=str(payload.get("reason") or ""), + detail=str(payload.get("detail") or ""), + ) + def to_dict(self) -> dict[str, str]: return {"task_id": self.task_id, "reason": self.reason, "detail": self.detail} @@ -151,6 +184,33 @@ class BatchSchedulingPlan: active_objectives: list[str] = field(default_factory=list) status: str = "previewed" + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> "BatchSchedulingPlan": + return cls( + plan_id=str(payload["plan_id"]), + batch_id=str(payload["batch_id"]), + strategy=str(payload.get("strategy") or "B6-hierarchical-batch"), + resource_snapshot_version=int(payload.get("resource_snapshot_version", 0)), + assignments=[BatchAssignment.from_dict(dict(item)) for item in payload.get("task_node_assignments", [])], + unassigned_tasks=[UnassignedTask.from_dict(dict(item)) for item in payload.get("unassigned_tasks", [])], + objective_breakdown={str(key): float(value) for key, value in dict(payload.get("objective_breakdown") or {}).items()}, + predicted_makespan=int(payload.get("predicted_makespan", 0)), + predicted_cost=float(payload.get("predicted_cost", 0.0)), + predicted_energy_kwh=float(payload.get("predicted_energy_kwh", 0.0)), + predicted_carbon_g=float(payload.get("predicted_carbon_g", 0.0)), + predicted_sla_violations=int(payload.get("predicted_sla_violations", 0)), + future_fit_before=float(payload.get("future_fit_before", 0.0)), + future_fit_after=float(payload.get("future_fit_after", 0.0)), + decision_time_ms=float(payload.get("decision_time_ms", 0.0)), + group_objective_breakdown={str(key): float(value) for key, value in dict(payload.get("group_objective_breakdown") or {}).items()}, + group_weights={str(key): float(value) for key, value in dict(payload.get("group_weights") or {}).items()}, + plan_utility=float(payload.get("plan_utility", 0.0)), + security_risk_penalty=float(payload.get("security_risk_penalty", 0.0)), + objective_hierarchy_version=str(payload.get("objective_hierarchy_version") or "flat-ten-v1"), + active_objectives=[str(item) for item in payload.get("active_objectives", [])], + status=str(payload.get("status") or "previewed"), + ) + def to_dict(self) -> dict[str, Any]: return { "plan_id": self.plan_id, @@ -190,6 +250,20 @@ class ReservationLedger: resource_snapshot_version: int reservations: dict[str, ResourceVector] = field(default_factory=dict) + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> "ReservationLedger": + return cls( + plan_id=str(payload["plan_id"]), + resource_snapshot_version=int(payload.get("resource_snapshot_version", 0)), + reservations={ + str(node_id): ResourceVector(**{ + key: float(value) for key, value in dict(demand).items() + if key in ResourceVector.__dataclass_fields__ + }) + for node_id, demand in dict(payload.get("reservations") or {}).items() + }, + ) + def reserve(self, node_id: str, demand: ResourceVector) -> None: self.reservations[node_id] = self.reservations.get(node_id, ResourceVector()) + demand diff --git a/src/tianjun/domain/decision.py b/src/tianjun/domain/decision.py index 1170f07..41582a0 100644 --- a/src/tianjun/domain/decision.py +++ b/src/tianjun/domain/decision.py @@ -20,6 +20,22 @@ class SchedulingDecision: explanation: str network_snapshot: dict[str, Any] = field(default_factory=dict) + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> "SchedulingDecision": + return cls( + task_id=str(payload["task_id"]), + node_id=str(payload["node_id"]), + total_score=float(payload.get("total_score", 0.0)), + metric_scores={str(key): float(value) for key, value in dict(payload.get("metric_scores") or {}).items()}, + raw_metrics=dict(payload.get("raw_metrics") or {}), + weights={str(key): float(value) for key, value in dict(payload.get("weights") or {}).items()}, + predicted_start_tick=int(payload.get("predicted_start_tick", 0)), + predicted_finish_tick=int(payload.get("predicted_finish_tick", 0)), + predicted_cost=float(payload.get("predicted_cost", 0.0)), + explanation=str(payload.get("explanation") or ""), + network_snapshot=dict(payload.get("network_snapshot") or {}), + ) + def to_dict(self) -> dict[str, Any]: return { "task_id": self.task_id, diff --git a/src/tianjun/interfaces/dashboard/static/css/nav.css b/src/tianjun/interfaces/dashboard/static/css/nav.css index 9f9dfc5..72b5996 100644 --- a/src/tianjun/interfaces/dashboard/static/css/nav.css +++ b/src/tianjun/interfaces/dashboard/static/css/nav.css @@ -80,7 +80,3 @@ .tab-btn { height: 34px; border-radius: var(--radius-sm); } .alert-banner { top: 88px; } } - -@media (max-width: 480px) { - .topnav-status .badge { max-width: 8.5rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -} diff --git a/src/tianjun/interfaces/dashboard/static/js/api.js b/src/tianjun/interfaces/dashboard/static/js/api.js index bc3b25e..f7f1da4 100644 --- a/src/tianjun/interfaces/dashboard/static/js/api.js +++ b/src/tianjun/interfaces/dashboard/static/js/api.js @@ -1,3 +1,5 @@ +import { requestJson, responseError } from "./request.js"; + const BASE = ""; export async function fetchReport(view = "summary", options = {}) { @@ -52,28 +54,9 @@ export async function importTaskBatch(file) { } async function _get(path, signal) { - const r = await fetch(BASE + path, { signal }); - if (!r.ok) throw await responseError(r, `GET ${path}`); - return r.json(); + return requestJson(BASE + path, { signal }); } async function _post(path, body) { - const r = await fetch(BASE + path, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!r.ok) throw await responseError(r, `POST ${path}`); - return r.json(); -} - -async function responseError(response, operation) { - let detail = ""; - try { - const payload = await response.json(); - detail = payload.error || payload.message || payload.validation?.errors?.[0]?.reason || JSON.stringify(payload); - } catch (_) { - detail = await response.text(); - } - return new Error(`${operation} -> ${response.status}${detail ? `: ${detail}` : ""}`); + return requestJson(BASE + path, { method: "POST", body }); } diff --git a/src/tianjun/interfaces/dashboard/static/js/pages/topology.js b/src/tianjun/interfaces/dashboard/static/js/pages/topology.js index b6e4006..2bef06c 100644 --- a/src/tianjun/interfaces/dashboard/static/js/pages/topology.js +++ b/src/tianjun/interfaces/dashboard/static/js/pages/topology.js @@ -1,4 +1,5 @@ import { renderTopology as renderTopologyCanvas } from "../topology.js"; +import { carbonSourceSummary, loadSourceSummary, sourceLabel } from "../topology-data.js"; import { escapeHtml, fmt } from "../utils.js"; const topologyLayers = new Set(["network", "load", "carbon"]); @@ -70,8 +71,8 @@ function renderLayerSummary(report) { const nodes = report.nodes ?? []; if (activeLayer === "load") { const groups = groupNodes(nodes, (node) => dcKey(node)); - target.innerHTML = `
RESOURCE LAYER

数据中心负载

实时遥测
-

颜色取 CPU、内存、GPU 三项中的最高利用率:低于 60% 为充足,60%–79% 需观察,80% 以上为热点。

+ target.innerHTML = `
RESOURCE LAYER

数据中心负载

${escapeHtml(loadSourceSummary(nodes))}
+

优先使用节点遥测;无遥测时使用任务分配量估算。颜色取 CPU、内存、GPU 中的最高利用率,并在每个数据中心标明来源。

${["dc1", "dc2", "dc3"].map((key) => renderLoadSummary(key, groups.get(key) ?? [])).join("")}
${renderHeatLegend("负载", "%")}`; return; @@ -80,16 +81,17 @@ function renderLayerSummary(report) { const sites = new Map(); for (const node of nodes) { const key = node.site_id || node.region || "unknown"; - if (!sites.has(key)) sites.set(key, { nodes: 0, pue: 0, ci: 0, power: 0, dc: dcKey(node) }); + if (!sites.has(key)) sites.set(key, { nodes: 0, pue: 0, ci: 0, power: 0, dc: dcKey(node), sources: new Set() }); const item = sites.get(key); item.nodes += 1; item.pue += Number(node.carbon_profile?.pue || 1); item.ci += carbonIntensity(node); item.power += Number(node.current_power_w || 0); + item.sources.add(node.carbon_data_source || "configured_profile"); } const sortedSites = Array.from(sites.entries()).sort((left, right) => (left[1].ci / left[1].nodes) - (right[1].ci / right[1].nodes)); - target.innerHTML = `
CARBON LAYER

站点碳强度

低碳优先
-

颜色依据实时 CI:300 g/kWh 以下为低碳,301–450 为中等,超过 450 为高碳;列表按 CI 从低到高排列。

+ target.innerHTML = `
CARBON LAYER

站点碳强度

${escapeHtml(carbonSourceSummary(nodes))}
+

CI 可能来自实时信号、CloudSim 模拟或配置曲线;300 g/kWh 以下为低碳,301–450 为中等,超过 450 为高碳。

${sortedSites.map(([site, item], index) => renderCarbonSummary(site, item, index === 0)).join("") || `

等待节点能源遥测

`}
${renderHeatLegend("CI", "g/kWh")}`; } @@ -99,8 +101,9 @@ function renderLoadSummary(key, nodes) { const value = Math.max(metrics.cpu, metrics.memory, metrics.gpu); const level = heatLevel(value, 60, 80); const state = { low: "容量充足", medium: "需观察", high: "资源热点" }[level]; + const source = loadSourceSummary(nodes); return `
-
${escapeHtml(key.toUpperCase())}${escapeHtml(state)}
+
${escapeHtml(key.toUpperCase())}${escapeHtml(`${state} · ${source}`)}
CPU${fmt(metrics.cpu, 1)}%内存${fmt(metrics.memory, 1)}%GPU${fmt(metrics.gpu, 1)}%任务${metrics.tasks}
`; } @@ -110,8 +113,9 @@ function renderCarbonSummary(site, item, recommended) { const level = heatLevel(ci, 301, 451); const state = { low: "低碳", medium: "中等", high: "高碳" }[level]; const dcLabel = item.dc && item.dc !== "unknown" ? item.dc.toUpperCase() : displayDcLabel(site); + const source = sourceLabel(Array.from(item.sources), "carbon"); return `
-
${escapeHtml(dcLabel)}${recommended ? "推荐 · " : ""}${state}
+
${escapeHtml(dcLabel)}${escapeHtml(`${recommended ? "推荐 · " : ""}${state} · ${source}`)}
CI${fmt(ci, 1)} g/kWhPUE${fmt(item.pue / item.nodes, 2)}功率${fmt(item.power, 1)} W
`; } @@ -161,7 +165,8 @@ function decorateLayerTarget(element, nodes) { } element.classList.add("layer-heat-target", `heat-${level}`); element.dataset.layerValue = label; - element.title = `${element.title || element.textContent.trim()} / ${label}`; + const source = activeLayer === "load" ? loadSourceSummary(nodes) : carbonSourceSummary(nodes); + element.title = `${element.title || element.textContent.trim()} / ${label} / ${source}`; } function aggregateNodeMetrics(nodes) { diff --git a/src/tianjun/interfaces/dashboard/static/js/request.js b/src/tianjun/interfaces/dashboard/static/js/request.js new file mode 100644 index 0000000..bd9a761 --- /dev/null +++ b/src/tianjun/interfaces/dashboard/static/js/request.js @@ -0,0 +1,52 @@ +export class RequestTimeoutError extends Error { + constructor(operation, timeoutMs) { + super(`${operation} 请求超时(${timeoutMs}ms)`); + this.name = "RequestTimeoutError"; + } +} + +export async function requestJson(path, options = {}) { + const operation = `${options.method || "GET"} ${path}`; + const timeoutMs = Math.max(100, Number(options.timeoutMs ?? 8000)); + const controller = new AbortController(); + const upstream = options.signal; + const relayAbort = () => controller.abort(upstream.reason); + if (upstream?.aborted) relayAbort(); + else upstream?.addEventListener("abort", relayAbort, { once: true }); + const timer = setTimeout( + () => controller.abort(new RequestTimeoutError(operation, timeoutMs)), + timeoutMs, + ); + try { + const response = await (options.fetchImpl || fetch)(path, { + method: options.method || "GET", + headers: options.body === undefined ? options.headers : { + "Content-Type": "application/json", + ...options.headers, + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + signal: controller.signal, + }); + if (!response.ok) throw await responseError(response, operation); + return response.json(); + } catch (error) { + if (controller.signal.reason instanceof RequestTimeoutError) { + throw controller.signal.reason; + } + throw error; + } finally { + clearTimeout(timer); + upstream?.removeEventListener("abort", relayAbort); + } +} + +export async function responseError(response, operation) { + let detail = ""; + try { + const payload = await response.json(); + detail = payload.error || payload.message || payload.validation?.errors?.[0]?.reason || JSON.stringify(payload); + } catch (_) { + detail = await response.text(); + } + return new Error(`${operation} -> ${response.status}${detail ? `: ${detail}` : ""}`); +} diff --git a/src/tianjun/interfaces/dashboard/static/js/topology-data.js b/src/tianjun/interfaces/dashboard/static/js/topology-data.js new file mode 100644 index 0000000..0d36fa3 --- /dev/null +++ b/src/tianjun/interfaces/dashboard/static/js/topology-data.js @@ -0,0 +1,29 @@ +export function loadSourceSummary(nodes) { + return sourceLabel(nodes.map((node) => node.resource_load_source || "unavailable"), "load"); +} + +export function carbonSourceSummary(nodes) { + return sourceLabel(nodes.map((node) => node.carbon_data_source || "configured_profile"), "carbon"); +} + +export function sourceLabel(rawKinds, layer) { + const kinds = new Set(rawKinds.filter(Boolean)); + if (!kinds.size || (kinds.size === 1 && kinds.has("unavailable"))) return "暂无数据"; + kinds.delete("unavailable"); + if (kinds.size > 1) return "混合来源"; + const kind = Array.from(kinds)[0]; + const labels = layer === "carbon" + ? { + live_signal: "实时碳信号", + simulated_signal: "CloudSim 模拟", + simulated_profile: "模拟曲线", + configured_profile: "配置曲线", + } + : { + live_telemetry: "实时遥测", + simulated_telemetry: "CloudSim 模拟", + task_progress_estimate: "进度估算", + allocation_estimate: "分配估算", + }; + return labels[kind] || "来源未标注"; +} diff --git a/src/tianjun/interfaces/dashboard/static/js/topology-geometry.js b/src/tianjun/interfaces/dashboard/static/js/topology-geometry.js new file mode 100644 index 0000000..01d3ce5 --- /dev/null +++ b/src/tianjun/interfaces/dashboard/static/js/topology-geometry.js @@ -0,0 +1,94 @@ +function top(box) { return { x: box.x, y: box.top }; } +function bottom(box) { return { x: box.x, y: box.bottom }; } +function left(box) { return { x: box.left, y: box.y }; } +function right(box) { return { x: box.right, y: box.y }; } + +function elementBox(element, stageRect) { + const rect = element.getBoundingClientRect(); + return { + left: rect.left - stageRect.left, + right: rect.right - stageRect.left, + top: rect.top - stageRect.top, + bottom: rect.bottom - stageRect.top, + x: rect.left - stageRect.left + rect.width / 2, + y: rect.top - stageRect.top + rect.height / 2, + }; +} + +function smartAnchor(from, to) { + const dx = to.x - from.x; + const dy = to.y - from.y; + if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? right(from) : left(from); + return dy > 0 ? bottom(from) : top(from); +} + +function labelPoint(item, start, end, lane) { + if (item.labelAnchor === "left") return { x: start.x - 62, y: (start.y + end.y) / 2 }; + if (item.labelAnchor === "right") return { x: lane + 28, y: (start.y + end.y) / 2 }; + if (item.labelAnchor === "below") return { x: (start.x + end.x) / 2, y: start.y + 42 }; + return { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 }; +} + +export function measuredPath(item, sourceElement, targetElement, stageRect) { + const source = elementBox(sourceElement, stageRect); + const target = elementBox(targetElement, stageRect); + const pair = `${item.source}-${item.target}`; + const reversePair = `${item.target}-${item.source}`; + const verticalPairs = new Set([ + "border1-pe1", "pe1-dc1", "border2-pe2", "pe2-dc2", "border3-pe3", "pe3-dc3", + ]); + const horizontalPairs = new Set(["pe1-pe2", "pe2-pe3"]); + const accessPairs = new Set([ + "user-access-border1", "user-access-border2", "user-access-border3", + ]); + + if (accessPairs.has(pair) || accessPairs.has(reversePair)) { + const sourceIsAccess = item.source === "user-access"; + const accessBox = sourceIsAccess ? source : target; + const borderBox = sourceIsAccess ? target : source; + const start = bottom(accessBox); + const end = top(borderBox); + const midY = start.y + Math.max(20, (end.y - start.y) * 0.48); + return { + path: `M ${start.x} ${start.y} C ${start.x} ${midY}, ${end.x} ${midY}, ${end.x} ${end.y}`, + label: { x: (start.x + end.x) / 2, y: midY - 10 }, + }; + } + + if (verticalPairs.has(pair) || verticalPairs.has(reversePair)) { + const sourceAboveTarget = source.y <= target.y; + const start = sourceAboveTarget ? bottom(source) : top(source); + const end = sourceAboveTarget ? top(target) : bottom(target); + return { + path: `M ${start.x} ${start.y} L ${end.x} ${end.y}`, + label: labelPoint(item, start, end, start.x), + }; + } + + if (horizontalPairs.has(pair) || horizontalPairs.has(reversePair)) { + const sourceLeftOfTarget = source.x < target.x; + const start = sourceLeftOfTarget ? right(source) : left(source); + const end = sourceLeftOfTarget ? left(target) : right(target); + return { + path: `M ${start.x} ${start.y} L ${end.x} ${end.y}`, + label: { x: (start.x + end.x) / 2, y: start.y + 42 }, + }; + } + + const start = smartAnchor(source, target); + const end = smartAnchor(target, source); + const midX = (start.x + end.x) / 2; + const bendY = item.type.includes("access") + ? Math.min(start.y, end.y) + 70 + : (start.y + end.y) / 2; + const path = item.type.includes("branch") + ? `M ${start.x} ${start.y} C ${midX + 40} ${start.y}, ${midX + 40} ${end.y}, ${end.x} ${end.y}` + : `M ${start.x} ${start.y} C ${midX} ${bendY}, ${midX} ${bendY}, ${end.x} ${end.y}`; + return { + path, + label: { + x: (start.x + end.x) / 2 + (item.labelAnchor === "right" ? 48 : 0), + y: (start.y + end.y) / 2 - 16, + }, + }; +} diff --git a/src/tianjun/interfaces/dashboard/static/js/topology-resource.js b/src/tianjun/interfaces/dashboard/static/js/topology-resource.js new file mode 100644 index 0000000..dfd76e6 --- /dev/null +++ b/src/tianjun/interfaces/dashboard/static/js/topology-resource.js @@ -0,0 +1,154 @@ +export function latestBy(items, key) { + return items.filter(Boolean).sort((a, b) => Number(a?.[key] ?? 0) - Number(b?.[key] ?? 0)).at(-1); +} + +export function firstNumber(...values) { + for (const value of values) { + if (value === null || value === undefined || value === "") continue; + const numeric = Number(value); + if (Number.isFinite(numeric)) return numeric; + } + return null; +} + +export function percentFrom(...values) { + const value = firstNumber(...values); + if (value == null) return null; + return Math.round(value <= 1 ? value * 100 : value); +} + +export function ratioPercent(used, total) { + const safeTotal = Number(total); + if (!Number.isFinite(safeTotal) || safeTotal <= 0) return 0; + return Math.round(Math.max(0, Number(used) || 0) / safeTotal * 100); +} + +export function resourceUsed(node, key) { + const capacity = Number(node?.capacity?.[key] ?? 0); + const available = Number(node?.available?.[key] ?? capacity); + return Math.max(0, capacity - available); +} + +export function parseDciNode(nodeId = "", node = {}) { + const match = String(nodeId).match(/^dci-dc(\d+)-([a-z]+)-vm-(\d+)$/i); + const dcKey = match ? `dc${match[1]}` : String(node.region ?? "").match(/^dc\d+$/i)?.[0]?.toLowerCase(); + return { + dcKey, + location: String(match?.[2] ?? node.location ?? "").toLowerCase(), + vmIndex: match ? Number(match[3]) : null, + }; +} + +export function aggregateResources(nodes, scope) { + const result = new Map(); + for (const node of nodes) { + const parsed = parseDciNode(node.node_id, node); + if (!parsed.dcKey || (scope === "zone" && !parsed.location)) continue; + const key = scope === "zone" ? `${parsed.dcKey}:${parsed.location}` : parsed.dcKey; + const bucket = result.get(key) ?? { + nodes: 0, + cpuUsed: 0, + cpuTotal: 0, + memoryUsed: 0, + memoryTotal: 0, + gpuUsed: 0, + gpuTotal: 0, + cpuTelemetry: 0, + cpuSamples: 0, + memoryTelemetry: 0, + memorySamples: 0, + gpuTelemetry: 0, + gpuSamples: 0, + tasks: 0, + }; + bucket.nodes += 1; + bucket.cpuTotal += Number(node?.capacity?.cpu ?? 0); + bucket.cpuUsed += resourceUsed(node, "cpu"); + bucket.memoryTotal += Number(node?.capacity?.memory ?? 0); + bucket.memoryUsed += resourceUsed(node, "memory"); + bucket.gpuTotal += Number(node?.capacity?.gpu ?? 0); + bucket.gpuUsed += resourceUsed(node, "gpu"); + const cpuTelemetry = percentFrom(node.runtime_utilization?.cpu, node.runtime_telemetry?.cpu); + const memoryTelemetry = percentFrom(node.runtime_utilization?.memory, node.runtime_telemetry?.memory); + const gpuTelemetry = percentFrom(node.runtime_utilization?.gpu, node.runtime_telemetry?.gpu); + if (cpuTelemetry != null) { + bucket.cpuTelemetry += cpuTelemetry; + bucket.cpuSamples += 1; + } + if (memoryTelemetry != null) { + bucket.memoryTelemetry += memoryTelemetry; + bucket.memorySamples += 1; + } + if (gpuTelemetry != null) { + bucket.gpuTelemetry += gpuTelemetry; + bucket.gpuSamples += 1; + } + bucket.tasks += node.active_task_ids?.length ?? (Array.isArray(node.running_tasks) ? node.running_tasks.length : 0); + result.set(key, bucket); + } + for (const bucket of result.values()) { + bucket.cpuPercent = bucket.cpuSamples ? Math.round(bucket.cpuTelemetry / bucket.cpuSamples) : ratioPercent(bucket.cpuUsed, bucket.cpuTotal); + bucket.memoryPercent = bucket.memorySamples ? Math.round(bucket.memoryTelemetry / bucket.memorySamples) : ratioPercent(bucket.memoryUsed, bucket.memoryTotal); + bucket.gpuUsed = Math.round(bucket.gpuUsed); + bucket.gpuTotal = Math.round(bucket.gpuTotal); + bucket.gpuPercent = bucket.gpuSamples ? Math.round(bucket.gpuTelemetry / bucket.gpuSamples) : ratioPercent(bucket.gpuUsed, bucket.gpuTotal); + } + return result; +} + +export function firstOnlineNodeId(nodes) { + return nodes.find((node) => node.online !== false)?.node_id ?? ""; +} + +export function zoneAggregate(nodes, report, metric) { + const result = new Map(); + for (const node of nodes) { + const parsed = parseDciNode(node.node_id, node); + if (!parsed.location) continue; + if (metric === "tasks") { + const count = node.active_task_ids?.length ?? (Array.isArray(node.running_tasks) ? node.running_tasks.length : 0); + result.set(parsed.location, (result.get(parsed.location) ?? 0) + count); + } else if (metric === "cpu") { + const value = percentFrom(node.runtime_utilization?.cpu, node.runtime_telemetry?.cpu, node.cpu_utilization, node.used_cpu_ratio); + if (value != null) result.set(parsed.location, Math.max(result.get(parsed.location) ?? 0, value)); + } else if (metric === "memory") { + const value = percentFrom(node.runtime_utilization?.memory, node.runtime_telemetry?.memory, node.memory_utilization, node.used_memory_ratio); + if (value != null) result.set(parsed.location, Math.max(result.get(parsed.location) ?? 0, value)); + } else if (metric === "gpu") { + const current = result.get(parsed.location) ?? { used: 0, total: 0, percent: 0 }; + current.used += resourceUsed(node, "gpu"); + current.total += Number(node?.capacity?.gpu ?? 0); + current.used = Math.round(current.used); + current.total = Math.round(current.total); + current.percent = ratioPercent(current.used, current.total); + result.set(parsed.location, current); + } + } + for (const run of report?.active_runs ?? []) { + const parsed = parseDciNode(run.node_id, {}); + if (parsed.location) result.set(parsed.location, Math.max(1, result.get(parsed.location) ?? 0)); + } + return result; +} + +export function normalizeGpu(value) { + if (!value || typeof value !== "object") return { used: 0, total: 0, percent: 0 }; + const used = Math.round(Number(value.used ?? 0)); + const total = Math.round(Number(value.total ?? 0)); + return { used, total, percent: ratioPercent(used, total) }; +} + +export function gpuSummary(value) { + if (value && typeof value === "object" && ("gpuUsed" in value || "gpuTotal" in value)) { + const used = Math.round(Number(value.gpuUsed ?? 0)); + const total = Math.round(Number(value.gpuTotal ?? 0)); + return total > 0 ? `${used}/${total} (${ratioPercent(used, total)}%)` : "0/0"; + } + const gpu = normalizeGpu(value?.gpu ?? value); + return gpu.total > 0 ? `${gpu.used}/${gpu.total} (${gpu.percent}%)` : "0/0"; +} + +export function nodeName(leafId, location) { + const suffix = leafId.endsWith("a") || leafId.endsWith("b") ? "1" : "2"; + return `Leaf-${String(location || "zone").toUpperCase()}-${suffix}`; +} diff --git a/src/tianjun/interfaces/dashboard/static/js/topology.js b/src/tianjun/interfaces/dashboard/static/js/topology.js index 0e4910f..1642930 100644 --- a/src/tianjun/interfaces/dashboard/static/js/topology.js +++ b/src/tianjun/interfaces/dashboard/static/js/topology.js @@ -1,4 +1,19 @@ import { escapeHtml } from "./utils.js"; +import { measuredPath } from "./topology-geometry.js"; +import { + aggregateResources, + firstNumber, + firstOnlineNodeId, + gpuSummary, + latestBy, + nodeName, + normalizeGpu, + parseDciNode, + percentFrom, + ratioPercent, + resourceUsed, + zoneAggregate, +} from "./topology-resource.js"; let activeTopologyKey = "global"; let selectedDetail = null; @@ -314,7 +329,7 @@ function updateLiveTopology(report) { globalTopology.currentRoute = []; globalTopology.currentPathText = "当前无活动调度路径"; globalTopology.footer = [ - "实时来源:在线节点 inventory", + "数据来源:节点 inventory(当前与最近状态)", `在线节点:${(report?.nodes ?? []).filter((node) => node.online !== false).length} 个`, "调度状态:当前无活动任务", ]; @@ -322,7 +337,7 @@ function updateLiveTopology(report) { topology.currentRoute = []; topology.currentPath = "当前无活动调度路径"; topology.internalPath = "当前无活动调度路径"; - topology.footer = ["当前无活动调度路径", `${topology.dcName} 资源遥测保持可用`, "路径高亮将在任务调度后恢复"]; + topology.footer = ["当前无活动调度路径", `${topology.dcName} 资源视图保留最近数据`, "路径高亮将在任务调度后恢复"]; } return; } @@ -331,7 +346,7 @@ function updateLiveTopology(report) { globalTopology.currentRoute = livePathContext.activityState === "idle" ? [] : targetRoute.nodes; globalTopology.currentPathText = livePathContext.globalPathText; globalTopology.footer = [ - `实时来源:${livePathContext.sourceKind} / tick ${livePathContext.tick ?? "--"}`, + `数据来源:${livePathContext.sourceKind} / tick ${livePathContext.tick ?? "--"}`, `目标节点:${livePathContext.nodeId}`, `链路画像:${livePathContext.latencyText} / 风险 ${livePathContext.riskText}`, ]; @@ -531,166 +546,11 @@ function buildLivePathContext(report) { }; } -function latestBy(items, key) { - return items.filter(Boolean).sort((a, b) => Number(a?.[key] ?? 0) - Number(b?.[key] ?? 0)).at(-1); -} - -function firstNumber(...values) { - for (const value of values) { - if (value === null || value === undefined || value === "") continue; - const numeric = Number(value); - if (Number.isFinite(numeric)) return numeric; - } - return null; -} - -function percentFrom(...values) { - const value = firstNumber(...values); - if (value == null) return null; - return Math.round(value <= 1 ? value * 100 : value); -} - -function ratioPercent(used, total) { - const safeTotal = Number(total); - if (!Number.isFinite(safeTotal) || safeTotal <= 0) return 0; - return Math.round(Math.max(0, Number(used) || 0) / safeTotal * 100); -} - -function resourceUsed(node, key) { - const capacity = Number(node?.capacity?.[key] ?? 0); - const available = Number(node?.available?.[key] ?? capacity); - return Math.max(0, capacity - available); -} - -function aggregateResources(nodes, scope) { - const result = new Map(); - for (const node of nodes) { - const parsed = parseDciNode(node.node_id, node); - if (!parsed.dcKey || (scope === "zone" && !parsed.location)) continue; - const key = scope === "zone" ? `${parsed.dcKey}:${parsed.location}` : parsed.dcKey; - const bucket = result.get(key) ?? { - nodes: 0, - cpuUsed: 0, - cpuTotal: 0, - memoryUsed: 0, - memoryTotal: 0, - gpuUsed: 0, - gpuTotal: 0, - cpuTelemetry: 0, - cpuSamples: 0, - memoryTelemetry: 0, - memorySamples: 0, - gpuTelemetry: 0, - gpuSamples: 0, - tasks: 0, - }; - bucket.nodes += 1; - bucket.cpuTotal += Number(node?.capacity?.cpu ?? 0); - bucket.cpuUsed += resourceUsed(node, "cpu"); - bucket.memoryTotal += Number(node?.capacity?.memory ?? 0); - bucket.memoryUsed += resourceUsed(node, "memory"); - bucket.gpuTotal += Number(node?.capacity?.gpu ?? 0); - bucket.gpuUsed += resourceUsed(node, "gpu"); - const cpuTelemetry = percentFrom(node.runtime_utilization?.cpu, node.runtime_telemetry?.cpu); - const memoryTelemetry = percentFrom(node.runtime_utilization?.memory, node.runtime_telemetry?.memory); - const gpuTelemetry = percentFrom(node.runtime_utilization?.gpu, node.runtime_telemetry?.gpu); - if (cpuTelemetry != null) { - bucket.cpuTelemetry += cpuTelemetry; - bucket.cpuSamples += 1; - } - if (memoryTelemetry != null) { - bucket.memoryTelemetry += memoryTelemetry; - bucket.memorySamples += 1; - } - if (gpuTelemetry != null) { - bucket.gpuTelemetry += gpuTelemetry; - bucket.gpuSamples += 1; - } - bucket.tasks += node.active_task_ids?.length ?? (Array.isArray(node.running_tasks) ? node.running_tasks.length : 0); - result.set(key, bucket); - } - for (const bucket of result.values()) { - bucket.cpuPercent = bucket.cpuSamples ? Math.round(bucket.cpuTelemetry / bucket.cpuSamples) : ratioPercent(bucket.cpuUsed, bucket.cpuTotal); - bucket.memoryPercent = bucket.memorySamples ? Math.round(bucket.memoryTelemetry / bucket.memorySamples) : ratioPercent(bucket.memoryUsed, bucket.memoryTotal); - bucket.gpuUsed = Math.round(bucket.gpuUsed); - bucket.gpuTotal = Math.round(bucket.gpuTotal); - bucket.gpuPercent = bucket.gpuSamples ? Math.round(bucket.gpuTelemetry / bucket.gpuSamples) : ratioPercent(bucket.gpuUsed, bucket.gpuTotal); - } - return result; -} - -function parseDciNode(nodeId = "", node = {}) { - const match = String(nodeId).match(/^dci-dc(\d+)-([a-z]+)-vm-(\d+)$/i); - const dcKey = match ? `dc${match[1]}` : String(node.region ?? "").match(/^dc\d+$/i)?.[0]?.toLowerCase(); - return { - dcKey, - location: String(match?.[2] ?? node.location ?? "").toLowerCase(), - vmIndex: match ? Number(match[3]) : null, - }; -} - function firstZoneModel(dcKey) { const zones = Object.values(dcZoneModel[dcKey]?.zones ?? {}); return zones[0] ?? { leaf: "leaf-a", cluster: "cluster-a", label: "资源区", clusterName: "计算集群" }; } -function firstOnlineNodeId(nodes) { - return nodes.find((node) => node.online !== false)?.node_id ?? ""; -} - -function zoneAggregate(nodes, report, metric) { - const result = new Map(); - for (const node of nodes) { - const parsed = parseDciNode(node.node_id, node); - if (!parsed.location) continue; - if (metric === "tasks") { - const count = node.active_task_ids?.length ?? (Array.isArray(node.running_tasks) ? node.running_tasks.length : 0); - result.set(parsed.location, (result.get(parsed.location) ?? 0) + count); - } else if (metric === "cpu") { - const value = percentFrom(node.runtime_utilization?.cpu, node.runtime_telemetry?.cpu, node.cpu_utilization, node.used_cpu_ratio); - if (value != null) result.set(parsed.location, Math.max(result.get(parsed.location) ?? 0, value)); - } else if (metric === "memory") { - const value = percentFrom(node.runtime_utilization?.memory, node.runtime_telemetry?.memory, node.memory_utilization, node.used_memory_ratio); - if (value != null) result.set(parsed.location, Math.max(result.get(parsed.location) ?? 0, value)); - } else if (metric === "gpu") { - const current = result.get(parsed.location) ?? { used: 0, total: 0, percent: 0 }; - current.used += resourceUsed(node, "gpu"); - current.total += Number(node?.capacity?.gpu ?? 0); - current.used = Math.round(current.used); - current.total = Math.round(current.total); - current.percent = ratioPercent(current.used, current.total); - result.set(parsed.location, current); - } - } - for (const run of report?.active_runs ?? []) { - const parsed = parseDciNode(run.node_id, {}); - if (parsed.location) result.set(parsed.location, Math.max(1, result.get(parsed.location) ?? 0)); - } - return result; -} - -function normalizeGpu(value) { - if (!value || typeof value !== "object") return { used: 0, total: 0, percent: 0 }; - const used = Math.round(Number(value.used ?? 0)); - const total = Math.round(Number(value.total ?? 0)); - return { used, total, percent: ratioPercent(used, total) }; -} - -function gpuSummary(value) { - if (value && typeof value === "object" && ("gpuUsed" in value || "gpuTotal" in value)) { - const used = Math.round(Number(value.gpuUsed ?? 0)); - const total = Math.round(Number(value.gpuTotal ?? 0)); - return total > 0 ? `${used}/${total} (${ratioPercent(used, total)}%)` : "0/0"; - } - const gpu = normalizeGpu(value?.gpu ?? value); - return gpu.total > 0 ? `${gpu.used}/${gpu.total} (${gpu.percent}%)` : "0/0"; -} - -function nodeName(leafId, location) { - const suffix = leafId.endsWith("a") || leafId.endsWith("b") ? "1" : "2"; - return `Leaf-${String(location || "zone").toUpperCase()}-${suffix}`; -} - function currentTopology() { return activeTopologyKey === "global" ? globalTopology : dcTopologies[activeTopologyKey] ?? globalTopology; } @@ -852,82 +712,6 @@ function drawMeasuredGlobalLinks(container, topology) { }); } -function measuredPath(item, sourceEl, targetEl, stageRect) { - const s = box(sourceEl, stageRect); - const t = box(targetEl, stageRect); - const pair = `${item.source}-${item.target}`; - const reversePair = `${item.target}-${item.source}`; - const verticalPairs = new Set(["border1-pe1", "pe1-dc1", "border2-pe2", "pe2-dc2", "border3-pe3", "pe3-dc3"]); - const horizontalPairs = new Set(["pe1-pe2", "pe2-pe3"]); - const accessPairs = new Set(["user-access-border1", "user-access-border2", "user-access-border3"]); - - if (accessPairs.has(pair) || accessPairs.has(reversePair)) { - const sourceIsAccess = item.source === "user-access"; - const accessBox = sourceIsAccess ? s : t; - const borderBox = sourceIsAccess ? t : s; - const start = bottom(accessBox); - const end = top(borderBox); - const midY = start.y + Math.max(20, (end.y - start.y) * 0.48); - const path = `M ${start.x} ${start.y} C ${start.x} ${midY}, ${end.x} ${midY}, ${end.x} ${end.y}`; - return { path, label: { x: (start.x + end.x) / 2, y: midY - 10 } }; - } - - if (verticalPairs.has(pair) || verticalPairs.has(reversePair)) { - const sourceAboveTarget = s.y <= t.y; - const start = sourceAboveTarget ? bottom(s) : top(s); - const end = sourceAboveTarget ? top(t) : bottom(t); - const path = `M ${start.x} ${start.y} L ${end.x} ${end.y}`; - return { path, label: labelPoint(item, start, end, start.x) }; - } - - if (horizontalPairs.has(pair) || horizontalPairs.has(reversePair)) { - const sourceLeftOfTarget = s.x < t.x; - const start = sourceLeftOfTarget ? right(s) : left(s); - const end = sourceLeftOfTarget ? left(t) : right(t); - return { path: `M ${start.x} ${start.y} L ${end.x} ${end.y}`, label: { x: (start.x + end.x) / 2, y: start.y + 42 } }; - } - - const start = smartAnchor(s, t); - const end = smartAnchor(t, s); - const midX = (start.x + end.x) / 2; - const bendY = item.type.includes("access") ? Math.min(start.y, end.y) + 70 : (start.y + end.y) / 2; - const path = item.type.includes("branch") - ? `M ${start.x} ${start.y} C ${midX + 40} ${start.y}, ${midX + 40} ${end.y}, ${end.x} ${end.y}` - : `M ${start.x} ${start.y} C ${midX} ${bendY}, ${midX} ${bendY}, ${end.x} ${end.y}`; - return { path, label: { x: (start.x + end.x) / 2 + (item.labelAnchor === "right" ? 48 : 0), y: (start.y + end.y) / 2 - 16 } }; -} - -function box(element, stageRect) { - const rect = element.getBoundingClientRect(); - return { - left: rect.left - stageRect.left, - right: rect.right - stageRect.left, - top: rect.top - stageRect.top, - bottom: rect.bottom - stageRect.top, - x: rect.left - stageRect.left + rect.width / 2, - y: rect.top - stageRect.top + rect.height / 2, - }; -} - -function top(b) { return { x: b.x, y: b.top }; } -function bottom(b) { return { x: b.x, y: b.bottom }; } -function left(b) { return { x: b.left, y: b.y }; } -function right(b) { return { x: b.right, y: b.y }; } - -function smartAnchor(from, to) { - const dx = to.x - from.x; - const dy = to.y - from.y; - if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? right(from) : left(from); - return dy > 0 ? bottom(from) : top(from); -} - -function labelPoint(item, start, end, lane) { - if (item.labelAnchor === "left") return { x: start.x - 62, y: (start.y + end.y) / 2 }; - if (item.labelAnchor === "right") return { x: lane + 28, y: (start.y + end.y) / 2 }; - if (item.labelAnchor === "below") return { x: (start.x + end.x) / 2, y: start.y + 42 }; - return { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 }; -} - function renderInternalScene(topology) { return `
diff --git a/src/tianjun/interfaces/http/server.py b/src/tianjun/interfaces/http/server.py index 5243d60..6535cb7 100644 --- a/src/tianjun/interfaces/http/server.py +++ b/src/tianjun/interfaces/http/server.py @@ -12,7 +12,8 @@ from ...application.control_plane import CentralControlPlane from ...application.batch_scheduling_service import BatchRequestError, MAX_BATCH_BYTES -from ...application.dashboard_reporting import dashboard_report_view +from ...application.dashboard_reporting import build_dashboard_report +from ...application.lifecycle import LifecycleSweeper from ...chat import ChatRuntime from ...scenarios import node_from_dict, task_from_dict from ..dashboard.page import render_dashboard_html @@ -22,7 +23,30 @@ LOGGER = logging.getLogger(__name__) -def _public_health_payload(control_plane: CentralControlPlane, chat: ChatRuntime) -> dict[str, Any]: +class TianjunHttpServer(ThreadingHTTPServer): + daemon_threads = True + + def __init__(self, server_address, handler, lifecycle: LifecycleSweeper) -> None: + self.lifecycle = lifecycle + super().__init__(server_address, handler) + + def serve_forever(self, poll_interval: float = 0.5) -> None: + self.lifecycle.start() + try: + super().serve_forever(poll_interval=poll_interval) + finally: + self.lifecycle.stop() + + def server_close(self) -> None: + self.lifecycle.stop() + super().server_close() + + +def _public_health_payload( + control_plane: CentralControlPlane, + chat: ChatRuntime, + lifecycle: LifecycleSweeper | None = None, +) -> dict[str, Any]: model_runtime = dict(control_plane.scheduler.model_runtime.describe()) model_runtime.pop("model_dir", None) trained_models = model_runtime.pop("trained_models", {}) or {} @@ -41,13 +65,29 @@ def _public_health_payload(control_plane: CentralControlPlane, chat: ChatRuntime issues.append("模型运行时不可用") if settings.get("required") and not llm.get("enabled"): issues.append("必需的 LLM 未启用") + persistence = { + "enabled": control_plane.state_store is not None, + "schema_version": None, + "integrity": None, + "writable": None, + } + if control_plane.state_store is not None: + database_readiness = control_plane.state_store.readiness() + persistence.update({ + "schema_version": control_plane.state_store.schema_version, + "integrity": database_readiness.get("integrity"), + "writable": database_readiness.get("writable"), + }) + if not database_readiness.get("ready"): + issues.append("状态数据库不可写或完整性检查失败") return { "status": "ok" if not issues else "degraded", "ready": not issues, "issues": issues, "model_runtime": model_runtime, "chat_runtime": chat_runtime, - "persistence": {"enabled": control_plane.state_store is not None}, + "persistence": persistence, + "lifecycle": None if lifecycle is None else lifecycle.snapshot(), } @@ -57,7 +97,8 @@ def build_http_server( port: int, *, chat_runtime: ChatRuntime | None = None, -) -> ThreadingHTTPServer: + lifecycle_sweep_interval_seconds: float = 1.0, +) -> TianjunHttpServer: chat = chat_runtime or ChatRuntime(control_plane) class ControlPlaneHandler(BaseHTTPRequestHandler): server_version = "TianjunControlPlane/0.3" @@ -83,8 +124,8 @@ def do_GET(self) -> None: # noqa: N802 limit = int(query.get("limit", ["50"])[0]) self._write_json( 200, - dashboard_report_view( - control_plane.build_report(), + build_dashboard_report( + control_plane, view, cursor=cursor, limit=limit, @@ -92,11 +133,11 @@ def do_GET(self) -> None: # noqa: N802 ) return if path == "/health": - payload = _public_health_payload(control_plane, chat) + payload = _public_health_payload(control_plane, chat, self.server.lifecycle) self._write_json(200, payload) return if path == "/ready": - payload = _public_health_payload(control_plane, chat) + payload = _public_health_payload(control_plane, chat, self.server.lifecycle) self._write_json(200 if payload["ready"] else 503, payload) return if handle_legacy_get(self, path, control_plane, chat): @@ -172,7 +213,10 @@ def do_POST(self) -> None: # noqa: N802 carbon_intensity_g_per_kwh=payload.get("carbon_intensity_g_per_kwh"), carbon_signal_timestamp=payload.get("carbon_signal_timestamp"), runtime_telemetry=payload.get("telemetry"), - telemetry_source=("cloudsim" if payload.get("simulated") else "node_agent"), + telemetry_source=( + payload.get("telemetry_source") + or ("cloudsim" if payload.get("simulated") else "node_agent") + ), simulation_tick=payload.get("sim_tick"), ) self._write_json(200, result) @@ -340,6 +384,13 @@ def do_POST(self) -> None: # noqa: N802 if path == "/leases/next": self._write_json(200, control_plane.request_lease(payload["node_id"])) return + if path == "/leases/ack": + self._write_json(200, control_plane.acknowledge_lease( + node_id=str(payload["node_id"]), + task_id=str(payload["task_id"]), + lease_id=str(payload["lease_id"]), + )) + return if path == "/task-runs/progress": self._write_json( 200, @@ -351,6 +402,7 @@ def do_POST(self) -> None: # noqa: N802 progress=payload.get("progress"), message=payload.get("message"), metrics=payload.get("metrics"), + lease_id=payload.get("lease_id"), ), ) return @@ -379,6 +431,8 @@ def do_POST(self) -> None: # noqa: N802 returncode=payload.get("returncode"), cost=payload.get("cost"), metadata=result_metadata, + lease_id=payload.get("lease_id"), + result_id=payload.get("result_id"), ) self._write_json(200, result) return @@ -550,4 +604,8 @@ def _write_exception(self, exc: Exception) -> None: LOGGER.exception("Unhandled HTTP request error request_id=%s", request_id, exc_info=exc) self._write_json(500, {"error": "internal_error", "request_id": request_id}) - return ThreadingHTTPServer((host, port), ControlPlaneHandler) + lifecycle = LifecycleSweeper( + control_plane, + interval_seconds=lifecycle_sweep_interval_seconds, + ) + return TianjunHttpServer((host, port), ControlPlaneHandler, lifecycle) diff --git a/src/tianjun/policy/constants.py b/src/tianjun/policy/constants.py new file mode 100644 index 0000000..d570467 --- /dev/null +++ b/src/tianjun/policy/constants.py @@ -0,0 +1,42 @@ +from __future__ import annotations + + +REGION_ALIASES = { + "东部区域": "east", "东部": "east", "华东": "east", "华北": "east", + "上海": "east", "杭州": "east", "北京": "east", "天津": "east", + "南京": "east", "苏州": "east", "无锡": "east", "宁波": "east", + "合肥": "east", "济南": "east", "青岛": "east", + "西部区域": "west", "西部": "west", "西南": "west", "成都": "west", + "重庆": "west", "西安": "west", "昆明": "west", "贵阳": "west", + "兰州": "west", "乌鲁木齐": "west", + "华南区域": "south", "华南": "south", "深圳": "south", "广州": "south", + "东莞": "south", "惠州": "south", "珠海": "south", "佛山": "south", + "中山": "south", "厦门": "south", "福州": "south", "南宁": "south", + "海口": "south", "武汉": "wuhan", "华中": "wuhan", + "east": "east", "east china": "east", "shanghai": "east", + "hangzhou": "east", "beijing": "east", "tianjin": "east", + "nanjing": "east", "suzhou": "east", "west": "west", + "chengdu": "west", "chongqing": "west", "cd": "west", "cq": "west", + "south": "south", "south china": "south", "shenzhen": "south", + "guangzhou": "south", "dongguan": "south", "huizhou": "south", + "zhuhai": "south", "foshan": "south", "zhongshan": "south", + "wuhan": "wuhan", +} + +SERVICE_REGION_CODES = {"east", "west", "south", "wuhan"} +GUANGDONG_REGIONS = ["south"] +PRIORITY_VECTOR_KEYS = { + "latency", "cost", "quality", "security", "balance", "fragmentation", + "locality", "network", "carbon", +} +PRIORITY_TO_METRICS = { + "latency": {"performance": 0.52, "completion": 0.18, "network": 0.30}, + "cost": {"cost": 1.0}, + "quality": {"reliability": 0.58, "completion": 0.24, "performance": 0.18}, + "security": {"security": 0.76, "reliability": 0.14, "locality": 0.10}, + "balance": {"balance": 1.0}, + "fragmentation": {"fragmentation": 1.0}, + "locality": {"locality": 1.0}, + "network": {"network": 0.78, "performance": 0.22}, + "carbon": {"carbon": 0.82, "cost": 0.08, "fragmentation": 0.10}, +} diff --git a/src/tianjun/policy/generator.py b/src/tianjun/policy/generator.py index 893811e..55c3872 100644 --- a/src/tianjun/policy/generator.py +++ b/src/tianjun/policy/generator.py @@ -24,98 +24,13 @@ ) from ..domain import ExecutionMode, METRIC_KEYS, Node, ResourceVector, SchedulingDecision, Task, TaskExecutionSpec, clamp from ..scheduling.engine import ClosedLoopAdaptiveScheduler - - -REGION_ALIASES = { - "东部区域": "east", - "东部": "east", - "华东": "east", - "华北": "east", - "上海": "east", - "杭州": "east", - "北京": "east", - "天津": "east", - "南京": "east", - "苏州": "east", - "无锡": "east", - "宁波": "east", - "合肥": "east", - "济南": "east", - "青岛": "east", - "西部区域": "west", - "西部": "west", - "西南": "west", - "成都": "west", - "重庆": "west", - "西安": "west", - "昆明": "west", - "贵阳": "west", - "兰州": "west", - "乌鲁木齐": "west", - "华南区域": "south", - "华南": "south", - "深圳": "south", - "广州": "south", - "东莞": "south", - "惠州": "south", - "珠海": "south", - "佛山": "south", - "中山": "south", - "厦门": "south", - "福州": "south", - "南宁": "south", - "海口": "south", - "武汉": "wuhan", - "华中": "wuhan", - "east": "east", - "east china": "east", - "shanghai": "east", - "hangzhou": "east", - "beijing": "east", - "tianjin": "east", - "nanjing": "east", - "suzhou": "east", - "west": "west", - "chengdu": "west", - "chongqing": "west", - "cd": "west", - "cq": "west", - "south": "south", - "south china": "south", - "shenzhen": "south", - "guangzhou": "south", - "dongguan": "south", - "huizhou": "south", - "zhuhai": "south", - "foshan": "south", - "zhongshan": "south", - "wuhan": "wuhan", -} - -SERVICE_REGION_CODES = {"east", "west", "south", "wuhan"} -GUANGDONG_REGIONS = ["south"] -PRIORITY_VECTOR_KEYS = { - "latency", - "cost", - "quality", - "security", - "balance", - "fragmentation", - "locality", - "network", - "carbon", -} -PRIORITY_TO_METRICS = { - "latency": {"performance": 0.52, "completion": 0.18, "network": 0.30}, - "cost": {"cost": 1.0}, - "quality": {"reliability": 0.58, "completion": 0.24, "performance": 0.18}, - "security": {"security": 0.76, "reliability": 0.14, "locality": 0.10}, - "balance": {"balance": 1.0}, - "fragmentation": {"fragmentation": 1.0}, - "locality": {"locality": 1.0}, - "network": {"network": 0.78, "performance": 0.22}, - "carbon": {"carbon": 0.82, "cost": 0.08, "fragmentation": 0.10}, -} +from .constants import ( + GUANGDONG_REGIONS, + PRIORITY_TO_METRICS, + PRIORITY_VECTOR_KEYS, + REGION_ALIASES, + SERVICE_REGION_CODES, +) class ComputeNetworkPolicyGenerator: diff --git a/src/tianjun/scheduling/engine.py b/src/tianjun/scheduling/engine.py index bf69760..7de1510 100644 --- a/src/tianjun/scheduling/engine.py +++ b/src/tianjun/scheduling/engine.py @@ -1,5 +1,6 @@ from __future__ import annotations +import threading from math import ceil from statistics import mean from typing import Any, Iterable @@ -44,6 +45,7 @@ def __init__( self.policy_state = policy_state self.model_runtime = model_runtime or get_default_model_runtime() self._deterministic_latency_state: dict[str, float] = {} + self._latency_state_lock = threading.Lock() self.physical_topology: PhysicalTopology | None = None def set_physical_topology(self, topology: PhysicalTopology | None) -> None: @@ -784,12 +786,13 @@ def _network_snapshot(self, task: Task, node: Node, topology_nodes: list[Node]) ) robust_stable_latency_ms = max(1.0, predicted_latency_ms + risk_margin_ms) state_key = f"{node.node_id}:{task.network_source() or node.region}:{task.task_type}" - previous_stable_latency = self._deterministic_latency_state.get(state_key) - if previous_stable_latency is None: - stable_latency_ms = robust_stable_latency_ms - else: - stable_latency_ms = (previous_stable_latency * 0.84) + (robust_stable_latency_ms * 0.16) - self._deterministic_latency_state[state_key] = stable_latency_ms + with self._latency_state_lock: + previous_stable_latency = self._deterministic_latency_state.get(state_key) + if previous_stable_latency is None: + stable_latency_ms = robust_stable_latency_ms + else: + stable_latency_ms = (previous_stable_latency * 0.84) + (robust_stable_latency_ms * 0.16) + self._deterministic_latency_state[state_key] = stable_latency_ms risk_factor = 1.0 + (task.network_sensitivity * 0.9) guaranteed_bandwidth_mbps = profile.guaranteed_bandwidth_mbps(risk_factor=risk_factor) diff --git a/src/tianjun/storage/sqlite_schema.py b/src/tianjun/storage/sqlite_schema.py new file mode 100644 index 0000000..35db2d8 --- /dev/null +++ b/src/tianjun/storage/sqlite_schema.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +import sqlite3 +import time +from pathlib import Path + + +SCHEMA_VERSION = 2 + +BASE_SCHEMA_STATEMENTS = ( + """ + CREATE TABLE IF NOT EXISTS control_state ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL, + updated_at REAL NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS nodes ( + node_id TEXT PRIMARY KEY, + payload_json TEXT NOT NULL, + last_heartbeat_at REAL NOT NULL, + updated_at REAL NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS tasks ( + task_id TEXT PRIMARY KEY, + status TEXT NOT NULL, + payload_json TEXT NOT NULL, + updated_at REAL NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS leases ( + task_id TEXT PRIMARY KEY, + node_id TEXT NOT NULL, + payload_json TEXT NOT NULL, + updated_at REAL NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS heartbeats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at REAL NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS decisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + node_id TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at REAL NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS execution_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + node_id TEXT NOT NULL, + success INTEGER NOT NULL, + payload_json TEXT NOT NULL, + created_at REAL NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS policy_adjustments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tick INTEGER NOT NULL, + payload_json TEXT NOT NULL, + created_at REAL NOT NULL + ) + """, +) + +V2_SCHEMA_STATEMENTS = ( + """ + CREATE TABLE IF NOT EXISTS task_batches ( + batch_id TEXT PRIMARY KEY, + client_batch_id TEXT NOT NULL UNIQUE, + status TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS batch_plans ( + plan_id TEXT PRIMARY KEY, + batch_id TEXT NOT NULL, + status TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ) + """, + "CREATE INDEX IF NOT EXISTS idx_batch_plans_batch_id ON batch_plans(batch_id)", + """ + CREATE TABLE IF NOT EXISTS reservation_ledgers ( + plan_id TEXT PRIMARY KEY, + payload_json TEXT NOT NULL, + updated_at REAL NOT NULL + ) + """, +) + +REQUIRED_TABLES = { + "control_state", + "nodes", + "tasks", + "leases", + "heartbeats", + "decisions", + "execution_records", + "policy_adjustments", + "task_batches", + "batch_plans", + "reservation_ledgers", +} + + +class UnsupportedSchemaVersion(RuntimeError): + pass + + +def quick_check(connection: sqlite3.Connection) -> str: + row = connection.execute("PRAGMA quick_check").fetchone() + return "unknown" if row is None else str(row[0]) + + +def initialize_schema(connection: sqlite3.Connection, path: Path) -> Path | None: + connection.execute("PRAGMA journal_mode=WAL") + connection.execute("PRAGMA synchronous=NORMAL") + current_version = int(connection.execute("PRAGMA user_version").fetchone()[0]) + if current_version > SCHEMA_VERSION: + raise UnsupportedSchemaVersion( + f"Database schema v{current_version} is newer than supported v{SCHEMA_VERSION}." + ) + integrity = quick_check(connection) + if integrity != "ok": + raise sqlite3.DatabaseError(f"SQLite quick_check failed: {integrity}") + + backup_path = None + if current_version < SCHEMA_VERSION and _has_user_tables(connection): + backup_path = _backup_before_migration(connection, path, current_version) + + if current_version < SCHEMA_VERSION: + try: + connection.execute("BEGIN IMMEDIATE") + _create_base_schema(connection) + _migrate_to_v2(connection) + connection.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") + _validate_schema(connection) + connection.commit() + except Exception: + connection.rollback() + raise + else: + _validate_schema(connection) + + integrity = quick_check(connection) + if integrity != "ok": + raise sqlite3.DatabaseError(f"SQLite quick_check failed after migration: {integrity}") + return backup_path + + +def _create_base_schema(connection: sqlite3.Connection) -> None: + for statement in BASE_SCHEMA_STATEMENTS: + connection.execute(statement) + + +def _migrate_to_v2(connection: sqlite3.Connection) -> None: + columns = { + str(row[1]) for row in connection.execute("PRAGMA table_info(nodes)").fetchall() + } + if "last_seen_epoch" not in columns: + connection.execute("ALTER TABLE nodes ADD COLUMN last_seen_epoch REAL") + connection.execute( + "UPDATE nodes SET last_seen_epoch = updated_at WHERE last_seen_epoch IS NULL" + ) + for statement in V2_SCHEMA_STATEMENTS: + connection.execute(statement) + + +def _validate_schema(connection: sqlite3.Connection) -> None: + tables = { + str(row[0]) + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ).fetchall() + } + missing = sorted(REQUIRED_TABLES - tables) + if missing: + raise sqlite3.DatabaseError(f"SQLite schema is missing tables: {', '.join(missing)}") + node_columns = { + str(row[1]) for row in connection.execute("PRAGMA table_info(nodes)").fetchall() + } + if "last_seen_epoch" not in node_columns: + raise sqlite3.DatabaseError("SQLite nodes table is missing last_seen_epoch") + + +def _has_user_tables(connection: sqlite3.Connection) -> bool: + row = connection.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1" + ).fetchone() + return row is not None + + +def _backup_before_migration( + connection: sqlite3.Connection, + path: Path, + current_version: int, +) -> Path | None: + if str(path) == ":memory:": + return None + stamp = time.strftime("%Y%m%d-%H%M%S") + backup_path = path.with_name( + f"{path.name}.pre-v{SCHEMA_VERSION}-from-v{current_version}-{stamp}-{time.time_ns() % 1_000_000:06d}.bak" + ) + backup_connection = sqlite3.connect(str(backup_path)) + try: + connection.backup(backup_connection) + if quick_check(backup_connection) != "ok": + raise sqlite3.DatabaseError("SQLite migration backup failed integrity check") + except Exception: + backup_connection.close() + backup_path.unlink(missing_ok=True) + raise + else: + backup_connection.close() + return backup_path diff --git a/src/tianjun/storage/sqlite_state_store.py b/src/tianjun/storage/sqlite_state_store.py index 62f9d06..152fa0d 100644 --- a/src/tianjun/storage/sqlite_state_store.py +++ b/src/tianjun/storage/sqlite_state_store.py @@ -4,11 +4,15 @@ import sqlite3 import threading import time +from contextlib import contextmanager from pathlib import Path from typing import Any +from . import sqlite_schema + class SQLiteStateStore: + SCHEMA_VERSION = sqlite_schema.SCHEMA_VERSION MAX_HEARTBEATS = 10_000 MAX_EXECUTION_RECORDS = 2_000 MAX_DECISIONS = 2_000 @@ -21,21 +25,92 @@ def __init__(self, path: str | Path) -> None: self.conn = sqlite3.connect(str(self.path), check_same_thread=False) self.conn.row_factory = sqlite3.Row self._heartbeat_writes = 0 - self._initialize_schema() + self._transaction_depth = 0 + try: + self.last_migration_backup = sqlite_schema.initialize_schema(self.conn, self.path) + self.integrity_status = sqlite_schema.quick_check(self.conn) + self._prune_retained_history() + except Exception: + self.conn.close() + raise def close(self) -> None: with self.lock: self.conn.close() + @property + def schema_version(self) -> int: + with self.lock: + row = self.conn.execute("PRAGMA user_version").fetchone() + return int(row[0]) + + @contextmanager + def transaction(self): + """Group state writes into one atomic SQLite transaction.""" + with self.lock: + outermost = self._transaction_depth == 0 + if outermost: + self.conn.execute("BEGIN IMMEDIATE") + self._transaction_depth += 1 + try: + yield self + except Exception: + self._transaction_depth -= 1 + if outermost: + self.conn.rollback() + raise + else: + self._transaction_depth -= 1 + if outermost: + self.conn.commit() + + def _commit_locked(self) -> None: + if self._transaction_depth == 0: + self.conn.commit() + + def readiness(self) -> dict[str, Any]: + """Verify that the state database accepts a reversible write.""" + with self.lock: + try: + integrity = sqlite_schema.quick_check(self.conn) + if integrity != "ok": + return {"ready": False, "integrity": integrity, "writable": False} + self.conn.execute("SAVEPOINT tianjun_readiness") + self.conn.execute( + """ + INSERT INTO control_state (key, value_json, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value_json = excluded.value_json, + updated_at = excluded.updated_at + """, + ("__readiness_probe__", "true", time.time()), + ) + self.conn.execute("ROLLBACK TO tianjun_readiness") + self.conn.execute("RELEASE tianjun_readiness") + return {"ready": True, "integrity": "ok", "writable": True} + except sqlite3.Error as exc: + try: + self.conn.execute("ROLLBACK TO tianjun_readiness") + self.conn.execute("RELEASE tianjun_readiness") + except sqlite3.Error: + pass + return { + "ready": False, + "integrity": "error", + "writable": False, + "error": type(exc).__name__, + } + def load_state(self) -> dict[str, Any]: with self.lock: nodes = [ { "payload": json.loads(row["payload_json"]), - "last_heartbeat_at": row["last_heartbeat_at"], + "last_seen_epoch": row["last_seen_epoch"], } for row in self.conn.execute( - "SELECT payload_json, last_heartbeat_at FROM nodes ORDER BY node_id" + "SELECT payload_json, last_seen_epoch FROM nodes ORDER BY node_id" ).fetchall() ] tasks = [ @@ -77,6 +152,24 @@ def load_state(self) -> dict[str, Any]: "SELECT key, value_json FROM control_state" ).fetchall() } + task_batches = [ + json.loads(row["payload_json"]) + for row in self.conn.execute( + "SELECT payload_json FROM task_batches ORDER BY created_at, batch_id" + ).fetchall() + ] + batch_plans = [ + json.loads(row["payload_json"]) + for row in self.conn.execute( + "SELECT payload_json FROM batch_plans ORDER BY created_at, plan_id" + ).fetchall() + ] + reservation_ledgers = [ + json.loads(row["payload_json"]) + for row in self.conn.execute( + "SELECT payload_json FROM reservation_ledgers ORDER BY plan_id" + ).fetchall() + ] return { "nodes": nodes, "tasks": tasks, @@ -85,24 +178,28 @@ def load_state(self) -> dict[str, Any]: "decisions": decisions, "policy_adjustments": policy_adjustments, "control_state": control_state, + "task_batches": task_batches, + "batch_plans": batch_plans, + "reservation_ledgers": reservation_ledgers, } - def save_node(self, node_payload: dict[str, Any], last_heartbeat_at: float) -> None: + def save_node(self, node_payload: dict[str, Any], last_seen_epoch: float) -> None: payload_json = json.dumps(node_payload, ensure_ascii=True) now = time.time() with self.lock: self.conn.execute( """ - INSERT INTO nodes (node_id, payload_json, last_heartbeat_at, updated_at) - VALUES (?, ?, ?, ?) + INSERT INTO nodes (node_id, payload_json, last_heartbeat_at, last_seen_epoch, updated_at) + VALUES (?, ?, ?, ?, ?) ON CONFLICT(node_id) DO UPDATE SET payload_json = excluded.payload_json, last_heartbeat_at = excluded.last_heartbeat_at, + last_seen_epoch = excluded.last_seen_epoch, updated_at = excluded.updated_at """, - (node_payload["node_id"], payload_json, last_heartbeat_at, now), + (node_payload["node_id"], payload_json, last_seen_epoch, last_seen_epoch, now), ) - self.conn.commit() + self._commit_locked() def save_task(self, task_payload: dict[str, Any]) -> None: payload_json = json.dumps(task_payload, ensure_ascii=True) @@ -119,12 +216,12 @@ def save_task(self, task_payload: dict[str, Any]) -> None: """, (task_payload["task_id"], task_payload["status"], payload_json, now), ) - self.conn.commit() + self._commit_locked() def delete_task(self, task_id: str) -> None: with self.lock: self.conn.execute("DELETE FROM tasks WHERE task_id = ?", (task_id,)) - self.conn.commit() + self._commit_locked() def record_heartbeat(self, node_id: str, payload: dict[str, Any]) -> None: now = time.time() @@ -140,7 +237,7 @@ def record_heartbeat(self, node_id: str, payload: dict[str, Any]) -> None: self._heartbeat_writes += 1 if self._heartbeat_writes % 256 == 0: self._prune_table_locked("heartbeats", self.MAX_HEARTBEATS) - self.conn.commit() + self._commit_locked() def save_lease(self, lease_payload: dict[str, Any]) -> None: payload_json = json.dumps(lease_payload, ensure_ascii=True) @@ -157,12 +254,12 @@ def save_lease(self, lease_payload: dict[str, Any]) -> None: """, (lease_payload["task_id"], lease_payload["node_id"], payload_json, now), ) - self.conn.commit() + self._commit_locked() def delete_lease(self, task_id: str) -> None: with self.lock: self.conn.execute("DELETE FROM leases WHERE task_id = ?", (task_id,)) - self.conn.commit() + self._commit_locked() def append_execution_record(self, record_payload: dict[str, Any]) -> None: now = time.time() @@ -182,7 +279,7 @@ def append_execution_record(self, record_payload: dict[str, Any]) -> None: ), ) self._prune_table_locked("execution_records", self.MAX_EXECUTION_RECORDS) - self.conn.commit() + self._commit_locked() def append_decision(self, decision_payload: dict[str, Any]) -> None: now = time.time() @@ -196,7 +293,7 @@ def append_decision(self, decision_payload: dict[str, Any]) -> None: (decision_payload["task_id"], decision_payload["node_id"], payload_json, now), ) self._prune_table_locked("decisions", self.MAX_DECISIONS) - self.conn.commit() + self._commit_locked() def append_policy_adjustment(self, adjustment_payload: dict[str, Any]) -> None: now = time.time() @@ -210,7 +307,7 @@ def append_policy_adjustment(self, adjustment_payload: dict[str, Any]) -> None: (adjustment_payload["tick"], payload_json, now), ) self._prune_table_locked("policy_adjustments", self.MAX_POLICY_ADJUSTMENTS) - self.conn.commit() + self._commit_locked() def set_control_value(self, key: str, value: Any) -> None: now = time.time() @@ -226,75 +323,76 @@ def set_control_value(self, key: str, value: Any) -> None: """, (key, value_json, now), ) - self.conn.commit() + self._commit_locked() - def _initialize_schema(self) -> None: + def save_task_batch(self, batch_payload: dict[str, Any]) -> None: + now = time.time() with self.lock: - self.conn.executescript( + self.conn.execute( """ - PRAGMA journal_mode=WAL; - PRAGMA synchronous=NORMAL; - - CREATE TABLE IF NOT EXISTS control_state ( - key TEXT PRIMARY KEY, - value_json TEXT NOT NULL, - updated_at REAL NOT NULL - ); - - CREATE TABLE IF NOT EXISTS nodes ( - node_id TEXT PRIMARY KEY, - payload_json TEXT NOT NULL, - last_heartbeat_at REAL NOT NULL, - updated_at REAL NOT NULL - ); - - CREATE TABLE IF NOT EXISTS tasks ( - task_id TEXT PRIMARY KEY, - status TEXT NOT NULL, - payload_json TEXT NOT NULL, - updated_at REAL NOT NULL - ); - - CREATE TABLE IF NOT EXISTS leases ( - task_id TEXT PRIMARY KEY, - node_id TEXT NOT NULL, - payload_json TEXT NOT NULL, - updated_at REAL NOT NULL - ); - - CREATE TABLE IF NOT EXISTS heartbeats ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - node_id TEXT NOT NULL, - payload_json TEXT NOT NULL, - created_at REAL NOT NULL - ); - - CREATE TABLE IF NOT EXISTS decisions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id TEXT NOT NULL, - node_id TEXT NOT NULL, - payload_json TEXT NOT NULL, - created_at REAL NOT NULL - ); + INSERT INTO task_batches (batch_id, client_batch_id, status, payload_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(batch_id) DO UPDATE SET + client_batch_id = excluded.client_batch_id, + status = excluded.status, + payload_json = excluded.payload_json, + updated_at = excluded.updated_at + """, + ( + batch_payload["batch_id"], + batch_payload["client_batch_id"], + batch_payload["status"], + json.dumps(batch_payload, ensure_ascii=True), + now, + now, + ), + ) + self._commit_locked() - CREATE TABLE IF NOT EXISTS execution_records ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id TEXT NOT NULL, - node_id TEXT NOT NULL, - success INTEGER NOT NULL, - payload_json TEXT NOT NULL, - created_at REAL NOT NULL - ); + def save_batch_plan(self, plan_payload: dict[str, Any]) -> None: + now = time.time() + with self.lock: + self.conn.execute( + """ + INSERT INTO batch_plans (plan_id, batch_id, status, payload_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(plan_id) DO UPDATE SET + status = excluded.status, + payload_json = excluded.payload_json, + updated_at = excluded.updated_at + """, + ( + plan_payload["plan_id"], + plan_payload["batch_id"], + plan_payload["status"], + json.dumps(plan_payload, ensure_ascii=True), + now, + now, + ), + ) + self._commit_locked() - CREATE TABLE IF NOT EXISTS policy_adjustments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - tick INTEGER NOT NULL, - payload_json TEXT NOT NULL, - created_at REAL NOT NULL - ); + def save_reservation_ledger(self, ledger_payload: dict[str, Any]) -> None: + now = time.time() + with self.lock: + self.conn.execute( """ + INSERT INTO reservation_ledgers (plan_id, payload_json, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(plan_id) DO UPDATE SET + payload_json = excluded.payload_json, + updated_at = excluded.updated_at + """, + ( + ledger_payload["plan_id"], + json.dumps(ledger_payload, ensure_ascii=True), + now, + ), ) - self.conn.execute("PRAGMA user_version = 1") + self._commit_locked() + + def _prune_retained_history(self) -> None: + with self.lock: self._prune_table_locked("heartbeats", self.MAX_HEARTBEATS) self._prune_table_locked("execution_records", self.MAX_EXECUTION_RECORDS) self._prune_table_locked("decisions", self.MAX_DECISIONS) diff --git a/tests/browser/dashboard.spec.js b/tests/browser/dashboard.spec.js new file mode 100644 index 0000000..2c6224d --- /dev/null +++ b/tests/browser/dashboard.spec.js @@ -0,0 +1,117 @@ +import { expect, test } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("/dashboard"); + await expect(page.locator("#systemStatus")).toHaveText("系统在线"); +}); + +test("tabs expose selection state and support keyboard navigation", async ({ page }) => { + const overview = page.locator("#tab-overview"); + const scheduling = page.locator("#tab-scheduling"); + const topology = page.locator("#tab-topology"); + + await expect(overview).toHaveAttribute("aria-selected", "true"); + await overview.focus(); + await page.keyboard.press("ArrowRight"); + await expect(scheduling).toBeFocused(); + await expect(scheduling).toHaveAttribute("aria-selected", "true"); + await expect(page.locator("#page-scheduling")).toBeVisible(); + await page.keyboard.press("End"); + await expect(page.locator("#tab-model")).toBeFocused(); + await page.keyboard.press("Home"); + await expect(overview).toBeFocused(); + await topology.click(); + await expect(topology).toHaveAttribute("aria-selected", "true"); + await expect(page).toHaveURL(/#topology$/); +}); + +test("latest navigation wins when an earlier polling response is delayed", async ({ page }) => { + let delayed = false; + await page.route("**/report/summary", async (route) => { + if (!delayed) { + delayed = true; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + await route.continue(); + }); + await page.reload(); + await page.locator("#tab-topology").click(); + await expect(page.locator("#page-topology")).toBeVisible(); + await page.waitForTimeout(350); + await expect(page.locator("#tab-topology")).toHaveAttribute("aria-selected", "true"); + await expect(page.locator("#page-topology")).toBeVisible(); +}); + +test("topology layers change both semantics and visible supporting content", async ({ page }) => { + await page.locator("#tab-topology").click(); + const canvas = page.locator("#topologyCanvas"); + const load = page.getByRole("button", { name: "资源负载" }); + const carbon = page.getByRole("button", { name: "碳强度" }); + + await expect(canvas).toHaveAttribute("data-layer", "network"); + await load.click(); + await expect(load).toHaveAttribute("aria-pressed", "true"); + await expect(canvas).toHaveAttribute("data-layer", "load"); + await expect(page.locator("#pathMetrics")).toBeHidden(); + await expect(page.locator("#topologyLayerSummary")).toContainText("数据中心负载"); + await carbon.click(); + await expect(carbon).toHaveAttribute("aria-pressed", "true"); + await expect(canvas).toHaveAttribute("data-layer", "carbon"); + await expect(page.locator("#topologyLayerSummary")).toContainText("站点碳强度"); +}); + +test("empty topology and failed health requests have explicit states", async ({ page }) => { + await page.route("**/report/topology", async (route) => { + const response = await route.fetch(); + const report = await response.json(); + await route.fulfill({ response, json: { ...report, nodes: [], physical_topology: null } }); + }); + await page.locator("#tab-topology").click(); + await expect(page.locator(".topology-empty-state")).toBeVisible(); + await expect(page.locator(".topology-empty-state")).toContainText("等待仿真节点导入"); + + await page.route("**/health", (route) => route.abort("failed")); + await page.locator("#refreshButton").click(); + await expect(page.locator("#systemStatus")).toHaveText("系统离线"); + await expect(page.locator("#autoRefreshStatus")).toContainText("刷新失败"); +}); + +test("topology geometry stays inside its canvas without page overflow", async ({ page }) => { + await page.locator("#tab-topology").click(); + await expect(page.locator(".network-topology-shell")).toBeVisible(); + const geometry = await page.evaluate(() => { + const canvas = document.querySelector("#topologyCanvas").getBoundingClientRect(); + const shell = document.querySelector(".network-topology-shell").getBoundingClientRect(); + const svg = document.querySelector(".network-links")?.getBoundingClientRect(); + const nodes = Array.from(document.querySelectorAll(".network-node")).map((node) => { + const rect = node.getBoundingClientRect(); + return { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom }; + }); + return { + viewportWidth: document.documentElement.clientWidth, + documentWidth: document.documentElement.scrollWidth, + canvas: { + left: canvas.left, + right: canvas.right, + top: canvas.top, + bottom: canvas.bottom, + }, + shell: { left: shell.left, right: shell.right, top: shell.top, bottom: shell.bottom }, + svg: svg && { left: svg.left, right: svg.right, top: svg.top, bottom: svg.bottom }, + nodes, + }; + }); + expect(geometry.documentWidth).toBeLessThanOrEqual(geometry.viewportWidth + 1); + expect(geometry.shell.left).toBeGreaterThanOrEqual(geometry.canvas.left - 1); + expect(geometry.shell.right).toBeLessThanOrEqual(geometry.canvas.right + 1); + if (geometry.svg) { + expect(geometry.svg.left).toBeGreaterThanOrEqual(geometry.shell.left - 1); + expect(geometry.svg.right).toBeLessThanOrEqual(geometry.shell.right + 1); + } + for (const node of geometry.nodes) { + expect(node.left).toBeGreaterThanOrEqual(geometry.shell.left - 1); + expect(node.right).toBeLessThanOrEqual(geometry.shell.right + 1); + expect(node.top).toBeGreaterThanOrEqual(geometry.shell.top - 1); + expect(node.bottom).toBeLessThanOrEqual(geometry.shell.bottom + 1); + } +}); diff --git a/tests/cloudsim/TianjunBridgeIntegrationProbe.java b/tests/cloudsim/TianjunBridgeIntegrationProbe.java new file mode 100644 index 0000000..3394183 --- /dev/null +++ b/tests/cloudsim/TianjunBridgeIntegrationProbe.java @@ -0,0 +1,31 @@ +package org.cloudsimplus.examples.tianjun; + +import org.cloudsimplus.examples.tianjun.TianjunHttpBridge.LeaseResult; +import org.cloudsimplus.examples.tianjun.TianjunHttpBridge.SimTaskResult; + +public final class TianjunBridgeIntegrationProbe { + private TianjunBridgeIntegrationProbe() { + } + + public static void main(final String[] args) { + final TianjunHttpBridge bridge = new TianjunHttpBridge(args[0]); + if (!bridge.isHealthy()) { + throw new IllegalStateException("Python control plane is not healthy"); + } + final LeaseResult lease = bridge.requestLease("java-node"); + if (lease == null || lease.taskId().isBlank()) { + throw new IllegalStateException("Java bridge did not receive a lease"); + } + bridge.reportResult(new SimTaskResult( + "java-node", + lease.taskId(), + true, + 1.0, + "java bridge completed", + "", + 0, + 1.0 + )); + System.out.println("TIANJUN_CLOUDSIM_BRIDGE_OK " + lease.taskId()); + } +} diff --git a/tests/frontend/request.test.js b/tests/frontend/request.test.js new file mode 100644 index 0000000..d7cb6b5 --- /dev/null +++ b/tests/frontend/request.test.js @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + RequestTimeoutError, + requestJson, +} from "../../src/tianjun/interfaces/dashboard/static/js/request.js"; + +test("requestJson executes the real response parsing path", async () => { + const payload = await requestJson("/health", { + fetchImpl: async (path, options) => { + assert.equal(path, "/health"); + assert.equal(options.method, "GET"); + return new Response(JSON.stringify({ status: "ok" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }, + }); + assert.deepEqual(payload, { status: "ok" }); +}); + +test("requestJson aborts a hung fetch at its deadline", async () => { + const hungFetch = (_path, options) => new Promise((_resolve, reject) => { + options.signal.addEventListener("abort", () => reject(options.signal.reason), { once: true }); + }); + await assert.rejects( + requestJson("/report/summary", { fetchImpl: hungFetch, timeoutMs: 100 }), + (error) => error instanceof RequestTimeoutError, + ); +}); diff --git a/tests/frontend/topology-data.test.js b/tests/frontend/topology-data.test.js new file mode 100644 index 0000000..d6bf431 --- /dev/null +++ b/tests/frontend/topology-data.test.js @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + carbonSourceSummary, + loadSourceSummary, + sourceLabel, +} from "../../src/tianjun/interfaces/dashboard/static/js/topology-data.js"; +import { + aggregateResources, + parseDciNode, +} from "../../src/tianjun/interfaces/dashboard/static/js/topology-resource.js"; + +test("resource layer distinguishes real, simulated, estimated and mixed data", () => { + assert.equal(loadSourceSummary([{ resource_load_source: "live_telemetry" }]), "实时遥测"); + assert.equal(loadSourceSummary([{ resource_load_source: "simulated_telemetry" }]), "CloudSim 模拟"); + assert.equal(loadSourceSummary([{ resource_load_source: "allocation_estimate" }]), "分配估算"); + assert.equal(loadSourceSummary([ + { resource_load_source: "live_telemetry" }, + { resource_load_source: "allocation_estimate" }, + ]), "混合来源"); + assert.equal(loadSourceSummary([{ resource_load_source: "unavailable" }]), "暂无数据"); +}); + +test("carbon layer never labels a configured profile as realtime", () => { + assert.equal(carbonSourceSummary([{ carbon_data_source: "configured_profile" }]), "配置曲线"); + assert.equal(carbonSourceSummary([{ carbon_data_source: "simulated_profile" }]), "模拟曲线"); + assert.equal(sourceLabel(["live_signal"], "carbon"), "实时碳信号"); +}); + +test("topology resource aggregation prefers heartbeat telemetry over allocation estimates", () => { + const parsed = parseDciNode("dci-dc2-chengdu-vm-3"); + assert.deepEqual(parsed, { dcKey: "dc2", location: "chengdu", vmIndex: 3 }); + const result = aggregateResources([{ + node_id: "dci-dc2-chengdu-vm-3", + capacity: { cpu: 8, memory: 16, gpu: 2 }, + available: { cpu: 7, memory: 14, gpu: 2 }, + runtime_utilization: { cpu: 0.5, memory: 0.25 }, + active_task_ids: ["task-a"], + }], "dc").get("dc2"); + assert.equal(result.cpuPercent, 50); + assert.equal(result.memoryPercent, 25); + assert.equal(result.tasks, 1); +}); diff --git a/tests/test_batch_carbon_scheduling.py b/tests/test_batch_carbon_scheduling.py index 803c7f2..4f217d5 100644 --- a/tests/test_batch_carbon_scheduling.py +++ b/tests/test_batch_carbon_scheduling.py @@ -1,8 +1,11 @@ from __future__ import annotations +import threading +import time + import pytest -from tianjun.application.batch_scheduling_service import BatchRequestError +from tianjun.application.batch_scheduling_service import BatchRequestError, BatchSchedulingService from tianjun.application.control_plane import CentralControlPlane from tianjun.domain import CarbonSiteProfile, Node, PowerProfile, ResourceVector, RunningTask, Task @@ -124,6 +127,42 @@ def test_snapshot_conflict_creates_no_partial_reservation() -> None: assert control.reservation_ledgers == {} +def test_slow_batch_planning_does_not_block_node_heartbeat(monkeypatch) -> None: + control = CentralControlPlane() + control.register_node(node("green", carbon=120)) + imported = control.import_task_batch(batch_payload("nonblocking-planning")) + planning_started = threading.Event() + release_planning = threading.Event() + original = BatchSchedulingService._build_plan + + def slow_build(self, *args, **kwargs): + planning_started.set() + assert release_planning.wait(timeout=3) + return original(self, *args, **kwargs) + + monkeypatch.setattr(BatchSchedulingService, "_build_plan", slow_build) + error: list[BaseException] = [] + + def preview() -> None: + try: + control.preview_batch_schedule(imported["batch_id"], {"strategy": "B1-batch-greedy"}) + except BaseException as exc: # pragma: no cover - asserted below + error.append(exc) + + worker = threading.Thread(target=preview) + worker.start() + assert planning_started.wait(timeout=2) + started = time.perf_counter() + control.record_heartbeat("green", health_score=0.99) + heartbeat_elapsed = time.perf_counter() - started + release_planning.set() + worker.join(timeout=5) + + assert not worker.is_alive() + assert error == [] + assert heartbeat_elapsed < 0.25 + + def test_carbon_time_shift_uses_lowest_forecast_tick_only_when_allowed() -> None: control = CentralControlPlane() green = node("trace", carbon=700) diff --git a/tests/test_cloudsim_java_integration.py b/tests/test_cloudsim_java_integration.py new file mode 100644 index 0000000..d585fec --- /dev/null +++ b/tests/test_cloudsim_java_integration.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import shutil +import subprocess +import threading +from pathlib import Path + +import pytest + +from tianjun.application.control_plane import CentralControlPlane +from tianjun.domain import Node, ResourceVector, Task, TaskStatus +from tianjun.interfaces.http.server import build_http_server + + +BRIDGE = Path("examples/cloudsimplus/src/main/java/org/cloudsimplus/examples/tianjun/TianjunHttpBridge.java") +PROBE = Path("tests/cloudsim/TianjunBridgeIntegrationProbe.java") + + +@pytest.mark.skipif( + shutil.which("javac") is None or shutil.which("java") is None, + reason="JDK is required for the CloudSim Java bridge integration test", +) +def test_java_cloudsim_bridge_acknowledges_lease_and_reports_result(tmp_path) -> None: + classes = tmp_path / "classes" + classes.mkdir() + subprocess.run( + ["javac", "-encoding", "UTF-8", "-d", str(classes), str(BRIDGE), str(PROBE)], + check=True, + capture_output=True, + text=True, + ) + + control = CentralControlPlane() + control.register_node(Node( + node_id="java-node", + region="dc1", + capacity=ResourceVector(cpu=4, memory=8, storage=20), + )) + control.submit_task(Task( + task_id="java-task", + task_type="batch", + demand=ResourceVector(cpu=1, memory=1, storage=1), + estimated_duration=2, + )) + server = build_http_server(control, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + try: + completed = subprocess.run( + [ + "java", + "-cp", + str(classes), + "org.cloudsimplus.examples.tianjun.TianjunBridgeIntegrationProbe", + base_url, + ], + check=True, + capture_output=True, + text=True, + timeout=20, + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert "TIANJUN_CLOUDSIM_BRIDGE_OK java-task" in completed.stdout + assert control.tasks["java-task"].status == TaskStatus.SUCCEEDED + assert len(control.execution_history) == 1 + assert control.execution_history[0].stdout_excerpt == "java bridge completed" diff --git a/tests/test_control_plane_services.py b/tests/test_control_plane_services.py index 1298dd5..77d0c78 100644 --- a/tests/test_control_plane_services.py +++ b/tests/test_control_plane_services.py @@ -131,6 +131,44 @@ def test_cloudsim_heartbeat_telemetry_survives_into_node_report() -> None: } assert node["telemetry_source"] == "cloudsim" assert node["simulation_tick"] == 12.5 + assert node["resource_load_source"] == "simulated_telemetry" + assert node["resource_load_source_label"] == "CloudSim Plus 模拟遥测" + assert node["telemetry_freshness"] == "current" + + +def test_cloudsim_label_does_not_exempt_node_from_heartbeat_expiry() -> None: + control_plane = CentralControlPlane(heartbeat_timeout_seconds=1.0) + control_plane.register_node(Node( + node_id="cloudsim-node", + region="dc1", + labels={"cloudsim"}, + capacity=ResourceVector(cpu=4), + )) + control_plane.last_heartbeat_at["cloudsim-node"] -= 2.0 + + node = control_plane.build_report()["nodes"][0] + + assert node["online"] is False + assert node["telemetry_freshness"] == "unavailable" + + +def test_report_distinguishes_configured_carbon_from_live_signal() -> None: + control_plane = CentralControlPlane() + control_plane.register_node(Node(node_id="node-a", region="dc1", capacity=ResourceVector(cpu=4))) + + configured = control_plane.build_report()["nodes"][0] + assert configured["carbon_data_source"] == "simulated_profile" + assert configured["carbon_data_freshness"] == "profile" + + control_plane.record_heartbeat( + "node-a", + carbon_intensity_g_per_kwh=320.0, + carbon_signal_timestamp=100.0, + telemetry_source="node_agent", + ) + live = control_plane.build_report()["nodes"][0] + assert live["carbon_data_source"] == "live_signal" + assert live["carbon_data_freshness"] == "current" def test_task_lease_service_handles_task_lifecycle_through_facade() -> None: diff --git a/tests/test_lease_protocol.py b/tests/test_lease_protocol.py new file mode 100644 index 0000000..bbd851d --- /dev/null +++ b/tests/test_lease_protocol.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import threading +import time + +import pytest + +from tianjun.application.control_plane import CentralControlPlane +from tianjun.domain import Node, ResourceVector, Task, TaskStatus +from tianjun.storage.sqlite_state_store import SQLiteStateStore + + +def _control(*, lease_timeout_seconds: float = 60.0) -> CentralControlPlane: + control = CentralControlPlane(lease_timeout_seconds=lease_timeout_seconds) + control.register_node(Node( + node_id="node-a", + region="dc1", + capacity=ResourceVector(cpu=4, memory=8, storage=20), + )) + return control + + +def _submit(control: CentralControlPlane, task_id: str = "task-a") -> None: + control.submit_task(Task( + task_id=task_id, + task_type="batch", + demand=ResourceVector(cpu=1, memory=1, storage=1), + estimated_duration=2, + )) + + +def test_lease_ack_and_result_retry_are_idempotent() -> None: + control = _control() + _submit(control) + lease = control.request_lease("node-a") + assert lease is not None + assert lease["lease_id"].startswith("lease-") + assert lease["acknowledged_at_epoch"] is None + + acknowledged = control.acknowledge_lease( + node_id="node-a", + task_id="task-a", + lease_id=lease["lease_id"], + ) + assert acknowledged["status"] == "acknowledged" + assert acknowledged["acknowledged_at_epoch"] is not None + assert control.request_lease("node-a") is None + + first = control.report_task_result( + node_id="node-a", + task_id="task-a", + lease_id=lease["lease_id"], + result_id="result-a", + success=True, + duration_seconds=1.0, + ) + replay = control.report_task_result( + node_id="node-a", + task_id="task-a", + lease_id=lease["lease_id"], + result_id="result-a", + success=True, + duration_seconds=1.0, + ) + + assert first["idempotent_replay"] is False + assert replay["idempotent_replay"] is True + assert replay["result_id"] == first["result_id"] + assert len(control.execution_history) == 1 + + +def test_progress_implicitly_acknowledges_and_renews_lease() -> None: + control = _control(lease_timeout_seconds=5.0) + _submit(control) + lease = control.request_lease("node-a") + assert lease is not None + original_expiry = lease["expires_at_epoch"] + control.leases["task-a"].expires_at_epoch = time.time() + 0.1 + + progress = control.report_task_progress( + node_id="node-a", + task_id="task-a", + lease_id=lease["lease_id"], + stage="executing", + progress=0.5, + ) + + assert progress["progress"] == 0.5 + active = control.leases["task-a"] + assert active.acknowledged_at_epoch is not None + assert active.expires_at_epoch > original_expiry + + +def test_expired_lease_releases_capacity_and_requeues_task() -> None: + control = _control(lease_timeout_seconds=1.0) + _submit(control) + lease = control.request_lease("node-a") + assert lease is not None + control.leases["task-a"].expires_at_epoch = time.time() - 1.0 + + expired = control.task_lease_service.expire_stale_leases() + + assert expired == ["task-a"] + assert control.leases == {} + assert control.nodes["node-a"].used().cpu == 0 + assert control.tasks["task-a"].status == TaskStatus.PENDING + assert control.pending_queue == ["task-a"] + + +def test_concurrent_lease_requests_share_one_identity() -> None: + control = _control() + _submit(control) + barrier = threading.Barrier(8) + results: list[dict | None] = [] + result_lock = threading.Lock() + + def request() -> None: + barrier.wait(timeout=2) + result = control.request_lease("node-a") + with result_lock: + results.append(result) + + workers = [threading.Thread(target=request) for _ in range(8)] + for worker in workers: + worker.start() + for worker in workers: + worker.join(timeout=3) + + assert all(not worker.is_alive() for worker in workers) + assert len(control.leases) == 1 + assert len({item["lease_id"] for item in results if item is not None}) == 1 + assert len([item for item in results if item is not None]) == 8 + + +def test_ack_rejects_wrong_lease_identity() -> None: + control = _control() + _submit(control) + lease = control.request_lease("node-a") + assert lease is not None + + with pytest.raises(ValueError, match="identity"): + control.acknowledge_lease( + node_id="node-a", + task_id="task-a", + lease_id="lease-wrong", + ) + + +def test_idempotent_result_receipt_survives_restart(tmp_path) -> None: + path = tmp_path / "receipts.db" + store = SQLiteStateStore(path) + control = CentralControlPlane(state_store=store) + control.register_node(Node( + node_id="node-a", + region="dc1", + capacity=ResourceVector(cpu=2, memory=2, storage=2), + )) + _submit(control) + lease = control.request_lease("node-a") + assert lease is not None + first = control.report_task_result( + node_id="node-a", + task_id="task-a", + lease_id=lease["lease_id"], + result_id="persistent-result", + success=True, + duration_seconds=1.0, + ) + store.close() + + restored_store = SQLiteStateStore(path) + try: + restored = CentralControlPlane(state_store=restored_store) + replay = restored.report_task_result( + node_id="node-a", + task_id="task-a", + lease_id=lease["lease_id"], + result_id="persistent-result", + success=True, + duration_seconds=1.0, + ) + assert replay["idempotent_replay"] is True + assert replay["result_id"] == first["result_id"] + assert len(restored.execution_history) == 1 + finally: + restored_store.close() diff --git a/tests/test_lifecycle_sweeper.py b/tests/test_lifecycle_sweeper.py new file mode 100644 index 0000000..7f2efff --- /dev/null +++ b/tests/test_lifecycle_sweeper.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import time + +from tianjun.application.control_plane import CentralControlPlane +from tianjun.application.lifecycle import LifecycleSweeper +from tianjun.domain import Node, ResourceVector, Task, TaskStatus + + +def _wait_until(predicate, timeout: float = 1.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return + time.sleep(0.01) + raise AssertionError("condition was not reached before timeout") + + +def test_sweeper_expires_nodes_without_dashboard_or_api_traffic() -> None: + control = CentralControlPlane(heartbeat_timeout_seconds=0.05) + control.register_node(Node( + node_id="idle-node", + region="dc1", + capacity=ResourceVector(cpu=2, memory=2, storage=2), + )) + sweeper = LifecycleSweeper(control, interval_seconds=0.01) + + sweeper.start() + try: + _wait_until(lambda: not control.nodes["idle-node"].online) + assert sweeper.run_count > 0 + assert sweeper.failure_count == 0 + finally: + sweeper.stop() + assert sweeper.running is False + + +def test_sweeper_expires_lease_and_releases_capacity_without_polling() -> None: + control = CentralControlPlane( + heartbeat_timeout_seconds=60.0, + lease_timeout_seconds=0.05, + ) + control.register_node(Node( + node_id="worker", + region="dc1", + capacity=ResourceVector(cpu=2, memory=2, storage=2), + )) + control.submit_task(Task( + task_id="leased-task", + task_type="batch", + demand=ResourceVector(cpu=1, memory=1, storage=1), + estimated_duration=2, + )) + assert control.request_lease("worker") is not None + control.leases["leased-task"].expires_at_epoch = time.time() - 1.0 + sweeper = LifecycleSweeper(control, interval_seconds=0.01) + + sweeper.start() + try: + _wait_until(lambda: "leased-task" not in control.leases) + assert control.tasks["leased-task"].status == TaskStatus.PENDING + assert control.nodes["worker"].used().cpu == 0 + finally: + sweeper.stop() + + +def test_sweeper_start_and_stop_are_idempotent() -> None: + sweeper = LifecycleSweeper(CentralControlPlane(), interval_seconds=0.01) + sweeper.start() + first_thread = sweeper._thread + sweeper.start() + assert sweeper._thread is first_thread + sweeper.stop() + sweeper.stop() + assert sweeper.running is False diff --git a/tests/test_mcp_stdio_e2e.py b/tests/test_mcp_stdio_e2e.py new file mode 100644 index 0000000..cad4209 --- /dev/null +++ b/tests/test_mcp_stdio_e2e.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import asyncio +import json +import os +import sys +import threading +import urllib.request +from contextlib import contextmanager +from pathlib import Path + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +from tianjun.application.bootstrap import build_control_plane +from tianjun.chat import ChatRuntime +from tianjun.interfaces.http.server import build_http_server +from tianjun.llm import LLMSettings + + +ROOT = Path(__file__).resolve().parents[1] + + +@contextmanager +def running_control_plane(): + control = build_control_plane() + chat = ChatRuntime.with_llm_settings(control, LLMSettings(offline=True)) + server = build_http_server(control, "127.0.0.1", 0, chat_runtime=chat) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + host, port = server.server_address + try: + yield f"http://{host}:{port}" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +async def call_real_stdio_server(base_url: str) -> tuple[list[str], object]: + environment = dict(os.environ) + source_path = str(ROOT / "src") + environment["PYTHONPATH"] = os.pathsep.join( + item for item in (source_path, environment.get("PYTHONPATH", "")) if item + ) + environment["TIANJUN_BASE_URL"] = base_url + parameters = StdioServerParameters( + command=sys.executable, + args=["-m", "tianjun.integrations.mcp_server"], + env=environment, + cwd=ROOT, + ) + async with stdio_client(parameters) as (reader, writer): + async with ClientSession(reader, writer) as session: + await session.initialize() + tools = await session.list_tools() + result = await session.call_tool("get_cluster_state", {}) + return [tool.name for tool in tools.tools], result + + +def get_report(base_url: str) -> dict: + with urllib.request.urlopen(f"{base_url}/report", timeout=5) as response: + return json.loads(response.read().decode("utf-8")) + + +def test_real_stdio_mcp_call_updates_dashboard_success_state() -> None: + with running_control_plane() as base_url: + tool_names, result = asyncio.run( + asyncio.wait_for(call_real_stdio_server(base_url), timeout=20) + ) + + assert "get_cluster_state" in tool_names + assert result.isError is False + report = get_report(base_url) + runtime = report["toolchain_runtime"] + assert runtime["external_mcp_call_count"] == 1 + assert runtime["external_mcp_success_count"] == 1 + assert runtime["external_mcp_last_success"]["tool_name"] == "get_cluster_state" + assert runtime["external_mcp_last_success"]["result_status"] == "success" diff --git a/tests/test_state_store_v2.py b/tests/test_state_store_v2.py new file mode 100644 index 0000000..3350145 --- /dev/null +++ b/tests/test_state_store_v2.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import sqlite3 +import time + +import pytest + +from tianjun.application.control_plane import CentralControlPlane +from tianjun.domain import Node, ResourceVector +from tianjun.storage.sqlite_state_store import SQLiteStateStore +from tianjun.storage.sqlite_schema import UnsupportedSchemaVersion +from tianjun.storage import sqlite_schema + + +def _batch_payload(client_batch_id: str = "persisted-client") -> dict: + return { + "client_batch_id": client_batch_id, + "batch_name": "持久化回归", + "tasks": [ + { + "task_id": "persisted-task", + "task_type": "batch", + "demand": {"cpu": 1, "memory": 1, "storage": 1}, + "estimated_duration": 3, + "priority": 5, + } + ], + } + + +def _control_with_node(store: SQLiteStateStore) -> CentralControlPlane: + control = CentralControlPlane(state_store=store) + if "node-a" not in control.nodes: + control.register_node(Node( + node_id="node-a", + region="dc1", + capacity=ResourceVector(cpu=4, memory=8, storage=20), + )) + return control + + +def test_sqlite_v1_migrates_monotonic_heartbeat_to_wall_clock_epoch(tmp_path) -> None: + path = tmp_path / "legacy.db" + updated_at = time.time() - 3.0 + connection = sqlite3.connect(path) + connection.execute( + "CREATE TABLE nodes (node_id TEXT PRIMARY KEY, payload_json TEXT NOT NULL, last_heartbeat_at REAL NOT NULL, updated_at REAL NOT NULL)" + ) + connection.execute( + "INSERT INTO nodes VALUES (?, ?, ?, ?)", + ("node-a", '{"node_id":"node-a"}', 12.5, updated_at), + ) + connection.execute("PRAGMA user_version = 1") + connection.commit() + connection.close() + + store = SQLiteStateStore(path) + try: + assert store.schema_version == 2 + assert store.load_state()["nodes"][0]["last_seen_epoch"] == pytest.approx(updated_at) + assert store.last_migration_backup is not None + assert store.last_migration_backup.is_file() + backup = sqlite3.connect(store.last_migration_backup) + try: + assert backup.execute("PRAGMA user_version").fetchone()[0] == 1 + finally: + backup.close() + finally: + store.close() + + +def test_sqlite_rejects_future_schema_without_downgrading(tmp_path) -> None: + path = tmp_path / "future.db" + connection = sqlite3.connect(path) + connection.execute("PRAGMA user_version = 99") + connection.commit() + connection.close() + + with pytest.raises(UnsupportedSchemaVersion, match="newer than supported"): + SQLiteStateStore(path) + + check = sqlite3.connect(path) + try: + assert check.execute("PRAGMA user_version").fetchone()[0] == 99 + finally: + check.close() + + +def test_failed_schema_migration_is_atomic(tmp_path, monkeypatch) -> None: + path = tmp_path / "failed-migration.db" + connection = sqlite3.connect(path) + connection.execute( + "CREATE TABLE nodes (node_id TEXT PRIMARY KEY, payload_json TEXT NOT NULL, last_heartbeat_at REAL NOT NULL, updated_at REAL NOT NULL)" + ) + connection.execute("PRAGMA user_version = 1") + connection.commit() + connection.close() + + def fail_after_schema_change(connection): + connection.execute("ALTER TABLE nodes ADD COLUMN last_seen_epoch REAL") + connection.execute("CREATE TABLE should_rollback (value TEXT)") + raise RuntimeError("simulated migration failure") + + monkeypatch.setattr(sqlite_schema, "_migrate_to_v2", fail_after_schema_change) + with pytest.raises(RuntimeError, match="migration failure"): + SQLiteStateStore(path) + + check = sqlite3.connect(path) + try: + assert check.execute("PRAGMA user_version").fetchone()[0] == 1 + tables = { + row[0] for row in check.execute("SELECT name FROM sqlite_master WHERE type='table'") + } + columns = {row[1] for row in check.execute("PRAGMA table_info(nodes)")} + assert "should_rollback" not in tables + assert "last_seen_epoch" not in columns + finally: + check.close() + + +def test_sqlite_readiness_performs_reversible_write(tmp_path) -> None: + store = SQLiteStateStore(tmp_path / "ready.db") + try: + assert store.readiness() == {"ready": True, "integrity": "ok", "writable": True} + assert "__readiness_probe__" not in store.load_state()["control_state"] + finally: + store.close() + + +def test_sqlite_transaction_rolls_back_all_grouped_writes(tmp_path) -> None: + store = SQLiteStateStore(tmp_path / "transaction.db") + try: + with pytest.raises(RuntimeError): + with store.transaction(): + store.set_control_value("partial", {"written": True}) + store.save_task({"task_id": "partial-task", "status": "pending"}) + raise RuntimeError("abort") + + snapshot = store.load_state() + assert "partial" not in snapshot["control_state"] + assert snapshot["tasks"] == [] + finally: + store.close() + + +def test_batch_plan_idempotency_and_reservations_survive_restart(tmp_path) -> None: + path = tmp_path / "batch.db" + store = SQLiteStateStore(path) + control = _control_with_node(store) + imported = control.import_task_batch(_batch_payload()) + plan = control.preview_batch_schedule(imported["batch_id"], {"strategy": "B1-batch-greedy"}) + store.close() + + restored_store = SQLiteStateStore(path) + restored = CentralControlPlane(state_store=restored_store) + replay = restored.import_task_batch(_batch_payload()) + assert replay["idempotent_replay"] is True + assert replay["batch_id"] == imported["batch_id"] + assert restored.get_task_batch(imported["batch_id"])["latest_plan"]["plan_id"] == plan["plan_id"] + + committed = restored.commit_batch_schedule(imported["batch_id"], { + "plan_id": plan["plan_id"], + "resource_snapshot_version": plan["resource_snapshot_version"], + "confirmed_by_user_button": True, + }) + assert committed["reservation_ledger"]["reservations"] + restored_store.close() + + final_store = SQLiteStateStore(path) + try: + final = CentralControlPlane(state_store=final_store) + assert final.batch_plans[plan["plan_id"]].status == "committed" + assert plan["plan_id"] in final.reservation_ledgers + assert "persisted-task" in final.pending_queue + finally: + final_store.close() + + +def test_wall_clock_heartbeat_expires_after_restart(tmp_path) -> None: + path = tmp_path / "heartbeat.db" + store = SQLiteStateStore(path) + control = CentralControlPlane(state_store=store, heartbeat_timeout_seconds=1.0) + control.register_node(Node(node_id="cloudsim-node", region="dc1", labels={"cloudsim"}, capacity=ResourceVector(cpu=2))) + control.last_heartbeat_epoch["cloudsim-node"] = time.time() - 10.0 + control._persist_node(control.nodes["cloudsim-node"]) + store.close() + + restored_store = SQLiteStateStore(path) + try: + restored = CentralControlPlane(state_store=restored_store, heartbeat_timeout_seconds=1.0) + assert restored.build_report()["nodes"][0]["online"] is False + finally: + restored_store.close() + + +def test_failed_batch_persistence_rolls_back_memory_and_database(tmp_path, monkeypatch) -> None: + path = tmp_path / "rollback.db" + store = SQLiteStateStore(path) + control = _control_with_node(store) + imported = control.import_task_batch(_batch_payload("rollback-client")) + plan = control.preview_batch_schedule(imported["batch_id"], {"strategy": "B1-batch-greedy"}) + + def fail_ledger(_payload): + raise RuntimeError("simulated storage failure") + + monkeypatch.setattr(store, "save_reservation_ledger", fail_ledger) + with pytest.raises(RuntimeError, match="storage failure"): + control.commit_batch_schedule(imported["batch_id"], { + "plan_id": plan["plan_id"], + "resource_snapshot_version": plan["resource_snapshot_version"], + "confirmed_by_user_button": True, + }) + + assert control.leases == {} + assert control.reservation_ledgers == {} + assert "persisted-task" not in control.tasks + assert control.nodes["node-a"].used().cpu == 0 + store.close() + + reopened = SQLiteStateStore(path) + try: + persisted = reopened.load_state() + stored_plan = next(item for item in persisted["batch_plans"] if item["plan_id"] == plan["plan_id"]) + assert stored_plan["status"] == "previewed" + assert persisted["reservation_ledgers"] == [] + finally: + reopened.close()