Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3f7db29b1 | ||
|
|
09c33eb448 | ||
|
|
f7abb8066b | ||
|
|
efc9ffcdc6 | ||
|
|
05b0d7fb80 | ||
|
|
13c2d24e76 | ||
|
|
fbb17597d1 | ||
|
|
7f8261a4b6 | ||
|
|
839e567060 | ||
|
|
db8d9c694d | ||
|
|
7fc2da56d2 | ||
|
|
ae30d716f9 | ||
|
|
d96086b345 | ||
|
|
6c7928c096 | ||
|
|
5585856cd3 | ||
|
|
9ffcf93fcd | ||
|
|
94db629904 | ||
|
|
7f9d664d6b | ||
|
|
90e05be0a9 | ||
|
|
70fbed1050 | ||
|
|
ab268789ee | ||
|
|
35e4a5350f | ||
|
|
dc5dae2a51 | ||
|
|
73c5b2330e | ||
|
|
8aa436043d | ||
|
|
3d4e630582 | ||
|
|
3b609a8e57 | ||
|
|
0a4e2b741d | ||
|
|
e68313f95e | ||
|
|
e62ff487ab | ||
|
|
680a2f4f2b | ||
|
|
8d6d92bcec | ||
|
|
3a5fc56c9d | ||
|
|
6327664d22 | ||
|
|
228d8fedf2 | ||
|
|
f94af50497 | ||
|
|
2a81d8ced4 | ||
|
|
759e343075 | ||
|
|
bef2de2c66 | ||
|
|
e8bd867c33 | ||
|
|
1953cacebc | ||
|
|
43be07af73 | ||
|
|
bdea3ef5ba | ||
|
|
76848939e9 | ||
|
|
f2a08293dd | ||
|
|
866da79a81 | ||
|
|
8a810c2ae8 | ||
|
|
c475a8238c | ||
|
|
24c55564a0 | ||
|
|
55a4b32aa6 | ||
|
|
42c4d11457 | ||
|
|
7be2107ad0 | ||
|
|
4587c9c46d | ||
|
|
aebff86864 | ||
|
|
76d2453ef3 |
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"approval_id": "owner-preapprove-V1R1C5-main-entry-20260707",
|
||||||
|
"created_at": "2026-07-07T00:00:00Z",
|
||||||
|
"approved_by_role": "owner",
|
||||||
|
"target_branch": "patch/web-ui-complete-gitops-dashboard-20260706",
|
||||||
|
"target_base": "main",
|
||||||
|
"target_version": "V1R1C5",
|
||||||
|
"rollback_target": "V1R1C4",
|
||||||
|
"allowed_actions": [
|
||||||
|
"merge_to_main",
|
||||||
|
"create_release_tag",
|
||||||
|
"activate_new_baseline"
|
||||||
|
],
|
||||||
|
"risk_acceptance": "Owner approved promotion of autonomous GitOps platform control plane to main, subject to validation, OpenClaw supervision, no secret leakage, no direct force push, and rollback target V1R1C4.",
|
||||||
|
"expires_at": "2026-07-14T23:59:59Z",
|
||||||
|
"constraints": {
|
||||||
|
"direct_main_push": false,
|
||||||
|
"force_push": false,
|
||||||
|
"secrets_in_git": false,
|
||||||
|
"destructive_operations": false,
|
||||||
|
"validation_required": true,
|
||||||
|
"owner_notification_required": true,
|
||||||
|
"web_ui_audit_required": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "aios-autonomous-merge-policy",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"mode": "gated-autonomous",
|
||||||
|
"description": "Defines when Codex CI may mark a patch branch as auto-merge eligible and when main merge must remain blocked.",
|
||||||
|
"branch_scope": {
|
||||||
|
"allowed_source_patterns": [
|
||||||
|
"patch/*"
|
||||||
|
],
|
||||||
|
"protected_targets": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
|
"direct_main_push": false
|
||||||
|
},
|
||||||
|
"merge_gate": {
|
||||||
|
"required_status": [
|
||||||
|
"validation_passed",
|
||||||
|
"acceptance_required_passed",
|
||||||
|
"secret_scan_passed",
|
||||||
|
"policy_scan_passed",
|
||||||
|
"indirect_regression_passed",
|
||||||
|
"gray_deployment_passed"
|
||||||
|
],
|
||||||
|
"required_artifacts": [
|
||||||
|
"reports/codex/validation-report.json",
|
||||||
|
"reports/codex/merge-candidate.json",
|
||||||
|
"versions/proposals/V1R1C5-gitops-platform-ui.proposed.json"
|
||||||
|
],
|
||||||
|
"required_approval": {
|
||||||
|
"owner_or_admin": true,
|
||||||
|
"audit_record_required": true,
|
||||||
|
"approval_request_type": "release",
|
||||||
|
"approval_target": "V1R1C5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autonomous_behavior": {
|
||||||
|
"auto_validate": true,
|
||||||
|
"auto_self_heal": true,
|
||||||
|
"auto_create_merge_candidate": true,
|
||||||
|
"auto_merge_to_main": "only_after_policy_and_owner_approval",
|
||||||
|
"auto_tag_after_merge": false,
|
||||||
|
"max_self_heal_iterations": 5
|
||||||
|
},
|
||||||
|
"hard_blocks": [
|
||||||
|
"secret_leak_detected",
|
||||||
|
"direct_main_write_detected",
|
||||||
|
"destructive_operation_detected",
|
||||||
|
"baseline_active_version_changed_before_merge",
|
||||||
|
"auth_or_gitea_security_config_modified",
|
||||||
|
"required_test_failed",
|
||||||
|
"unapproved_high_risk_change"
|
||||||
|
],
|
||||||
|
"post_merge": {
|
||||||
|
"proposed_version": "V1R1C5",
|
||||||
|
"rollback_target": "V1R1C4",
|
||||||
|
"tag_creation": "request_only",
|
||||||
|
"baseline_activation": "owner_approved_action_only"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"name": "aios-bounded-autonomous-production-policy",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"mode": "bounded-lights-out-production",
|
||||||
|
"description": "Allows unattended CI/self-healing/canary within pre-approved policy boundaries while preserving production safety gates.",
|
||||||
|
"non_negotiable_controls": {
|
||||||
|
"direct_main_push": false,
|
||||||
|
"secrets_in_git": false,
|
||||||
|
"destructive_operations": "forbidden",
|
||||||
|
"rollback_execution": "request_or_preapproved_runbook_only",
|
||||||
|
"tag_creation": "request_or_preapproved_runbook_only",
|
||||||
|
"baseline_activation": "preapproved_runbook_only",
|
||||||
|
"force_push": false,
|
||||||
|
"delete_tags_or_history": false
|
||||||
|
},
|
||||||
|
"autonomous_scope": {
|
||||||
|
"allowed_without_human_intervention": [
|
||||||
|
"static_validation",
|
||||||
|
"acceptance_validation",
|
||||||
|
"secret_scan",
|
||||||
|
"policy_scan",
|
||||||
|
"self_healing_patch_on_same_branch",
|
||||||
|
"merge_candidate_generation",
|
||||||
|
"gray_deployment_validation",
|
||||||
|
"failure_report_generation",
|
||||||
|
"rollback_request_generation",
|
||||||
|
"baseline_proposal_generation"
|
||||||
|
],
|
||||||
|
"requires_preapproved_runbook": [
|
||||||
|
"merge_to_main",
|
||||||
|
"create_release_tag",
|
||||||
|
"activate_new_baseline",
|
||||||
|
"production_runtime_change",
|
||||||
|
"production_rollback_execution"
|
||||||
|
],
|
||||||
|
"always_blocked": [
|
||||||
|
"force_push",
|
||||||
|
"purge_baseline",
|
||||||
|
"delete_version_history",
|
||||||
|
"delete_audit_records",
|
||||||
|
"commit_real_secret",
|
||||||
|
"disable_auth_or_gitea_protection"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"preapproval_contract": {
|
||||||
|
"path_pattern": "approvals/preapproved/*.json",
|
||||||
|
"required_fields": [
|
||||||
|
"approval_id",
|
||||||
|
"approved_by_role",
|
||||||
|
"target_branch",
|
||||||
|
"target_version",
|
||||||
|
"expires_at",
|
||||||
|
"allowed_actions",
|
||||||
|
"risk_acceptance",
|
||||||
|
"rollback_target"
|
||||||
|
],
|
||||||
|
"required_values": {
|
||||||
|
"approved_by_role": ["owner", "admin"],
|
||||||
|
"target_version": "V1R1C5",
|
||||||
|
"rollback_target": "V1R1C4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"promotion_pipeline": [
|
||||||
|
"validate",
|
||||||
|
"self_heal_until_pass",
|
||||||
|
"generate_merge_candidate",
|
||||||
|
"gray_deploy_validate",
|
||||||
|
"check_preapproval",
|
||||||
|
"merge_or_request_approval",
|
||||||
|
"propose_or_activate_baseline",
|
||||||
|
"monitor",
|
||||||
|
"auto_generate_rollback_request_on_failure"
|
||||||
|
],
|
||||||
|
"slo_gates": {
|
||||||
|
"gray_deployment_required": true,
|
||||||
|
"error_budget_policy": "block_if_unknown",
|
||||||
|
"observability_required": true,
|
||||||
|
"post_merge_monitoring_window": "30m"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
{
|
||||||
|
"name": "aios-multi-agent-consensus-policy",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"mode": "bounded-autonomous-consensus",
|
||||||
|
"description": "Production-safe consensus policy for OpenClaw, Codex and ChatGPT agents supervising AIOS GitOps changes.",
|
||||||
|
"agents": {
|
||||||
|
"openclaw": {
|
||||||
|
"role": "supervisor",
|
||||||
|
"weight": 2,
|
||||||
|
"required_for_high_risk": true,
|
||||||
|
"responsibility": [
|
||||||
|
"execution supervision",
|
||||||
|
"policy compliance",
|
||||||
|
"failure containment",
|
||||||
|
"owner-facing status certification"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"codex": {
|
||||||
|
"role": "validator_and_repairer",
|
||||||
|
"weight": 2,
|
||||||
|
"required_for_high_risk": true,
|
||||||
|
"responsibility": [
|
||||||
|
"static validation",
|
||||||
|
"acceptance validation",
|
||||||
|
"self-healing patch loop",
|
||||||
|
"merge candidate generation"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"chatgpt": {
|
||||||
|
"role": "operator_and_summary_agent",
|
||||||
|
"weight": 1,
|
||||||
|
"required_for_high_risk": false,
|
||||||
|
"responsibility": [
|
||||||
|
"change explanation",
|
||||||
|
"owner notification summary",
|
||||||
|
"repair proposal generation",
|
||||||
|
"runbook authoring"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"decision_rules": {
|
||||||
|
"low_risk": {
|
||||||
|
"minimum_score": 2,
|
||||||
|
"required_agents": ["codex"],
|
||||||
|
"allowed_outcome": "auto_merge_candidate"
|
||||||
|
},
|
||||||
|
"medium_risk": {
|
||||||
|
"minimum_score": 3,
|
||||||
|
"required_agents": ["codex", "openclaw"],
|
||||||
|
"allowed_outcome": "auto_merge_candidate_after_gray"
|
||||||
|
},
|
||||||
|
"high_risk": {
|
||||||
|
"minimum_score": 5,
|
||||||
|
"required_agents": ["codex", "openclaw"],
|
||||||
|
"allowed_outcome": "production_request_or_preapproved_execution"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"hard_blocks": [
|
||||||
|
"credential_material_detected",
|
||||||
|
"direct_main_push_detected",
|
||||||
|
"destructive_history_operation",
|
||||||
|
"auth_protection_disabled",
|
||||||
|
"gitea_protection_disabled",
|
||||||
|
"baseline_activation_without_approval",
|
||||||
|
"owner_notification_failed_for_main_change",
|
||||||
|
"required_acceptance_test_failed"
|
||||||
|
],
|
||||||
|
"required_artifacts": {
|
||||||
|
"validation": "reports/codex/validation-report.json",
|
||||||
|
"openclaw": "reports/openclaw/final-supervision-report.json",
|
||||||
|
"consensus": "reports/consensus/decision.json",
|
||||||
|
"merge_candidate": "reports/codex/merge-candidate.json",
|
||||||
|
"baseline_proposal": "versions/proposals/V1R1C5-gitops-platform-ui.proposed.json"
|
||||||
|
},
|
||||||
|
"production_safety": {
|
||||||
|
"direct_main_push": false,
|
||||||
|
"tag_creation": "request_or_preapproved_only",
|
||||||
|
"baseline_activation": "request_or_preapproved_only",
|
||||||
|
"rollback_execution": "request_or_preapproved_only",
|
||||||
|
"owner_notification": "mandatory_for_main_version_change",
|
||||||
|
"web_ui_audit_record": "mandatory"
|
||||||
|
},
|
||||||
|
"outputs": {
|
||||||
|
"approved": "merge_candidate_ready",
|
||||||
|
"rejected": "failure_report_required",
|
||||||
|
"blocked": "owner_review_required"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
{
|
||||||
|
"name": "aios-self-governing-devops-policy",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"mode": "bounded-self-governing",
|
||||||
|
"description": "Self-governing DevOps policy for AIOS GitOps with dynamic agent weights, drift detection, guarded rollback requests, policy runtime and skill evolution.",
|
||||||
|
"non_negotiable_controls": {
|
||||||
|
"direct_main_push": false,
|
||||||
|
"all_writes_require_git_commit": true,
|
||||||
|
"real_credentials_in_git": false,
|
||||||
|
"disable_auth_protection": false,
|
||||||
|
"delete_audit_or_version_history": false,
|
||||||
|
"production_execution_without_policy": false
|
||||||
|
},
|
||||||
|
"governance_loop": [
|
||||||
|
"observe_events",
|
||||||
|
"evaluate_policy",
|
||||||
|
"score_agents",
|
||||||
|
"detect_consensus_drift",
|
||||||
|
"render_decision_graph",
|
||||||
|
"run_validation_or_repair",
|
||||||
|
"generate_candidate_or_request",
|
||||||
|
"notify_owner",
|
||||||
|
"persist_audit",
|
||||||
|
"learn_from_outcome",
|
||||||
|
"propose_skill_update"
|
||||||
|
],
|
||||||
|
"agent_learning": {
|
||||||
|
"enabled": true,
|
||||||
|
"weight_bounds": {
|
||||||
|
"openclaw": [1, 4],
|
||||||
|
"codex": [1, 4],
|
||||||
|
"chatgpt": [1, 3]
|
||||||
|
},
|
||||||
|
"initial_weights": {
|
||||||
|
"openclaw": 2,
|
||||||
|
"codex": 2,
|
||||||
|
"chatgpt": 1
|
||||||
|
},
|
||||||
|
"signals": {
|
||||||
|
"validation_accuracy": 0.35,
|
||||||
|
"repair_success": 0.25,
|
||||||
|
"false_positive_rate": -0.15,
|
||||||
|
"owner_override": -0.2,
|
||||||
|
"post_merge_stability": 0.25
|
||||||
|
},
|
||||||
|
"update_policy": "bounded_exponential_smoothing",
|
||||||
|
"write_target": "runtime/governance/agent-weights.json"
|
||||||
|
},
|
||||||
|
"drift_detection": {
|
||||||
|
"enabled": true,
|
||||||
|
"thresholds": {
|
||||||
|
"consensus_score_drop": 2,
|
||||||
|
"agent_disagreement_ratio": 0.5,
|
||||||
|
"repeated_failure_count": 3,
|
||||||
|
"owner_override_count": 2
|
||||||
|
},
|
||||||
|
"on_drift": "block_production_and_request_owner_review"
|
||||||
|
},
|
||||||
|
"policy_runtime": {
|
||||||
|
"required_checks": [
|
||||||
|
"no_direct_main_write",
|
||||||
|
"no_real_credentials",
|
||||||
|
"no_destructive_history_change",
|
||||||
|
"acceptance_tests_pass",
|
||||||
|
"owner_notice_for_main_change",
|
||||||
|
"rollback_target_defined",
|
||||||
|
"baseline_proposal_exists"
|
||||||
|
],
|
||||||
|
"decision_outputs": [
|
||||||
|
"allow_candidate",
|
||||||
|
"conditional_candidate",
|
||||||
|
"request_owner_review",
|
||||||
|
"block"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"guarded_rollback": {
|
||||||
|
"mode": "request_first",
|
||||||
|
"automatic_action": "create_rollback_request_and_notify_owner",
|
||||||
|
"execution": "requires_preapproved_runbook_or_owner_action",
|
||||||
|
"default_target": "V1R1C4"
|
||||||
|
},
|
||||||
|
"skill_evolution": {
|
||||||
|
"enabled": true,
|
||||||
|
"mode": "propose_only",
|
||||||
|
"sources": [
|
||||||
|
"validation_failures",
|
||||||
|
"owner_feedback",
|
||||||
|
"post_merge_incidents",
|
||||||
|
"repeated_manual_repairs"
|
||||||
|
],
|
||||||
|
"output_dir": "skills/proposals",
|
||||||
|
"activation": "merge_after_acceptance"
|
||||||
|
},
|
||||||
|
"owner_control": {
|
||||||
|
"channels": ["web_ui", "wechat_or_wecom"],
|
||||||
|
"actions": ["audit", "approve", "reject", "request_change", "request_rollback"],
|
||||||
|
"main_version_change_notice": "mandatory"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "self-healing-gitops",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"mode": "codex-driven-ci",
|
||||||
|
"description": "Defines how Codex + CI loop detects failures, generates repair plan, and re-runs validation.",
|
||||||
|
"workflow": {
|
||||||
|
"trigger": ["push", "pull_request"],
|
||||||
|
"validation_script": "scripts/validate-gitops-platform-ui.sh",
|
||||||
|
"failure_handling": {
|
||||||
|
"create_failure_report": true,
|
||||||
|
"report_path": "reports/codex/failure.json",
|
||||||
|
"notify": "codex-gpt",
|
||||||
|
"auto_repair": false,
|
||||||
|
"require_human_or_gpt_review": true
|
||||||
|
},
|
||||||
|
"success_handling": {
|
||||||
|
"allow_merge_candidate": true,
|
||||||
|
"propose_baseline_update": "V1R1C5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"self_healing_loop": {
|
||||||
|
"steps": [
|
||||||
|
"run_validation",
|
||||||
|
"collect_failures",
|
||||||
|
"send_to_gpt",
|
||||||
|
"apply_patch_to_branch",
|
||||||
|
"rerun_validation",
|
||||||
|
"repeat_until_pass"
|
||||||
|
],
|
||||||
|
"max_iterations": 5,
|
||||||
|
"stop_conditions": [
|
||||||
|
"PASS",
|
||||||
|
"critical_security_failure",
|
||||||
|
"manual_override"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"constraints": {
|
||||||
|
"no_main_write": true,
|
||||||
|
"no_secret_exposure": true,
|
||||||
|
"approval_required_for_release": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
{
|
||||||
|
"name": "aios-self-optimizing-orchestrator",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"mode": "hybrid-event-plus-reconciliation",
|
||||||
|
"description": "Low-impact orchestrator for ChatGPT/Codex self-optimization, version-change notification, audit, and owner action routing.",
|
||||||
|
"design_principles": {
|
||||||
|
"execution_layer_impact": "minimal",
|
||||||
|
"writes": "git_patch_only",
|
||||||
|
"direct_main_push": false,
|
||||||
|
"secrets_in_git": false,
|
||||||
|
"production_high_risk_actions": "approval_or_preapproved_runbook_only",
|
||||||
|
"user_impact": "prefer_async_sidecar_and_read_only_observation"
|
||||||
|
},
|
||||||
|
"triggers": {
|
||||||
|
"primary": {
|
||||||
|
"type": "event_driven",
|
||||||
|
"sources": [
|
||||||
|
"git_push",
|
||||||
|
"tag_created",
|
||||||
|
"baseline_changed",
|
||||||
|
"patch_created",
|
||||||
|
"validation_failed",
|
||||||
|
"validation_passed",
|
||||||
|
"merge_candidate_created",
|
||||||
|
"runtime_health_degraded"
|
||||||
|
],
|
||||||
|
"target": "orchestrator_queue"
|
||||||
|
},
|
||||||
|
"fallback": {
|
||||||
|
"type": "scheduled_reconciliation",
|
||||||
|
"intervals": {
|
||||||
|
"codex_branch_scan": "*/15 * * * *",
|
||||||
|
"chatgpt_patch_scan": "*/15 * * * *",
|
||||||
|
"main_version_scan": "*/5 * * * *",
|
||||||
|
"notification_retry_scan": "*/10 * * * *",
|
||||||
|
"daily_owner_digest": "0 9 * * *"
|
||||||
|
},
|
||||||
|
"purpose": "Catch missed events, reconcile web UI records, and retry failed notifications."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"codex": {
|
||||||
|
"responsibility": [
|
||||||
|
"code validation",
|
||||||
|
"static/security/policy tests",
|
||||||
|
"self-healing patch loop",
|
||||||
|
"merge candidate report"
|
||||||
|
],
|
||||||
|
"trigger": "event_and_schedule",
|
||||||
|
"skill": "skills/gitops-platform-validation/SKILL.md"
|
||||||
|
},
|
||||||
|
"chatgpt": {
|
||||||
|
"responsibility": [
|
||||||
|
"operator summary",
|
||||||
|
"failure triage",
|
||||||
|
"GitOps patch authoring",
|
||||||
|
"owner-facing change explanation"
|
||||||
|
],
|
||||||
|
"trigger": "event_and_schedule",
|
||||||
|
"skill": "skills/gitops-owner-notification/SKILL.md"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"change_summary_required_fields": [
|
||||||
|
"event_id",
|
||||||
|
"branch",
|
||||||
|
"from_rev",
|
||||||
|
"to_rev",
|
||||||
|
"from_version",
|
||||||
|
"to_version",
|
||||||
|
"elapsed_since_previous_main_change",
|
||||||
|
"changed_features",
|
||||||
|
"code_lines_added",
|
||||||
|
"code_lines_deleted",
|
||||||
|
"config_lines_added",
|
||||||
|
"config_lines_deleted",
|
||||||
|
"test_status",
|
||||||
|
"risk",
|
||||||
|
"approval_status",
|
||||||
|
"rollback_target",
|
||||||
|
"web_ui_audit_url"
|
||||||
|
],
|
||||||
|
"owner_channels": {
|
||||||
|
"wechat": {
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "wechat_or_wecom",
|
||||||
|
"webhook_secret_ref": "secret_ref://aios/notifications/owner-wechat-webhook",
|
||||||
|
"signing_secret_ref": "secret_ref://aios/notifications/owner-action-signing-key",
|
||||||
|
"message_types": [
|
||||||
|
"main_version_changed",
|
||||||
|
"merge_candidate_ready",
|
||||||
|
"validation_failed",
|
||||||
|
"rollback_request_created",
|
||||||
|
"approval_required",
|
||||||
|
"daily_digest"
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
"view_audit",
|
||||||
|
"approve_preapproved_runbook",
|
||||||
|
"request_rollback",
|
||||||
|
"request_change",
|
||||||
|
"reject_release"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"web_ui": {
|
||||||
|
"enabled": true,
|
||||||
|
"audit_route": "/portal/me/#gitops-audit",
|
||||||
|
"owner_action_route": "/portal/me/#gitops-owner-actions",
|
||||||
|
"record_store": "runtime/gitops/events/*.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version_change_policy": {
|
||||||
|
"main_change_notification": "mandatory",
|
||||||
|
"notify_owner_on_every_main_version_change": true,
|
||||||
|
"include_diff_stats": true,
|
||||||
|
"include_elapsed_time_since_previous_main_change": true,
|
||||||
|
"include_function_summary": true,
|
||||||
|
"write_web_ui_record": true,
|
||||||
|
"retry_until_delivered_or_acknowledged": true
|
||||||
|
},
|
||||||
|
"owner_action_policy": {
|
||||||
|
"wechat_actions": "signed_short_link_or_command_token",
|
||||||
|
"web_actions": "login_required_portal_action",
|
||||||
|
"all_actions_audited": true,
|
||||||
|
"high_risk_actions": "request_only_unless_preapproved_runbook",
|
||||||
|
"rollback": "create_request_by_default",
|
||||||
|
"change_request": "create_patch_or_plan_by_default"
|
||||||
|
},
|
||||||
|
"minimal_execution_layer_changes": [
|
||||||
|
"Add sidecar orchestrator queue consumer.",
|
||||||
|
"Add event writer for GitOps version changes.",
|
||||||
|
"Add notification sender using secret_ref only.",
|
||||||
|
"Add Web UI audit/action records reading event files.",
|
||||||
|
"Keep existing /api/base /api/ver /api/diff /api/patch /api/model /api/run stable."
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "v1r1-execution-api-runtime",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"mode": "api_runtime_not_filesystem",
|
||||||
|
"baseline": "V1R1C4",
|
||||||
|
"target_proposal": "V1R1C5",
|
||||||
|
"description": "Execution API runtime contract for AIOS Xiamayiti self-evolution loop. All production execution requests must go through the V1R1 runtime API rather than direct filesystem or protected credential access.",
|
||||||
|
"security": {
|
||||||
|
"direct_runtime_secret_access": false,
|
||||||
|
"direct_filesystem_production_write": false,
|
||||||
|
"all_execution_audited": true,
|
||||||
|
"owner_control_required_for_high_risk": true,
|
||||||
|
"direct_main_push": false
|
||||||
|
},
|
||||||
|
"api": {
|
||||||
|
"submit_execution_request": "POST /api/v1r1/execution/request",
|
||||||
|
"get_execution_status": "GET /api/v1r1/execution/status/{request_id}",
|
||||||
|
"append_audit": "POST /api/v1r1/audit/append",
|
||||||
|
"send_owner_notification": "POST /api/v1r1/notification/owner",
|
||||||
|
"submit_owner_action": "POST /api/v1r1/owner/action"
|
||||||
|
},
|
||||||
|
"allowed_request_types": [
|
||||||
|
"audit_record",
|
||||||
|
"owner_notification",
|
||||||
|
"evolution_finding",
|
||||||
|
"evolution_plan",
|
||||||
|
"validation_run",
|
||||||
|
"merge_candidate",
|
||||||
|
"release_request",
|
||||||
|
"rollback_request",
|
||||||
|
"baseline_activation_request"
|
||||||
|
],
|
||||||
|
"blocked_request_types": [
|
||||||
|
"write_protected_credential",
|
||||||
|
"force_push",
|
||||||
|
"delete_history",
|
||||||
|
"delete_audit",
|
||||||
|
"disable_auth",
|
||||||
|
"disable_gitea_protection",
|
||||||
|
"direct_main_push"
|
||||||
|
],
|
||||||
|
"execution_states": [
|
||||||
|
"received",
|
||||||
|
"policy_checked",
|
||||||
|
"accepted",
|
||||||
|
"queued",
|
||||||
|
"executed_as_request",
|
||||||
|
"blocked",
|
||||||
|
"failed"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "v1r1-existing-wechat-runtime-integration",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"baseline": "V1R1C4",
|
||||||
|
"description": "Integrates AIOS self-evolution notifications with the already deployed Xiamayiti/V1R1 personal WeChat runtime instead of creating a duplicate gateway.",
|
||||||
|
"detected_runtime_processes": [
|
||||||
|
{
|
||||||
|
"process_hint": "/data/agent/services/ucc-wechat-qr-adapter/adapter.py",
|
||||||
|
"role": "user-control-center personal WeChat QR adapter",
|
||||||
|
"integration_priority": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"process_hint": "scripts/wechat_notify_worker.py",
|
||||||
|
"role": "existing personal WeChat notification worker",
|
||||||
|
"integration_priority": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"preferred_integration": {
|
||||||
|
"mode": "existing_worker_queue_bridge",
|
||||||
|
"runtime_env": "AIOS_V1R1_WECHAT_BRIDGE_OUTBOX",
|
||||||
|
"behavior": "Write normalized owner notification JSON into the existing V1R1 WeChat worker queue directory. The existing V1R1 worker is responsible for actual personal WeChat delivery."
|
||||||
|
},
|
||||||
|
"fallback_integration": {
|
||||||
|
"mode": "http_endpoint",
|
||||||
|
"runtime_env": "AIOS_V1R1_WECHAT_OUTBOUND_URL",
|
||||||
|
"behavior": "POST normalized owner notification JSON to a runtime-provided V1R1 notification API endpoint."
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"do_not_duplicate_wechat_gateway": true,
|
||||||
|
"do_not_store_credentials_in_git": true,
|
||||||
|
"do_not_access_runtime_secrets_directly": true,
|
||||||
|
"web_ui_and_wechat_share_event_id": true,
|
||||||
|
"delivery_status_must_be_audited": true
|
||||||
|
},
|
||||||
|
"operator_discovery_commands": [
|
||||||
|
"ps aux | grep -E 'ucc-wechat|wechat_notify'",
|
||||||
|
"pwdx <pid>",
|
||||||
|
"tr '\\0' '\\n' < /proc/<pid>/environ",
|
||||||
|
"grep -R \"queue\|outbox\|notify\|send\|wechat\" -n /data/agent /data/aios-gitops/workspace/scripts 2>/dev/null | head -100"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS message_outbox (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
channel TEXT NOT NULL,
|
||||||
|
recipient_user_id TEXT NOT NULL,
|
||||||
|
recipient_role TEXT,
|
||||||
|
message_type TEXT NOT NULL,
|
||||||
|
priority TEXT NOT NULL DEFAULT 'normal',
|
||||||
|
title TEXT,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
payload TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
sent_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_message_outbox_dispatch
|
||||||
|
ON message_outbox(channel, status, priority, created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_bindings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
channel_type TEXT NOT NULL,
|
||||||
|
external_chat_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channel_bindings_lookup
|
||||||
|
ON channel_bindings(user_id, channel_type, status, updated_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
target TEXT,
|
||||||
|
detail TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "[AIOS] Installing Autonomous GitOps System..."
|
||||||
|
|
||||||
|
WORKDIR=/data/aios-gitops/workspace
|
||||||
|
ETC=/etc/aios
|
||||||
|
RUNTIME=/data/aios-gitops/runtime
|
||||||
|
SERVICE_USER=aios
|
||||||
|
|
||||||
|
if ! id "$SERVICE_USER" >/dev/null 2>&1; then
|
||||||
|
echo "[AIOS] Creating system user: $SERVICE_USER"
|
||||||
|
useradd --system --home /data/aios-gitops --shell /usr/sbin/nologin "$SERVICE_USER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$ETC"
|
||||||
|
cp deploy/systemd/aios-autonomous-daemon.env.template "$ETC/aios-autonomous-daemon.env"
|
||||||
|
|
||||||
|
mkdir -p \
|
||||||
|
"$RUNTIME/events" \
|
||||||
|
"$RUNTIME/audit" \
|
||||||
|
"$RUNTIME/requests" \
|
||||||
|
"$RUNTIME/reports/codex" \
|
||||||
|
"$RUNTIME/approvals/preapproved" \
|
||||||
|
"$RUNTIME/integrations/v1r1-personal-wechat/outbox" \
|
||||||
|
"$RUNTIME/integrations/v1r1-personal-wechat/inbox" \
|
||||||
|
"$RUNTIME/integrations/v1r1-personal-wechat/status"
|
||||||
|
|
||||||
|
chown -R "$SERVICE_USER:$SERVICE_USER" /data/aios-gitops/runtime || true
|
||||||
|
chown -R "$SERVICE_USER:$SERVICE_USER" "$WORKDIR" || true
|
||||||
|
|
||||||
|
cp deploy/systemd/aios-autonomous-daemon.service /etc/systemd/system/
|
||||||
|
cp deploy/systemd/aios-v1r1-wechat-worker.service /etc/systemd/system/
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable aios-autonomous-daemon.service || true
|
||||||
|
systemctl enable aios-v1r1-wechat-worker.service || true
|
||||||
|
|
||||||
|
systemctl restart aios-autonomous-daemon.service || true
|
||||||
|
systemctl restart aios-v1r1-wechat-worker.service || true
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
systemctl status aios-autonomous-daemon.service --no-pager || true
|
||||||
|
systemctl status aios-v1r1-wechat-worker.service --no-pager || true
|
||||||
|
|
||||||
|
echo "[AIOS] Installation complete"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# AIOS Autonomous GitOps Daemon runtime environment template
|
||||||
|
# Copy to /etc/aios/aios-autonomous-daemon.env during deployment.
|
||||||
|
# This template stores only paths and secret_ref identifiers. Runtime values are injected by the host secret manager.
|
||||||
|
|
||||||
|
AIOS_REPO_ROOT=/data/aios-gitops/workspace
|
||||||
|
AIOS_EVENT_DIR=/data/aios-gitops/runtime/events
|
||||||
|
AIOS_AUDIT_DIR=/data/aios-gitops/runtime/audit
|
||||||
|
AIOS_REQUEST_DIR=/data/aios-gitops/runtime/requests
|
||||||
|
AIOS_REPORT_DIR=/data/aios-gitops/runtime/reports/codex
|
||||||
|
AIOS_PREAPPROVAL_DIR=/data/aios-gitops/runtime/approvals/preapproved
|
||||||
|
AIOS_DAEMON_INTERVAL=30
|
||||||
|
AIOS_INGRESS_BIND=127.0.0.1
|
||||||
|
AIOS_INGRESS_PORT=8787
|
||||||
|
AIOS_OWNER_CHANNEL_REF=secret_ref://aios/notifications/owner-wechat-endpoint
|
||||||
|
AIOS_OWNER_ACTION_SIGN_REF=secret_ref://aios/notifications/owner-action-signing
|
||||||
|
AIOS_INGRESS_SIGN_REF=secret_ref://aios/notifications/inbound-event-signing
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AIOS Autonomous GitOps Daemon
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=aios
|
||||||
|
WorkingDirectory=/data/aios-gitops/workspace
|
||||||
|
EnvironmentFile=/etc/aios/aios-autonomous-daemon.env
|
||||||
|
ExecStart=/usr/bin/python3 services/aios-autonomous-daemon/daemon.py run
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# Hardening (minimal impact)
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AIOS Multi-Agent Consensus Controller
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=aios
|
||||||
|
WorkingDirectory=/data/aios-gitops/workspace
|
||||||
|
ExecStart=/usr/bin/python3 services/aios-consensus-controller/controller.py
|
||||||
|
|
||||||
|
# Hardening (non-invasive)
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AIOS V1R1 Personal WeChat Outbound Worker
|
||||||
|
After=network.target aios-autonomous-daemon.service
|
||||||
|
Wants=aios-autonomous-daemon.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=aios
|
||||||
|
WorkingDirectory=/data/aios-gitops/workspace
|
||||||
|
EnvironmentFile=/etc/aios/aios-autonomous-daemon.env
|
||||||
|
ExecStart=/usr/bin/python3 services/aios-v1r1-wechat-outbound-worker/worker.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# Hardening
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# AIOS 远程 Codex 多 Agent 统筹架构
|
||||||
|
|
||||||
|
## 1. 核心定位
|
||||||
|
|
||||||
|
当前 AIOS / 虾马一体系统的自我进化,不再假设服务器上存在 Codex CLI。
|
||||||
|
|
||||||
|
真实运行模型是:
|
||||||
|
|
||||||
|
- Windows 11 Codex App:总控 / 统筹 / 拆分任务 / 监督多个子 agent;
|
||||||
|
- 服务器 OpenClaw:生产运行侧消息、微信、执行与通道能力;
|
||||||
|
- 服务器 Hermes Agent:运行观测、事件、消息/用户交互、反馈归集;
|
||||||
|
- 服务器 AIOS Autonomous Daemon:GitOps event loop、validation、audit、request;
|
||||||
|
- GitOps 仓库:唯一可审计状态同步面;
|
||||||
|
- WebOps / Portal:owner 审计、审批、回滚、变更入口。
|
||||||
|
|
||||||
|
## 2. 架构图
|
||||||
|
|
||||||
|
```text
|
||||||
|
Windows 11 Codex App
|
||||||
|
├─ Planner Agent
|
||||||
|
├─ Code Patch Agent
|
||||||
|
├─ Validation Agent
|
||||||
|
├─ WeChat/UCC Integration Agent
|
||||||
|
├─ WebOps UI Agent
|
||||||
|
└─ Supervisor Agent
|
||||||
|
↓ git / ssh / reports
|
||||||
|
GitOps Repository: patch/web-ui-complete-gitops-dashboard-20260706
|
||||||
|
↓ pull / event
|
||||||
|
AIOS Server Runtime
|
||||||
|
├─ aios-autonomous-daemon
|
||||||
|
├─ OpenClaw Agent / Gateway
|
||||||
|
├─ Hermes Agent
|
||||||
|
├─ UCC WeChat Adapter
|
||||||
|
├─ WebOps / message_outbox
|
||||||
|
└─ audit / reports / requests
|
||||||
|
↓
|
||||||
|
Owner WebOps + 个人微信
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 分工
|
||||||
|
|
||||||
|
### Windows Codex App
|
||||||
|
|
||||||
|
Windows Codex 是统筹器,不是服务器守护进程。
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
1. 读取 GitOps 指令与当前 branch;
|
||||||
|
2. 启动多个子 agent 并行处理;
|
||||||
|
3. 生成代码/配置/文档 patch;
|
||||||
|
4. push 到同一 GitOps patch 分支;
|
||||||
|
5. 读取服务器回传的验证结果;
|
||||||
|
6. 对失败项继续修复;
|
||||||
|
7. 输出 merge candidate / blocked reason。
|
||||||
|
|
||||||
|
### OpenClaw Agent
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
1. 维护微信通道、message send、用户通道状态;
|
||||||
|
2. 提供个人微信实际执行能力;
|
||||||
|
3. 接收 message_outbox worker 或 UCC Adapter 的调用;
|
||||||
|
4. 输出 channel status / delivery result;
|
||||||
|
5. 不直接接收业务模块调用。
|
||||||
|
|
||||||
|
### Hermes Agent
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
1. 汇总系统运行事件;
|
||||||
|
2. 处理用户/消息交互流;
|
||||||
|
3. 将微信或 WebOps 反馈转为系统事件;
|
||||||
|
4. 协助 self-evolution loop 判断用户体验和业务状态;
|
||||||
|
5. 输出观测报告给 GitOps / Codex。
|
||||||
|
|
||||||
|
### AIOS Autonomous Daemon
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
1. 处理 runtime events;
|
||||||
|
2. 触发 validation / merge gate;
|
||||||
|
3. 写 audit / request / report;
|
||||||
|
4. 不直接改 runtime secrets;
|
||||||
|
5. 不直接调用微信底层适配器。
|
||||||
|
|
||||||
|
## 4. 并行子 Agent 模型
|
||||||
|
|
||||||
|
Windows Codex 可启动以下子 agent:
|
||||||
|
|
||||||
|
| 子 Agent | 任务 |
|
||||||
|
|---|---|
|
||||||
|
| planner | 读取 directive,拆分任务,确认风险 |
|
||||||
|
| server-inspector | 解析服务器命令输出,定位 runtime 状态 |
|
||||||
|
| code-patcher | 修改 GitOps 仓库代码 |
|
||||||
|
| test-author | 补验收用例和验证脚本 |
|
||||||
|
| wechat-integrator | 处理 message_outbox / UCC / OpenClaw 适配 |
|
||||||
|
| webops-ui | 补 WebOps 页面和 API contract |
|
||||||
|
| supervisor | 汇总 PASS/FAIL/BLOCKED |
|
||||||
|
|
||||||
|
## 5. 自动运行机制
|
||||||
|
|
||||||
|
自动运行不代表 Codex 在服务器常驻,而是通过以下 loop 运行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
server event / owner request
|
||||||
|
↓
|
||||||
|
GitOps directive / report
|
||||||
|
↓
|
||||||
|
Windows Codex pulls branch
|
||||||
|
↓
|
||||||
|
Codex child agents patch code/config
|
||||||
|
↓
|
||||||
|
git push
|
||||||
|
↓
|
||||||
|
server git pull + run validation
|
||||||
|
↓
|
||||||
|
reports written
|
||||||
|
↓
|
||||||
|
Codex reads reports
|
||||||
|
↓
|
||||||
|
repeat until PASS/BLOCKED
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 通信通道
|
||||||
|
|
||||||
|
### Git
|
||||||
|
|
||||||
|
主要状态同步面:
|
||||||
|
|
||||||
|
- directives;
|
||||||
|
- patches;
|
||||||
|
- code/config;
|
||||||
|
- docs;
|
||||||
|
- acceptance tests;
|
||||||
|
- version proposals。
|
||||||
|
|
||||||
|
### SSH / Operator Logs
|
||||||
|
|
||||||
|
服务器运行结果通过 SSH 或人工粘贴日志回传给 Codex/GPT。
|
||||||
|
|
||||||
|
### Runtime Reports
|
||||||
|
|
||||||
|
服务器写入:
|
||||||
|
|
||||||
|
```text
|
||||||
|
reports/codex/*.json
|
||||||
|
reports/openclaw/*.json
|
||||||
|
reports/hermes/*.json
|
||||||
|
reports/consensus/*.json
|
||||||
|
runtime/gitops/audit/*.json
|
||||||
|
runtime/gitops/requests/*.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 生产安全边界
|
||||||
|
|
||||||
|
仍然禁止:
|
||||||
|
|
||||||
|
- 直接 main push;
|
||||||
|
- force push;
|
||||||
|
- 写 runtime secrets;
|
||||||
|
- 删除 audit / version history;
|
||||||
|
- 业务模块直接调用 OpenClaw;
|
||||||
|
- 业务模块直接调用 UCC WeChat adapter;
|
||||||
|
- 绕过 message_outbox;
|
||||||
|
- 伪造 sent 状态。
|
||||||
|
|
||||||
|
## 8. 微信链路标准
|
||||||
|
|
||||||
|
生产标准链路:
|
||||||
|
|
||||||
|
```text
|
||||||
|
业务/系统事件
|
||||||
|
→ POST /api/v1/wechat/outbox 或写 message_outbox
|
||||||
|
→ wechat_notify_worker
|
||||||
|
→ wechat_send_backend
|
||||||
|
→ ucc-adapter / openclaw-channel
|
||||||
|
→ 个人微信
|
||||||
|
→ sent/failed/retry/audit
|
||||||
|
```
|
||||||
|
|
||||||
|
当前优先后端:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ucc-adapter
|
||||||
|
```
|
||||||
|
|
||||||
|
OpenClaw channel 仅作为可发现的可选后端,不得硬编码 `openclaw-weixin`。
|
||||||
|
|
||||||
|
## 9. 自我进化目标
|
||||||
|
|
||||||
|
自我进化不是“代码自己改代码”,而是虾马一体系统整体优化:
|
||||||
|
|
||||||
|
1. 业务流程优化;
|
||||||
|
2. 运行健康优化;
|
||||||
|
3. 模型路由优化;
|
||||||
|
4. 通知和审批体验优化;
|
||||||
|
5. WebOps 可观测性优化;
|
||||||
|
6. 必要时生成代码/配置 patch。
|
||||||
|
|
||||||
|
## 10. 进入自动运行的判据
|
||||||
|
|
||||||
|
满足以下条件即认为进入自动运行:
|
||||||
|
|
||||||
|
1. `aios-autonomous-daemon` running;
|
||||||
|
2. OpenClaw/Hermes 进程 running;
|
||||||
|
3. message_outbox 可写;
|
||||||
|
4. Windows Codex 监控目标 branch;
|
||||||
|
5. Codex 可读取 directive 并 push patch;
|
||||||
|
6. 服务器可运行验收命令并写 reports;
|
||||||
|
7. owner 可在 WebOps 或微信收到关键进展。
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# Codex 验收手册:GitOps Platform UI
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
本次变更的目标是把 `cnnetmkt.top` AIOS GitOps Web UI 从基础 dashboard 升级为平台级控制台,形成可审计、可审批、可灰度、可回滚请求的 GitOps 操作入口。
|
||||||
|
|
||||||
|
## 验证分支
|
||||||
|
|
||||||
|
- Branch: `patch/web-ui-complete-gitops-dashboard-20260706`
|
||||||
|
- Base: `main`
|
||||||
|
- 当前目标基线: `V1R1C4`
|
||||||
|
- 合并后建议新基线: `V1R1C5`
|
||||||
|
|
||||||
|
## 主线差异点
|
||||||
|
|
||||||
|
与 `main` 相比,本分支新增或修改以下直接内容:
|
||||||
|
|
||||||
|
1. `overlays/user-control-center/portal/gitops-dashboard/index.html`
|
||||||
|
- 新增平台级 GitOps 控制台页面。
|
||||||
|
- 包含 Overview、RBAC、Approval、Diff Tree、Patch、Model、Release、Audit 模块。
|
||||||
|
|
||||||
|
2. `overlays/user-control-center/portal/gitops-dashboard/app.js`
|
||||||
|
- 新增浏览器运行时 Platform Control Plane。
|
||||||
|
- 包含 API-first client、RBAC guard、approval fallback、audit local store、diff tree parser、model graph、SSE event hook。
|
||||||
|
|
||||||
|
3. `overlays/user-control-center/portal/gitops-dashboard/styles.css`
|
||||||
|
- 控制台样式。
|
||||||
|
|
||||||
|
4. `overlays/user-control-center/portal/gitops-dashboard/manifest.json`
|
||||||
|
- 控制台 manifest。
|
||||||
|
|
||||||
|
5. `overlays/user-control-center/portal/gitops-dashboard/gitops-ui-policy.json`
|
||||||
|
- UI 侧策略声明。
|
||||||
|
|
||||||
|
6. `overlays/user-control-center/portal/gitops-dashboard/gitops-enterprise-api.contract.json`
|
||||||
|
- 后端 API 契约说明。
|
||||||
|
|
||||||
|
7. `patches/patch_web-ui-complete-gitops-dashboard-20260706.branch.json`
|
||||||
|
- GitOps branch 元数据。
|
||||||
|
|
||||||
|
## 间接影响区域
|
||||||
|
|
||||||
|
Codex 验证时必须确认以下间接影响:
|
||||||
|
|
||||||
|
1. Portal 登录入口不应被破坏。
|
||||||
|
- `/portal/me/#gitops`
|
||||||
|
- `/portal/me/#gitops-dashboard`
|
||||||
|
|
||||||
|
2. 现有短链仍应保持登录保护或正常跳转。
|
||||||
|
- `/base`
|
||||||
|
- `/ver`
|
||||||
|
- `/diff`
|
||||||
|
- `/patch`
|
||||||
|
- `/model`
|
||||||
|
- `/run`
|
||||||
|
|
||||||
|
3. 机器 API 不应被前端变更破坏。
|
||||||
|
- `/api/base`
|
||||||
|
- `/api/ver`
|
||||||
|
- `/api/diff`
|
||||||
|
- `/api/patch`
|
||||||
|
- `/api/model`
|
||||||
|
- `/api/run`
|
||||||
|
|
||||||
|
4. 敏感信息不得进入 Git、日志、前端源码或测试产物。
|
||||||
|
- 禁止真实 API key。
|
||||||
|
- 禁止 Bearer token。
|
||||||
|
- 禁止 password / private key。
|
||||||
|
- 允许 `secret_ref://...`。
|
||||||
|
|
||||||
|
5. 高风险操作不得绕过审批。
|
||||||
|
- rollback 只能 request。
|
||||||
|
- tag/release 默认只能 request。
|
||||||
|
- runtime 写入只能 request。
|
||||||
|
- UI 不提供 direct main write。
|
||||||
|
|
||||||
|
## Codex 快速验证命令
|
||||||
|
|
||||||
|
在目标工作区执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch --all --prune
|
||||||
|
git checkout patch/web-ui-complete-gitops-dashboard-20260706
|
||||||
|
git diff --stat main...HEAD
|
||||||
|
bash scripts/validate-gitops-platform-ui.sh patch/web-ui-complete-gitops-dashboard-20260706 main
|
||||||
|
```
|
||||||
|
|
||||||
|
## 灰度验证外的必测项目
|
||||||
|
|
||||||
|
除正常灰度页面点击验证外,Codex 必须按 `tests/acceptance/gitops-platform-ui.acceptance.json` 执行以下测试组:
|
||||||
|
|
||||||
|
1. Static Files
|
||||||
|
- 必须存在 index/html/js/css/manifest/policy/contract 文件。
|
||||||
|
|
||||||
|
2. Security
|
||||||
|
- 源码中不得出现真实 secret/token/password。
|
||||||
|
- 模型注册必须只允许 `secret_ref://`。
|
||||||
|
- rollback/tag/runtime/main write 必须 request-only 或 disabled。
|
||||||
|
|
||||||
|
3. UI Direct Impact
|
||||||
|
- 所有 tab id 必须能在 HTML 中找到。
|
||||||
|
- app.js 必须绑定 `DOMContentLoaded`。
|
||||||
|
- app.js 必须包含 `guardAction`、`audit`、`createApproval`、`renderDiffTree`。
|
||||||
|
|
||||||
|
4. API Contract Compatibility
|
||||||
|
- 读接口保持 `/api/base`、`/api/ver`、`/api/run`、`/api/patch`、`/api/model`。
|
||||||
|
- 写接口必须包含 branch、patch/plan、model、model-priority、rollback-request、tag-request。
|
||||||
|
|
||||||
|
5. Indirect Regression
|
||||||
|
- 不得修改 auth/casdoor 配置。
|
||||||
|
- 不得修改 Gitea owner password sync 配置。
|
||||||
|
- 不得修改 baseline active_version。
|
||||||
|
- 不得删除任何版本/tag/patch 历史。
|
||||||
|
|
||||||
|
## 验收判定
|
||||||
|
|
||||||
|
- PASS: 所有 required 测试项通过,且没有 secret 泄漏、没有 direct main write、没有破坏短链/API。
|
||||||
|
- FAIL: 任一 required 测试项失败。
|
||||||
|
- BLOCKED: 需要真实后端或浏览器环境,但静态检查通过;必须列出 blocked 项,不得合并 main。
|
||||||
|
|
||||||
|
## 失败回流规则
|
||||||
|
|
||||||
|
如果验证失败,Codex 必须输出:
|
||||||
|
|
||||||
|
1. 失败测试 ID。
|
||||||
|
2. 失败文件路径。
|
||||||
|
3. 具体失败原因。
|
||||||
|
4. 最小修改建议。
|
||||||
|
5. 是否阻止合并。
|
||||||
|
|
||||||
|
然后把问题交给自定义 GPT 继续修改同一 patch 分支。修复后重复执行完整验收,直到 PASS。
|
||||||
|
|
||||||
|
## 合并规则
|
||||||
|
|
||||||
|
只有满足以下条件才能合并 main:
|
||||||
|
|
||||||
|
1. `scripts/validate-gitops-platform-ui.sh` PASS。
|
||||||
|
2. acceptance JSON 中所有 `required: true` 项 PASS。
|
||||||
|
3. 灰度验证通过。
|
||||||
|
4. 无 secret 泄漏。
|
||||||
|
5. 无 direct main write。
|
||||||
|
6. 已创建或确认合并后基线提案 `V1R1C5`。
|
||||||
|
|
||||||
|
## 合并后基线要求
|
||||||
|
|
||||||
|
合并进入 main 后,必须定义新基线:
|
||||||
|
|
||||||
|
- Version: `V1R1C5`
|
||||||
|
- Purpose: `Promote AIOS GitOps Platform Control Plane UI with Codex validation workflow, acceptance tests, RBAC/approval/audit/release/model control-plane scaffolding.`
|
||||||
|
- Risk: `high`
|
||||||
|
- Approval: `owner/admin required`
|
||||||
|
- Rollback target: `V1R1C4`
|
||||||
|
|
||||||
|
合并后再创建 tag/version,不允许在未合并或未验收通过前直接创建生产 tag。
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
# Codex 指令:修复 V1R1 微信主动发送后端
|
||||||
|
|
||||||
|
Directive ID: `CODEX-FIX-V1R1-WECHAT-BACKEND-20260707`
|
||||||
|
|
||||||
|
Branch: `patch/web-ui-complete-gitops-dashboard-20260706`
|
||||||
|
|
||||||
|
Base: `main`
|
||||||
|
|
||||||
|
Current baseline: `V1R1C4`
|
||||||
|
|
||||||
|
## 1. 当前阻塞
|
||||||
|
|
||||||
|
当前微信主动发送链路已改为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
message_outbox
|
||||||
|
→ wechat_notify_worker.py
|
||||||
|
→ wechat_send_backend.py
|
||||||
|
→ openclaw-channel 或 ucc-adapter
|
||||||
|
```
|
||||||
|
|
||||||
|
实际验证结果:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"backend": "unavailable",
|
||||||
|
"reason": "wechat outbound backend unavailable: openclaw channel not registered; ucc adapter send endpoint not detected"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
已确认:
|
||||||
|
|
||||||
|
1. OpenClaw Gateway 运行中,但 `openclaw channels list --json` 只有 `{ "chat": {} }`,没有 wechat/weixin channel。
|
||||||
|
2. 当前生产微信能力优先出口应是 `ucc-wechat-qr-adapter`,不是 OpenClaw channel。
|
||||||
|
3. 当前系统存在进程:`/data/agent/services/ucc-wechat-qr-adapter/adapter.py`。
|
||||||
|
4. 当前服务配置:`/etc/aios/ucc-wechat-qr-adapter.env`。
|
||||||
|
5. 当前监听端口:`AIOS_WECHAT_QR_ADAPTER_PORT=18800`。
|
||||||
|
6. `wechat_send_backend.py` 没有探测到 UCC adapter 的可用发送接口。
|
||||||
|
|
||||||
|
## 2. 修复目标
|
||||||
|
|
||||||
|
Codex 必须补齐 V1R1 微信主动发送后端,使 `message_outbox → worker → ucc-adapter → 个人微信` 成为主路径。
|
||||||
|
|
||||||
|
不得让业务模块直接调用:
|
||||||
|
|
||||||
|
- `openclaw message send`;
|
||||||
|
- OpenClaw Gateway `127.0.0.1:18789`;
|
||||||
|
- 微信适配器底层进程;
|
||||||
|
- 本地 secrets 文件。
|
||||||
|
|
||||||
|
业务/外部系统只允许写入:
|
||||||
|
|
||||||
|
```text
|
||||||
|
message_outbox
|
||||||
|
```
|
||||||
|
|
||||||
|
或调用未来统一 API:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/v1/wechat/outbox
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Codex 必须先执行的检查
|
||||||
|
|
||||||
|
只执行命令,不输出 token。
|
||||||
|
|
||||||
|
### 3.1 检查 adapter 路由和函数
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo grep -RniE '@.*route|@app\.(get|post)|FastAPI|Flask|send|message|notify|outbound|wechat|target' \
|
||||||
|
/data/agent/services/ucc-wechat-qr-adapter/adapter.py \
|
||||||
|
| sed -E 's/(token|password|secret|key|auth)([=: ][^ ]+)/\1=***MASKED***/Ig' \
|
||||||
|
| head -n 200
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 检查 adapter 健康接口
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for p in \
|
||||||
|
/health \
|
||||||
|
/status \
|
||||||
|
/api/health \
|
||||||
|
/api/internal/health \
|
||||||
|
/api/internal/wechat/status \
|
||||||
|
/api/internal/wechat/send
|
||||||
|
do
|
||||||
|
echo "=== $p ==="
|
||||||
|
curl -sS "http://127.0.0.1:18800$p" || true
|
||||||
|
echo
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 如需 token,安全读取但不得打印
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ADAPTER_TOKEN="$(sudo awk -F= '/^AIOS_WECHAT_QR_ADAPTER_TOKEN=/{print $2}' /etc/aios/ucc-wechat-qr-adapter.env)"
|
||||||
|
|
||||||
|
for p in \
|
||||||
|
/health \
|
||||||
|
/status \
|
||||||
|
/api/health \
|
||||||
|
/api/internal/health \
|
||||||
|
/api/internal/wechat/status
|
||||||
|
do
|
||||||
|
echo "=== $p ==="
|
||||||
|
curl -sS \
|
||||||
|
-H "Authorization: Bearer ${ADAPTER_TOKEN}" \
|
||||||
|
-H "X-AIOS-Token: ${ADAPTER_TOKEN}" \
|
||||||
|
"http://127.0.0.1:18800$p" || true
|
||||||
|
echo
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 必须实现
|
||||||
|
|
||||||
|
### 4.1 如果 adapter 已有发送接口
|
||||||
|
|
||||||
|
若 `adapter.py` 已经存在发送接口,Codex 应:
|
||||||
|
|
||||||
|
1. 确认真实路径、请求格式、认证头;
|
||||||
|
2. 修改 `scripts/wechat_send_backend.py` 的 `ucc-adapter` backend 调用该真实接口;
|
||||||
|
3. 保证 token 仅从运行时环境或 env 文件读取,不进入 Git;
|
||||||
|
4. 更新验收文档。
|
||||||
|
|
||||||
|
### 4.2 如果 adapter 没有发送接口
|
||||||
|
|
||||||
|
Codex 应在 `/data/agent/services/ucc-wechat-qr-adapter/adapter.py` 对应源码路径中补内部接口,或在 GitOps 仓库新增 overlay/patch 指令,目标接口为:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/internal/wechat/send
|
||||||
|
```
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"target": "...",
|
||||||
|
"content": "...",
|
||||||
|
"account_id": "...",
|
||||||
|
"message_id": "...",
|
||||||
|
"metadata": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
认证:
|
||||||
|
|
||||||
|
- 使用 `/etc/aios/ucc-wechat-qr-adapter.env` 中的 `AIOS_WECHAT_QR_ADAPTER_TOKEN`;
|
||||||
|
- 支持 `Authorization: Bearer <token>`;
|
||||||
|
- 支持 `X-AIOS-Token: <token>`;
|
||||||
|
- 不允许打印 token。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
|
||||||
|
成功:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"provider_message_id": "...",
|
||||||
|
"status": "sent"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
如果底层暂时没有真实发送函数,必须返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": false,
|
||||||
|
"code": "adapter_send_not_supported"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
不得假装 `sent`。
|
||||||
|
|
||||||
|
## 5. 修改范围
|
||||||
|
|
||||||
|
允许修改或新增:
|
||||||
|
|
||||||
|
- `scripts/wechat_send_backend.py`
|
||||||
|
- `scripts/wechat_notify_worker.py`
|
||||||
|
- `scripts/enqueue_wechat_notification.py`
|
||||||
|
- `scripts/message_outbox_schema.py`
|
||||||
|
- `docs/ops/*wechat*`
|
||||||
|
- `tests/acceptance/*wechat*`
|
||||||
|
- UCC adapter 的 overlay/patch 文件
|
||||||
|
- systemd/env template 中与 worker 配置相关的字段
|
||||||
|
|
||||||
|
禁止修改:
|
||||||
|
|
||||||
|
- runtime secrets 明文;
|
||||||
|
- 个人微信原始 token;
|
||||||
|
- Gitea 安全配置;
|
||||||
|
- Casdoor/auth 配置;
|
||||||
|
- baseline active version;
|
||||||
|
- 删除 audit/version/history 的逻辑。
|
||||||
|
|
||||||
|
## 6. 必须新增或更新验收用例
|
||||||
|
|
||||||
|
### TC-WX-BE-01
|
||||||
|
|
||||||
|
OpenClaw channel list 只有 `{ "chat": {} }` 时,不得调用 `openclaw-weixin`。
|
||||||
|
|
||||||
|
预期:
|
||||||
|
|
||||||
|
```text
|
||||||
|
wechat_send_backend 选择 ucc-adapter 或明确 unavailable
|
||||||
|
last_error 不包含 Unknown channel: openclaw-weixin
|
||||||
|
```
|
||||||
|
|
||||||
|
### TC-WX-BE-02
|
||||||
|
|
||||||
|
UCC adapter 存活时自动选择 `ucc-adapter`。
|
||||||
|
|
||||||
|
预期:
|
||||||
|
|
||||||
|
```text
|
||||||
|
python3 scripts/wechat_send_backend.py
|
||||||
|
→ backend=ucc-adapter
|
||||||
|
```
|
||||||
|
|
||||||
|
### TC-WX-BE-03
|
||||||
|
|
||||||
|
outbox 测试消息可进入 worker。
|
||||||
|
|
||||||
|
预期:
|
||||||
|
|
||||||
|
```text
|
||||||
|
message_outbox.status 从 queued 变为 dispatching,再变为 sent 或 failed
|
||||||
|
```
|
||||||
|
|
||||||
|
### TC-WX-BE-04
|
||||||
|
|
||||||
|
如果 adapter 支持真实发送,owner 微信收到测试消息。
|
||||||
|
|
||||||
|
预期:
|
||||||
|
|
||||||
|
```text
|
||||||
|
message_outbox.status=sent
|
||||||
|
audit_logs.action=wechat_outbound_sent
|
||||||
|
```
|
||||||
|
|
||||||
|
### TC-WX-BE-05
|
||||||
|
|
||||||
|
如果 adapter 不支持发送,消息不得丢失。
|
||||||
|
|
||||||
|
预期:
|
||||||
|
|
||||||
|
```text
|
||||||
|
message_outbox.status=queued 或 failed
|
||||||
|
last_error 包含 adapter_send_not_supported
|
||||||
|
audit_logs.action=wechat_outbound_failed
|
||||||
|
```
|
||||||
|
|
||||||
|
### TC-WX-SEC-01
|
||||||
|
|
||||||
|
日志不得泄露:
|
||||||
|
|
||||||
|
- token;
|
||||||
|
- password;
|
||||||
|
- secret;
|
||||||
|
- cookie;
|
||||||
|
- 个人微信原始 ID 给普通用户;
|
||||||
|
- route proof;
|
||||||
|
- runtime secrets 路径。
|
||||||
|
|
||||||
|
## 7. 最小验收命令
|
||||||
|
|
||||||
|
### 7.1 检查 backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /data/aios-gitops/workspace
|
||||||
|
python3 scripts/wechat_send_backend.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 入队测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/enqueue_wechat_notification.py \
|
||||||
|
--recipient owner \
|
||||||
|
--recipient-role owner \
|
||||||
|
--message-type system_report \
|
||||||
|
--priority high \
|
||||||
|
--title "AIOS 测试通知" \
|
||||||
|
--content "这是通过 message_outbox → ucc-adapter 发送的主动微信通知。"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 执行 worker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/wechat_notify_worker.py --once
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 查询状态
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 - <<'PY'
|
||||||
|
import sqlite3
|
||||||
|
con=sqlite3.connect('/data/aios-gitops/workspace/data/webops.sqlite3')
|
||||||
|
con.row_factory=sqlite3.Row
|
||||||
|
print('message_outbox')
|
||||||
|
for r in con.execute('select id,status,retry_count,last_error,sent_at from message_outbox order by created_at desc limit 5'):
|
||||||
|
print(dict(r))
|
||||||
|
print('audit_logs')
|
||||||
|
for r in con.execute('select action,target,detail,created_at from audit_logs order by created_at desc limit 5'):
|
||||||
|
print(dict(r))
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 输出要求
|
||||||
|
|
||||||
|
Codex 完成后必须输出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Status: PASS / FAIL / BLOCKED
|
||||||
|
Branch: patch/web-ui-complete-gitops-dashboard-20260706
|
||||||
|
Files Changed: ...
|
||||||
|
Backend Selected: openclaw-channel / ucc-adapter / unavailable
|
||||||
|
Adapter Send Endpoint: detected / added / unsupported
|
||||||
|
Message Outbox Status: sent / queued / failed
|
||||||
|
Audit Status: written / missing
|
||||||
|
Owner WeChat Received: yes / no / not testable
|
||||||
|
Blocked Reason: ...
|
||||||
|
Next Action: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 当前期望结果
|
||||||
|
|
||||||
|
最优结果:
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend=ucc-adapter
|
||||||
|
message_outbox.status=sent
|
||||||
|
owner 微信收到 AIOS 测试通知
|
||||||
|
```
|
||||||
|
|
||||||
|
可接受结果:
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend=ucc-adapter
|
||||||
|
message_outbox.status=failed
|
||||||
|
last_error=adapter_send_not_supported
|
||||||
|
```
|
||||||
|
|
||||||
|
不可接受结果:
|
||||||
|
|
||||||
|
```text
|
||||||
|
继续报 Unknown channel: openclaw-weixin
|
||||||
|
假装 sent
|
||||||
|
日志打印 token
|
||||||
|
业务模块直接调用 OpenClaw
|
||||||
|
```
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# OpenClaw 指令:AIOS Autonomous GitOps 静默优化与部署监督
|
||||||
|
|
||||||
|
Directive ID: `OPENCLAW-AIOS-AUTONOMOUS-GITOPS-DEPLOY-20260707`
|
||||||
|
|
||||||
|
Branch: `patch/web-ui-complete-gitops-dashboard-20260706`
|
||||||
|
|
||||||
|
Base: `main`
|
||||||
|
|
||||||
|
Current baseline: `V1R1C4`
|
||||||
|
|
||||||
|
Proposed baseline after approved merge: `V1R1C5`
|
||||||
|
|
||||||
|
## 1. 执行目的
|
||||||
|
|
||||||
|
OpenClaw 负责接管本分支的执行与监督,目标是在不影响现有执行层、不破坏用户使用体验的前提下,完成 AIOS Autonomous GitOps Runtime 的验证、静默优化、部署准备、Owner 通报与合并候选推进。
|
||||||
|
|
||||||
|
本次改动的目标不是直接绕过 main 保护,而是让系统形成:
|
||||||
|
|
||||||
|
- 自动 CI 验证;
|
||||||
|
- Codex 自愈修复;
|
||||||
|
- event bus + daemon 自动执行;
|
||||||
|
- 微信/网页 Owner 审计与操作通道;
|
||||||
|
- V1R1C5 基线提案;
|
||||||
|
- 失败回流自定义 GPT 修复;
|
||||||
|
- 通过后生成 merge candidate。
|
||||||
|
|
||||||
|
## 2. OpenClaw 角色
|
||||||
|
|
||||||
|
OpenClaw 是本任务的执行监督代理,职责如下:
|
||||||
|
|
||||||
|
1. 读取并监督当前分支与 main 的差异。
|
||||||
|
2. 按验收测试执行静态验证、安全验证、策略验证、灰度验证。
|
||||||
|
3. 发现失败后,生成结构化 failure report。
|
||||||
|
4. 将失败项交给自定义 GPT 继续修复同一 patch 分支。
|
||||||
|
5. 修复后重复验证,直到 PASS 或触发 hard block。
|
||||||
|
6. 生成 merge candidate,但不得直接绕过 main 保护。
|
||||||
|
7. 监督 Owner 通知记录:每个 main 版本变更必须有摘要通报与 Web UI 记录。
|
||||||
|
|
||||||
|
## 3. 禁止事项
|
||||||
|
|
||||||
|
OpenClaw 不得执行以下操作:
|
||||||
|
|
||||||
|
- 不得直接 force push;
|
||||||
|
- 不得删除版本历史、tag、audit 记录;
|
||||||
|
- 不得写入真实凭据;
|
||||||
|
- 不得关闭 auth、Gitea 登录保护或 owner sync;
|
||||||
|
- 不得在未通过验收前合并 main;
|
||||||
|
- 不得在未有审批或预授权 runbook 前执行生产 tag、baseline activation 或 rollback execution。
|
||||||
|
|
||||||
|
## 4. 必须执行的命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch --all --prune
|
||||||
|
git checkout patch/web-ui-complete-gitops-dashboard-20260706
|
||||||
|
git diff --stat main...HEAD
|
||||||
|
bash scripts/validate-gitops-platform-ui.sh patch/web-ui-complete-gitops-dashboard-20260706 main
|
||||||
|
bash scripts/autonomous-merge-gate.sh patch/web-ui-complete-gitops-dashboard-20260706 main
|
||||||
|
```
|
||||||
|
|
||||||
|
若部署验证需要启动本地 runtime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/install/install.sh
|
||||||
|
python3 services/aios-autonomous-daemon/daemon.py emit --type patch_created --branch patch/web-ui-complete-gitops-dashboard-20260706 --base main
|
||||||
|
python3 services/aios-autonomous-daemon/daemon.py run --once
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 必须验证的直接修改
|
||||||
|
|
||||||
|
OpenClaw 必须检查以下路径:
|
||||||
|
|
||||||
|
- `overlays/user-control-center/portal/gitops-dashboard/`
|
||||||
|
- `services/aios-autonomous-daemon/`
|
||||||
|
- `services/aios-event-ingress/`
|
||||||
|
- `deploy/systemd/`
|
||||||
|
- `deploy/install/`
|
||||||
|
- `config/codex/`
|
||||||
|
- `scripts/`
|
||||||
|
- `tests/acceptance/`
|
||||||
|
- `versions/proposals/V1R1C5-gitops-platform-ui.proposed.json`
|
||||||
|
- `skills/`
|
||||||
|
- `docs/codex/`
|
||||||
|
|
||||||
|
## 6. 必须验证的间接影响
|
||||||
|
|
||||||
|
OpenClaw 必须确认以下内容未被破坏:
|
||||||
|
|
||||||
|
- `/api/base`、`/api/ver`、`/api/diff`、`/api/patch`、`/api/model`、`/api/run`;
|
||||||
|
- `/base`、`/ver`、`/diff`、`/patch`、`/model`、`/run` 登录保护;
|
||||||
|
- Portal 登录入口 `/portal/me/#gitops`;
|
||||||
|
- Gitea require sign-in view;
|
||||||
|
- owner password sync;
|
||||||
|
- active baseline 仍为 `V1R1C4`,未提前激活 `V1R1C5`。
|
||||||
|
|
||||||
|
## 7. Owner 通报要求
|
||||||
|
|
||||||
|
每个 main 版本变更、merge candidate、validation failure、rollback request、approval required 事件都必须形成:
|
||||||
|
|
||||||
|
1. Owner 消息摘要;
|
||||||
|
2. Web UI 审计记录;
|
||||||
|
3. 可追踪 event_id;
|
||||||
|
4. 变更功能摘要;
|
||||||
|
5. 代码量与配置量统计;
|
||||||
|
6. 与上一次主线变化的间隔;
|
||||||
|
7. 测试状态;
|
||||||
|
8. rollback target。
|
||||||
|
|
||||||
|
## 8. OpenClaw 进度监督格式
|
||||||
|
|
||||||
|
OpenClaw 每次监督输出必须包含:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Status: PASS / FAIL / BLOCKED / RUNNING
|
||||||
|
Branch: patch/web-ui-complete-gitops-dashboard-20260706
|
||||||
|
Base: main
|
||||||
|
Current Rev: <rev>
|
||||||
|
Validation: <summary>
|
||||||
|
Gray Test: <summary>
|
||||||
|
Owner Notice: <summary>
|
||||||
|
Merge Candidate: yes/no
|
||||||
|
Blocked Reason: <reason or none>
|
||||||
|
Next Action: <action>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 失败回流规则
|
||||||
|
|
||||||
|
若失败,OpenClaw 必须写入 failure report:
|
||||||
|
|
||||||
|
- 路径:`reports/openclaw/failure-<event_id>.json`
|
||||||
|
- 内容:失败测试 ID、文件路径、失败原因、建议修复、是否阻断合并。
|
||||||
|
|
||||||
|
随后将问题交给自定义 GPT 修改当前 patch 分支,修复后重新执行完整验收。
|
||||||
|
|
||||||
|
## 10. 合并前门禁
|
||||||
|
|
||||||
|
只有全部满足以下条件,OpenClaw 才能标记为 `merge_candidate_ready`:
|
||||||
|
|
||||||
|
- required acceptance tests 全部 PASS;
|
||||||
|
- 无凭据泄露;
|
||||||
|
- 无 direct main write;
|
||||||
|
- 无高风险未审批动作;
|
||||||
|
- 灰度验证 PASS;
|
||||||
|
- Owner 通报与 Web UI 记录正常;
|
||||||
|
- `V1R1C5` baseline proposal 存在且未提前激活。
|
||||||
|
|
||||||
|
## 11. 最终输出
|
||||||
|
|
||||||
|
OpenClaw 最终应生成:
|
||||||
|
|
||||||
|
- `reports/openclaw/final-supervision-report.json`
|
||||||
|
- `reports/codex/merge-candidate.json`
|
||||||
|
- Owner 摘要事件记录;
|
||||||
|
- Web UI audit record;
|
||||||
|
- 若失败则生成 failure report 并阻止合并。
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
/* AIOS GitOps Platform Control Plane
|
||||||
|
* Browser-only runtime with API-first behavior and safe local fallback.
|
||||||
|
* No credentials or secret values are stored here. Model registration accepts secret_ref only.
|
||||||
|
*/
|
||||||
|
const Platform = (() => {
|
||||||
|
const state = {
|
||||||
|
tenant: 'default',
|
||||||
|
role: 'viewer',
|
||||||
|
identity: null,
|
||||||
|
baseline: null,
|
||||||
|
versions: null,
|
||||||
|
runtime: null,
|
||||||
|
patches: null,
|
||||||
|
models: null,
|
||||||
|
approvals: [],
|
||||||
|
audit: [],
|
||||||
|
policy: null,
|
||||||
|
eventSource: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENDPOINTS = {
|
||||||
|
identity: 'identity',
|
||||||
|
policy: 'platform/policy',
|
||||||
|
audit: 'audit',
|
||||||
|
approvals: 'approval',
|
||||||
|
approvalRequest: 'approval-request',
|
||||||
|
approvalDecision: 'approval-decision',
|
||||||
|
branch: 'branch',
|
||||||
|
plan: 'patch/plan',
|
||||||
|
base: 'base',
|
||||||
|
versions: 'ver',
|
||||||
|
diff: 'diff',
|
||||||
|
patch: 'patch',
|
||||||
|
model: 'model',
|
||||||
|
priority: 'model-priority',
|
||||||
|
runtime: 'run',
|
||||||
|
rollback: 'rollback-request',
|
||||||
|
tag: 'tag-request',
|
||||||
|
events: 'events/gitops'
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_POLICY = {
|
||||||
|
roles: {
|
||||||
|
viewer: ['read:*', 'audit:read'],
|
||||||
|
operator: ['read:*', 'audit:read', 'branch:create', 'plan:create', 'approval:create'],
|
||||||
|
admin: ['read:*', 'audit:read', 'branch:create', 'plan:create', 'approval:create', 'model:write', 'priority:write', 'release:request', 'rollback:request'],
|
||||||
|
owner: ['read:*', 'audit:*', 'branch:*', 'plan:*', 'approval:*', 'model:*', 'priority:*', 'release:*', 'rollback:*', 'runtime:*']
|
||||||
|
},
|
||||||
|
highRiskActions: ['release:execute', 'rollback:execute', 'runtime:write', 'tag:create', 'main:write'],
|
||||||
|
destructiveActions: ['delete:*', 'force_push', 'purge:*', 'clear:*'],
|
||||||
|
requiredApprovals: { low: 1, medium: 1, high: 2 },
|
||||||
|
tenantIsolation: true,
|
||||||
|
secretHygiene: {
|
||||||
|
allowSecretRefOnly: true,
|
||||||
|
forbiddenFields: ['api_key', 'token', 'password', 'secret', 'private_key', 'bearer']
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function el(id) { return document.getElementById(id); }
|
||||||
|
function qs(sel) { return document.querySelector(sel); }
|
||||||
|
function qsa(sel) { return Array.from(document.querySelectorAll(sel)); }
|
||||||
|
|
||||||
|
function now() { return new Date().toISOString(); }
|
||||||
|
|
||||||
|
function safeStringify(value) {
|
||||||
|
return JSON.stringify(redact(value), null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function redact(value) {
|
||||||
|
if (Array.isArray(value)) return value.map(redact);
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const out = {};
|
||||||
|
for (const [key, val] of Object.entries(value)) {
|
||||||
|
const k = key.toLowerCase();
|
||||||
|
if (['api_key', 'apikey', 'token', 'password', 'secret', 'private_key', 'authorization', 'bearer'].some(x => k.includes(x))) {
|
||||||
|
out[key] = typeof val === 'string' && val.startsWith('secret_ref://') ? val : '[REDACTED]';
|
||||||
|
} else out[key] = redact(val);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function put(id, value) {
|
||||||
|
const target = el(id);
|
||||||
|
if (target) target.textContent = typeof value === 'string' ? value : safeStringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, method = 'GET', body = undefined, options = {}) {
|
||||||
|
const url = `/api/${path}`;
|
||||||
|
const headers = { 'Content-Type': 'application/json', 'X-AIOS-Tenant': state.tenant };
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body)
|
||||||
|
});
|
||||||
|
let data = null;
|
||||||
|
try { data = await res.json(); } catch (_) { data = { status: res.status, text: await res.text() }; }
|
||||||
|
if (!res.ok && !options.allowError) {
|
||||||
|
const err = new Error(`API ${method} ${url} failed with ${res.status}`);
|
||||||
|
err.response = data;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function has(capability) {
|
||||||
|
const policy = state.policy || DEFAULT_POLICY;
|
||||||
|
const caps = policy.roles[state.role] || [];
|
||||||
|
return caps.includes('*') || caps.includes(capability) || caps.some(c => c.endsWith(':*') && capability.startsWith(c.slice(0, -1))) || caps.includes('read:*') && capability.startsWith('read:');
|
||||||
|
}
|
||||||
|
|
||||||
|
function guardAction(capability, risk = 'low') {
|
||||||
|
const policy = state.policy || DEFAULT_POLICY;
|
||||||
|
if (policy.destructiveActions.some(pattern => matchCapability(pattern, capability))) return { allowed: false, reason: 'destructive_action_blocked' };
|
||||||
|
if (policy.highRiskActions.includes(capability)) return { allowed: false, reason: 'high_risk_requires_approval' };
|
||||||
|
if (!has(capability)) return { allowed: false, reason: 'insufficient_role' };
|
||||||
|
if (risk === 'high' && !['admin', 'owner'].includes(state.role)) return { allowed: false, reason: 'high_risk_admin_required' };
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchCapability(pattern, capability) {
|
||||||
|
if (pattern === capability) return true;
|
||||||
|
if (pattern.endsWith('*')) return capability.startsWith(pattern.slice(0, -1));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function audit(action, target, status, detail = {}) {
|
||||||
|
const record = redact({
|
||||||
|
ts: now(), tenant: state.tenant, role: state.role, action, target, status, detail
|
||||||
|
});
|
||||||
|
state.audit.unshift(record);
|
||||||
|
state.audit = state.audit.slice(0, 500);
|
||||||
|
localStorage.setItem('aios.gitops.audit', JSON.stringify(state.audit));
|
||||||
|
put('audit-output', record);
|
||||||
|
renderAudit();
|
||||||
|
postAudit(record);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postAudit(record) {
|
||||||
|
try { await api(ENDPOINTS.audit, 'POST', record, { allowError: true }); } catch (_) { /* local fallback */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLocal() {
|
||||||
|
try { state.audit = JSON.parse(localStorage.getItem('aios.gitops.audit') || '[]'); } catch (_) { state.audit = []; }
|
||||||
|
try { state.approvals = JSON.parse(localStorage.getItem('aios.gitops.approvals') || '[]'); } catch (_) { state.approvals = []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveApprovals() {
|
||||||
|
localStorage.setItem('aios.gitops.approvals', JSON.stringify(state.approvals));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
loadLocal();
|
||||||
|
wireTabs();
|
||||||
|
wireForms();
|
||||||
|
await loadIdentity();
|
||||||
|
await loadPolicy();
|
||||||
|
applyRbac();
|
||||||
|
await refreshAll();
|
||||||
|
await refreshApprovals();
|
||||||
|
await refreshAudit();
|
||||||
|
connectEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadIdentity() {
|
||||||
|
try {
|
||||||
|
const identity = await api(ENDPOINTS.identity, 'GET', undefined, { allowError: true });
|
||||||
|
state.identity = identity && identity.user ? identity : { user: 'unknown', role: identity.role || 'viewer', tenant: identity.tenant || 'default' };
|
||||||
|
state.role = state.identity.role || 'viewer';
|
||||||
|
state.tenant = state.identity.tenant || 'default';
|
||||||
|
} catch (_) {
|
||||||
|
state.identity = { user: 'local-preview', role: 'admin', tenant: 'default', source: 'local_fallback' };
|
||||||
|
state.role = 'admin';
|
||||||
|
state.tenant = 'default';
|
||||||
|
}
|
||||||
|
put('identity-output', state.identity);
|
||||||
|
const roleChip = el('role-chip');
|
||||||
|
if (roleChip) roleChip.textContent = `role: ${state.role}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPolicy() {
|
||||||
|
try {
|
||||||
|
const policy = await api(ENDPOINTS.policy, 'GET', undefined, { allowError: true });
|
||||||
|
state.policy = policy && policy.roles ? policy : DEFAULT_POLICY;
|
||||||
|
} catch (_) { state.policy = DEFAULT_POLICY; }
|
||||||
|
put('rbac-output', state.policy);
|
||||||
|
renderPermissionMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
const tasks = [
|
||||||
|
['baseline', ENDPOINTS.base],
|
||||||
|
['versions', ENDPOINTS.versions],
|
||||||
|
['runtime', ENDPOINTS.runtime],
|
||||||
|
['patches', ENDPOINTS.patch],
|
||||||
|
['models', ENDPOINTS.model]
|
||||||
|
];
|
||||||
|
for (const [key, endpoint] of tasks) {
|
||||||
|
try { state[key] = await api(endpoint, 'GET', undefined, { allowError: true }); }
|
||||||
|
catch (e) { state[key] = { error: e.message, response: e.response }; }
|
||||||
|
}
|
||||||
|
put('baseline-output', state.baseline);
|
||||||
|
put('versions-output', state.versions);
|
||||||
|
put('runtime-output', state.runtime);
|
||||||
|
put('patch-summary-output', state.patches);
|
||||||
|
put('model-output', state.models);
|
||||||
|
updateMetrics();
|
||||||
|
renderModelGraph();
|
||||||
|
audit('dashboard.refresh', 'all', 'ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMetrics() {
|
||||||
|
const runtimeStatus = state.runtime && state.runtime.status || 'unknown';
|
||||||
|
const version = state.baseline && (state.baseline.active_version || state.baseline.version) || '-';
|
||||||
|
const branches = state.patches && Array.isArray(state.patches.branches) ? state.patches.branches.filter(b => b.startsWith('patch/')).length : 0;
|
||||||
|
const pending = state.approvals.filter(a => a.status === 'pending').length;
|
||||||
|
setText('metric-runtime', runtimeStatus);
|
||||||
|
setText('metric-version', version);
|
||||||
|
setText('metric-branches', String(branches));
|
||||||
|
setText('metric-approvals', String(pending));
|
||||||
|
setText('runtime-chip', `runtime: ${runtimeStatus}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setText(id, text) { const node = el(id); if (node) node.textContent = text; }
|
||||||
|
|
||||||
|
function applyRbac() {
|
||||||
|
qsa('.require-operator').forEach(btn => btn.disabled = !has('approval:create') && !has('branch:create'));
|
||||||
|
qsa('.require-admin').forEach(btn => btn.disabled = !['admin', 'owner'].includes(state.role));
|
||||||
|
qsa('.guarded').forEach(btn => btn.disabled = state.role !== 'owner');
|
||||||
|
renderPermissionMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPermissionMatrix() {
|
||||||
|
const policy = state.policy || DEFAULT_POLICY;
|
||||||
|
const caps = ['read:*', 'branch:create', 'plan:create', 'approval:create', 'model:write', 'priority:write', 'release:request', 'rollback:request', 'runtime:write'];
|
||||||
|
const rows = Object.keys(policy.roles).map(role => {
|
||||||
|
const cells = caps.map(cap => `<td>${roleHas(role, cap, policy) ? '✅' : '—'}</td>`).join('');
|
||||||
|
return `<tr><th>${escapeHtml(role)}</th>${cells}</tr>`;
|
||||||
|
}).join('');
|
||||||
|
const html = `<table><thead><tr><th>Role</th>${caps.map(c => `<th>${escapeHtml(c)}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table>`;
|
||||||
|
const target = el('permission-matrix'); if (target) target.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleHas(role, cap, policy) {
|
||||||
|
const caps = policy.roles[role] || [];
|
||||||
|
return caps.includes('*') || caps.includes(cap) || caps.some(c => c.endsWith(':*') && cap.startsWith(c.slice(0, -1))) || (caps.includes('read:*') && cap.startsWith('read:'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModelGraph() {
|
||||||
|
const target = el('model-priority-graph');
|
||||||
|
if (!target) return;
|
||||||
|
const base = state.models && Array.isArray(state.models.base) ? state.models.base : [];
|
||||||
|
const custom = state.models && Array.isArray(state.models.custom) ? state.models.custom : [];
|
||||||
|
const priority = state.models && Array.isArray(state.models.priority) ? state.models.priority : [];
|
||||||
|
const models = [...base, ...custom];
|
||||||
|
if (!models.length) { target.innerHTML = '<p class="empty">无模型数据</p>'; return; }
|
||||||
|
const byId = new Map(priority.map(p => [p.model_id || p.id, p]));
|
||||||
|
target.innerHTML = models.map((m, idx) => {
|
||||||
|
const p = byId.get(m.id) || { priority: 100 - idx, enabled: true, fallback_policy: 'next_enabled' };
|
||||||
|
return `<div class="node"><strong>${escapeHtml(m.display_name || m.model || m.id)}</strong><span>${escapeHtml(m.id || '')}</span><em>priority ${escapeHtml(String(p.priority))} · ${p.enabled === false ? 'disabled' : 'enabled'} · ${escapeHtml(p.fallback_policy || 'next_enabled')}</em></div>`;
|
||||||
|
}).join('<div class="edge">↓ fallback</div>');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshApprovals() {
|
||||||
|
try {
|
||||||
|
const remote = await api(ENDPOINTS.approvals, 'GET', undefined, { allowError: true });
|
||||||
|
if (remote && Array.isArray(remote.items)) state.approvals = remote.items;
|
||||||
|
else if (Array.isArray(remote)) state.approvals = remote;
|
||||||
|
} catch (_) { /* local approvals already loaded */ }
|
||||||
|
renderApprovals();
|
||||||
|
updateMetrics();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderApprovals() {
|
||||||
|
const target = el('approval-board');
|
||||||
|
if (!target) return;
|
||||||
|
if (!state.approvals.length) { target.innerHTML = '<p class="empty">暂无审批请求</p>'; return; }
|
||||||
|
target.innerHTML = state.approvals.map(item => {
|
||||||
|
const canDecide = has('approval:*') || state.role === 'owner';
|
||||||
|
return `<article class="approval ${escapeHtml(item.risk || 'medium')}">
|
||||||
|
<header><strong>${escapeHtml(item.type)}</strong><span>${escapeHtml(item.status)}</span></header>
|
||||||
|
<p>${escapeHtml(item.target)}</p><small>${escapeHtml(item.reason || '')}</small>
|
||||||
|
<footer>${canDecide && item.status === 'pending' ? `<button data-approve="${escapeHtml(item.id)}">Approve</button><button data-reject="${escapeHtml(item.id)}">Reject</button>` : ''}</footer>
|
||||||
|
</article>`;
|
||||||
|
}).join('');
|
||||||
|
qsa('[data-approve]').forEach(btn => btn.onclick = () => decideApproval(btn.dataset.approve, 'approved'));
|
||||||
|
qsa('[data-reject]').forEach(btn => btn.onclick = () => decideApproval(btn.dataset.reject, 'rejected'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createApproval(payload) {
|
||||||
|
const guard = guardAction('approval:create', payload.risk || 'medium');
|
||||||
|
if (!guard.allowed) return { status: 'blocked', ...guard };
|
||||||
|
const item = {
|
||||||
|
id: `apr-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
|
||||||
|
tenant: state.tenant,
|
||||||
|
status: 'pending',
|
||||||
|
created_at: now(),
|
||||||
|
created_by: state.identity && state.identity.user || 'unknown',
|
||||||
|
...payload
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const remote = await api(ENDPOINTS.approvalRequest, 'POST', item, { allowError: true });
|
||||||
|
if (remote && (remote.id || remote.status)) {
|
||||||
|
audit('approval.create', item.target, 'ok', remote);
|
||||||
|
await refreshApprovals();
|
||||||
|
return remote;
|
||||||
|
}
|
||||||
|
} catch (_) { /* local fallback */ }
|
||||||
|
state.approvals.unshift(item); saveApprovals(); renderApprovals(); updateMetrics();
|
||||||
|
audit('approval.create.local', item.target, 'ok', item);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decideApproval(id, status) {
|
||||||
|
const guard = guardAction('approval:decide', 'high');
|
||||||
|
if (!guard.allowed) { audit('approval.decide', id, 'blocked', guard); return; }
|
||||||
|
try {
|
||||||
|
const remote = await api(ENDPOINTS.approvalDecision, 'POST', { id, status }, { allowError: true });
|
||||||
|
audit('approval.decide', id, 'ok', remote);
|
||||||
|
} catch (_) {
|
||||||
|
const item = state.approvals.find(a => a.id === id);
|
||||||
|
if (item) { item.status = status; item.decided_at = now(); item.decided_by = state.identity && state.identity.user || 'unknown'; }
|
||||||
|
saveApprovals(); audit('approval.decide.local', id, 'ok', { status });
|
||||||
|
}
|
||||||
|
renderApprovals(); updateMetrics();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAudit() {
|
||||||
|
try {
|
||||||
|
const remote = await api(ENDPOINTS.audit, 'GET', undefined, { allowError: true });
|
||||||
|
if (remote && Array.isArray(remote.items)) state.audit = remote.items.concat(state.audit).slice(0, 500);
|
||||||
|
} catch (_) { /* local fallback */ }
|
||||||
|
renderAudit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAudit() {
|
||||||
|
const target = el('audit-table'); if (!target) return;
|
||||||
|
const filter = (el('audit-filter') && el('audit-filter').value || '').toLowerCase();
|
||||||
|
const rows = state.audit.filter(r => !filter || JSON.stringify(r).toLowerCase().includes(filter)).slice(0, 100).map(r =>
|
||||||
|
`<tr><td>${escapeHtml(r.ts)}</td><td>${escapeHtml(r.tenant || '')}</td><td>${escapeHtml(r.role || '')}</td><td>${escapeHtml(r.action)}</td><td>${escapeHtml(r.target)}</td><td>${escapeHtml(r.status)}</td></tr>`
|
||||||
|
).join('');
|
||||||
|
target.innerHTML = `<table><thead><tr><th>Time</th><th>Tenant</th><th>Role</th><th>Action</th><th>Target</th><th>Status</th></tr></thead><tbody>${rows || '<tr><td colspan="6">无审计记录</td></tr>'}</tbody></table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDiffTree(diffData) {
|
||||||
|
const stdout = diffData && diffData.diff && diffData.diff.stdout || diffData && diffData.stdout || '';
|
||||||
|
const files = [];
|
||||||
|
let current = null;
|
||||||
|
stdout.split('\n').forEach(line => {
|
||||||
|
if (line.startsWith('diff --git ')) {
|
||||||
|
const parts = line.split(' ');
|
||||||
|
current = { file: (parts[3] || '').replace(/^b\//, ''), additions: 0, deletions: 0, chunks: 0 };
|
||||||
|
files.push(current);
|
||||||
|
} else if (current) {
|
||||||
|
if (line.startsWith('@@')) current.chunks++;
|
||||||
|
else if (line.startsWith('+') && !line.startsWith('+++')) current.additions++;
|
||||||
|
else if (line.startsWith('-') && !line.startsWith('---')) current.deletions++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDiffTree(diffData) {
|
||||||
|
const target = el('diff-tree'); if (!target) return;
|
||||||
|
const files = parseDiffTree(diffData);
|
||||||
|
if (!files.length) { target.innerHTML = '<p class="empty">未解析到文件级 diff,可能是接口返回过大或为空。</p>'; return; }
|
||||||
|
target.innerHTML = files.map(f => `<div class="diff-file"><strong>${escapeHtml(f.file)}</strong><span>+${f.additions} / -${f.deletions} / ${f.chunks} chunks</span></div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectEvents() {
|
||||||
|
if (!window.EventSource) return;
|
||||||
|
try {
|
||||||
|
state.eventSource = new EventSource(`/api/${ENDPOINTS.events}?tenant=${encodeURIComponent(state.tenant)}`);
|
||||||
|
state.eventSource.onmessage = (event) => {
|
||||||
|
const payload = JSON.parse(event.data);
|
||||||
|
audit('event.gitops', payload.type || 'event', 'ok', payload);
|
||||||
|
if (['approval', 'patch', 'runtime', 'model'].includes(payload.type)) refreshAll();
|
||||||
|
};
|
||||||
|
state.eventSource.onerror = () => { if (state.eventSource) state.eventSource.close(); };
|
||||||
|
} catch (_) { /* server-sent events optional */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireTabs() {
|
||||||
|
qsa('.tabs button').forEach(button => {
|
||||||
|
button.onclick = () => {
|
||||||
|
qsa('.tabs button').forEach(b => b.classList.remove('active'));
|
||||||
|
qsa('.tab').forEach(tab => tab.classList.remove('active'));
|
||||||
|
button.classList.add('active');
|
||||||
|
const target = el(`tab-${button.dataset.tab}`);
|
||||||
|
if (target) target.classList.add('active');
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireForms() {
|
||||||
|
const refreshBtn = el('refresh-all'); if (refreshBtn) refreshBtn.onclick = refreshAll;
|
||||||
|
const refreshModel = el('refresh-model'); if (refreshModel) refreshModel.onclick = refreshAll;
|
||||||
|
const refreshApprovalsBtn = el('refresh-approvals'); if (refreshApprovalsBtn) refreshApprovalsBtn.onclick = refreshApprovals;
|
||||||
|
const clearApprovals = el('clear-local-approvals'); if (clearApprovals) clearApprovals.onclick = () => { state.approvals = []; saveApprovals(); renderApprovals(); audit('approval.local.clear', 'localStorage', 'ok'); };
|
||||||
|
const refreshAuditBtn = el('refresh-audit'); if (refreshAuditBtn) refreshAuditBtn.onclick = refreshAudit;
|
||||||
|
const auditFilter = el('audit-filter'); if (auditFilter) auditFilter.oninput = renderAudit;
|
||||||
|
const exportAudit = el('export-audit'); if (exportAudit) exportAudit.onclick = () => downloadJson('aios-gitops-audit.json', state.audit);
|
||||||
|
|
||||||
|
bindForm('approval-form', async f => createApproval({ type: f.get('type'), target: f.get('target'), risk: f.get('risk'), reason: f.get('reason') }), 'approval-output');
|
||||||
|
bindForm('diff-form', async f => {
|
||||||
|
const data = await api(`${ENDPOINTS.diff}?from=${encodeURIComponent(f.get('from'))}&to=${encodeURIComponent(f.get('to'))}`, 'GET', undefined, { allowError: true });
|
||||||
|
renderDiffTree(data); return data;
|
||||||
|
}, 'diff-output');
|
||||||
|
bindForm('branch-form', async f => checkedApi('branch:create', ENDPOINTS.branch, 'POST', { branch: f.get('branch'), base: f.get('base'), message: f.get('message') }), 'branch-output');
|
||||||
|
bindForm('plan-form', async f => checkedApi('plan:create', ENDPOINTS.plan, 'POST', { branch: f.get('branch'), text: f.get('text') }), 'plan-output');
|
||||||
|
bindForm('model-form', async f => {
|
||||||
|
const payload = { provider_name: f.get('provider_name'), base_url: f.get('base_url'), model_name: f.get('model_name'), display_name: f.get('display_name'), api_key_secret_ref: f.get('api_key_secret_ref') };
|
||||||
|
if (!String(payload.api_key_secret_ref || '').startsWith('secret_ref://')) return { status: 'blocked', reason: 'api_key_secret_ref_required' };
|
||||||
|
return checkedApi('model:write', ENDPOINTS.model, 'POST', payload, 'high');
|
||||||
|
}, 'model-register-output');
|
||||||
|
bindForm('priority-form', async f => checkedApi('priority:write', ENDPOINTS.priority, 'PUT', { model_id: f.get('model_id'), priority: Number(f.get('priority')), enabled: f.get('enabled') === 'true', fallback_policy: f.get('fallback_policy') }, 'medium'), 'priority-output');
|
||||||
|
bindForm('rollback-form', async f => checkedApi('rollback:request', ENDPOINTS.rollback, 'POST', { target_version: f.get('target_version'), reason: f.get('reason'), branch: f.get('branch') }, 'high'), 'rollback-output');
|
||||||
|
bindForm('tag-form', async f => checkedApi('release:request', ENDPOINTS.tag, 'POST', { version: f.get('version'), message: f.get('message') }, 'high'), 'tag-output');
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindForm(id, handler, outputId) {
|
||||||
|
const form = el(id); if (!form) return;
|
||||||
|
form.onsubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const f = new FormData(form);
|
||||||
|
try {
|
||||||
|
const result = await handler(f);
|
||||||
|
put(outputId, result);
|
||||||
|
audit(`${id}.submit`, outputId, result && result.status === 'blocked' ? 'blocked' : 'ok', result);
|
||||||
|
} catch (err) {
|
||||||
|
const detail = { error: err.message, response: err.response };
|
||||||
|
put(outputId, detail);
|
||||||
|
audit(`${id}.submit`, outputId, 'failed', detail);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkedApi(capability, endpoint, method, payload, risk = 'low') {
|
||||||
|
const guard = guardAction(capability, risk);
|
||||||
|
if (!guard.allowed) return { status: 'blocked', capability, ...guard };
|
||||||
|
return await api(endpoint, method, payload, { allowError: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadJson(filename, value) {
|
||||||
|
const blob = new Blob([safeStringify(value)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url; a.download = filename; a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value == null ? '' : value).replace(/[&<>'"]/g, ch => ({ '&': '&', '<': '<', '>': '>', '\'': ''', '"': '"' }[ch]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bootstrap, refreshAll, state };
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.addEventListener('DOMContentLoaded', Platform.bootstrap);
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"name": "aios-gitops-enterprise-api-contract",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scope": "portal-gitops-dashboard",
|
||||||
|
"principles": {
|
||||||
|
"all_writes_require_git_commit": true,
|
||||||
|
"direct_main_push_from_ui": false,
|
||||||
|
"secrets_in_git": false,
|
||||||
|
"destructive_operations": "forbidden_or_request_only",
|
||||||
|
"server_side_authz_is_authoritative": true
|
||||||
|
},
|
||||||
|
"read_endpoints": {
|
||||||
|
"baseline": "GET /api/base",
|
||||||
|
"versions": "GET /api/ver",
|
||||||
|
"runtime": "GET /api/run",
|
||||||
|
"patches": "GET /api/patch",
|
||||||
|
"model_registry": "GET /api/model",
|
||||||
|
"diff_raw": "GET /api/diff?from={from}&to={to}",
|
||||||
|
"diff_tree": "GET /api/diff/tree?from={from}&to={to}",
|
||||||
|
"approval_queue": "GET /api/approval/queue",
|
||||||
|
"release_pipeline": "GET /api/release/pipeline",
|
||||||
|
"audit_log": "GET /api/audit/log?limit={limit}",
|
||||||
|
"me": "GET /api/me"
|
||||||
|
},
|
||||||
|
"write_endpoints": {
|
||||||
|
"create_branch": "POST /api/branch",
|
||||||
|
"natural_language_plan": "POST /api/patch/plan",
|
||||||
|
"register_model": "POST /api/model",
|
||||||
|
"set_model_priority": "PUT /api/model-priority",
|
||||||
|
"rollback_request": "POST /api/rollback-request",
|
||||||
|
"tag_request": "POST /api/tag-request",
|
||||||
|
"approval_decision": "POST /api/approval/decision",
|
||||||
|
"release_promotion_request": "POST /api/release/promote-request",
|
||||||
|
"audit_append": "POST /api/audit/log"
|
||||||
|
},
|
||||||
|
"approval_decision_payload": {
|
||||||
|
"request_id": "string",
|
||||||
|
"decision": "approve | reject",
|
||||||
|
"reason": "string",
|
||||||
|
"branch": "patch/approval-..."
|
||||||
|
},
|
||||||
|
"release_promotion_payload": {
|
||||||
|
"from_environment": "dev | staging",
|
||||||
|
"to_environment": "staging | prod",
|
||||||
|
"version": "VxRxCx",
|
||||||
|
"reason": "string",
|
||||||
|
"branch": "patch/release-promote-..."
|
||||||
|
},
|
||||||
|
"response_contract": {
|
||||||
|
"write_success_any_of": ["branch", "commit", "rev", "sync_status"],
|
||||||
|
"auth_failure": {
|
||||||
|
"401": "Bearer token not injected",
|
||||||
|
"403": "Bearer token injected but scope insufficient"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{
|
||||||
|
"name": "aios-gitops-enterprise-ui-policy",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scope": "portal-gitops-dashboard",
|
||||||
|
"client_side_notice": "Client-side RBAC is advisory only. Server-side Actions/API authorization remains authoritative.",
|
||||||
|
"roles": {
|
||||||
|
"viewer": {
|
||||||
|
"label": "Viewer",
|
||||||
|
"permissions": [
|
||||||
|
"read:baseline",
|
||||||
|
"read:versions",
|
||||||
|
"read:diff",
|
||||||
|
"read:patch",
|
||||||
|
"read:model",
|
||||||
|
"read:runtime",
|
||||||
|
"read:audit"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"developer": {
|
||||||
|
"label": "Developer",
|
||||||
|
"inherits": ["viewer"],
|
||||||
|
"permissions": [
|
||||||
|
"write:branch",
|
||||||
|
"write:plan"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"label": "Admin",
|
||||||
|
"inherits": ["developer"],
|
||||||
|
"permissions": [
|
||||||
|
"write:model",
|
||||||
|
"write:model_priority",
|
||||||
|
"request:rollback",
|
||||||
|
"request:tag"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"owner": {
|
||||||
|
"label": "Owner",
|
||||||
|
"inherits": ["admin"],
|
||||||
|
"permissions": [
|
||||||
|
"approve:rollback",
|
||||||
|
"approve:tag",
|
||||||
|
"approve:priority",
|
||||||
|
"approve:release"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"risk_policy": {
|
||||||
|
"direct_main_write": "disabled_in_ui",
|
||||||
|
"api_keys_in_git": "forbidden",
|
||||||
|
"rollback": "request_only",
|
||||||
|
"tag_create": "request_only_by_default",
|
||||||
|
"runtime_change": "request_only",
|
||||||
|
"baseline_high_risk_change": "request_only",
|
||||||
|
"bulk_delete": "forbidden"
|
||||||
|
},
|
||||||
|
"secret_redaction": {
|
||||||
|
"enabled": true,
|
||||||
|
"patterns": [
|
||||||
|
"api_key",
|
||||||
|
"apikey",
|
||||||
|
"authorization",
|
||||||
|
"bearer",
|
||||||
|
"token",
|
||||||
|
"password",
|
||||||
|
"secret",
|
||||||
|
"private_key",
|
||||||
|
"credential"
|
||||||
|
],
|
||||||
|
"replacement": "[REDACTED]"
|
||||||
|
},
|
||||||
|
"approval_queues": [
|
||||||
|
"rollback",
|
||||||
|
"tag",
|
||||||
|
"model_priority",
|
||||||
|
"release",
|
||||||
|
"runtime_change"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>AIOS Enterprise GitOps Control Plane</title>
|
||||||
|
<link rel="stylesheet" href="./styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">cnnetmkt.top / AIOS Enterprise</p>
|
||||||
|
<h1>GitOps Enterprise Control Plane</h1>
|
||||||
|
<p class="subtitle">审批流、RBAC、Release Pipeline、Diff Tree、模型优先级、审计查询</p>
|
||||||
|
</div>
|
||||||
|
<div class="statusbar">
|
||||||
|
<span id="role-chip" class="chip">role: loading</span>
|
||||||
|
<span id="runtime-chip" class="chip muted">runtime: loading</span>
|
||||||
|
<button id="refresh-all" class="primary">刷新全部</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="tabs" aria-label="Enterprise GitOps modules">
|
||||||
|
<button data-tab="overview" class="active">总览</button>
|
||||||
|
<button data-tab="rbac">RBAC</button>
|
||||||
|
<button data-tab="approval">审批流</button>
|
||||||
|
<button data-tab="diff">Diff Tree</button>
|
||||||
|
<button data-tab="patch">Patch / Branch</button>
|
||||||
|
<button data-tab="model">模型控制</button>
|
||||||
|
<button data-tab="release">Release Pipeline</button>
|
||||||
|
<button data-tab="audit">审计中心</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section id="tab-overview" class="tab active">
|
||||||
|
<div class="grid four">
|
||||||
|
<article class="metric"><span>Runtime</span><strong id="metric-runtime">-</strong></article>
|
||||||
|
<article class="metric"><span>Active Version</span><strong id="metric-version">-</strong></article>
|
||||||
|
<article class="metric"><span>Patch Branches</span><strong id="metric-branches">-</strong></article>
|
||||||
|
<article class="metric"><span>Pending Approvals</span><strong id="metric-approvals">-</strong></article>
|
||||||
|
</div>
|
||||||
|
<div class="grid two">
|
||||||
|
<article class="card"><h2>Baseline</h2><pre id="baseline-output">loading...</pre></article>
|
||||||
|
<article class="card"><h2>Versions</h2><pre id="versions-output">loading...</pre></article>
|
||||||
|
<article class="card"><h2>Runtime</h2><pre id="runtime-output">loading...</pre></article>
|
||||||
|
<article class="card"><h2>Patch Summary</h2><pre id="patch-summary-output">loading...</pre></article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="tab-rbac" class="tab grid two">
|
||||||
|
<article class="card">
|
||||||
|
<h2>当前用户权限</h2>
|
||||||
|
<p class="hint">按钮级权限由 role + capability 共同控制;viewer 只能读。</p>
|
||||||
|
<pre id="identity-output">loading...</pre>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h2>RBAC 策略</h2>
|
||||||
|
<pre id="rbac-output">loading...</pre>
|
||||||
|
</article>
|
||||||
|
<article class="card span-two">
|
||||||
|
<h2>权限矩阵</h2>
|
||||||
|
<div id="permission-matrix" class="table-wrap">loading...</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="tab-approval" class="tab">
|
||||||
|
<div class="grid two">
|
||||||
|
<article class="card">
|
||||||
|
<h2>Approval Queue</h2>
|
||||||
|
<p class="hint">高风险操作进入审批队列;UI 不直接执行 destructive / production 变更。</p>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button id="refresh-approvals" class="secondary">刷新审批队列</button>
|
||||||
|
<button id="clear-local-approvals" class="secondary guarded">清理本地缓存</button>
|
||||||
|
</div>
|
||||||
|
<div id="approval-board" class="approval-board">loading...</div>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h2>创建审批请求</h2>
|
||||||
|
<form id="approval-form" class="stack">
|
||||||
|
<label>类型
|
||||||
|
<select name="type">
|
||||||
|
<option value="merge">merge</option>
|
||||||
|
<option value="release">release</option>
|
||||||
|
<option value="rollback">rollback</option>
|
||||||
|
<option value="model_priority">model_priority</option>
|
||||||
|
<option value="runtime_change">runtime_change</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>目标 <input name="target" placeholder="branch / version / model id" required /></label>
|
||||||
|
<label>风险
|
||||||
|
<select name="risk"><option>low</option><option selected>medium</option><option>high</option></select>
|
||||||
|
</label>
|
||||||
|
<label>说明 <textarea name="reason" rows="5" required></textarea></label>
|
||||||
|
<button class="primary require-operator" type="submit">提交审批请求</button>
|
||||||
|
</form>
|
||||||
|
<pre id="approval-output">等待操作...</pre>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="tab-diff" class="tab">
|
||||||
|
<article class="card">
|
||||||
|
<h2>File-level Diff Tree</h2>
|
||||||
|
<p class="hint">支持 from/to 参数;会自动按文件聚合 diff,便于 review。</p>
|
||||||
|
<form id="diff-form" class="form-row wrap">
|
||||||
|
<label>From <input name="from" value="HEAD~1" /></label>
|
||||||
|
<label>To <input name="to" value="HEAD" /></label>
|
||||||
|
<button class="primary" type="submit">读取 Diff</button>
|
||||||
|
</form>
|
||||||
|
<div id="diff-tree" class="diff-tree">等待查询...</div>
|
||||||
|
<pre id="diff-output">等待查询...</pre>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="tab-patch" class="tab grid two">
|
||||||
|
<article class="card">
|
||||||
|
<h2>创建 Patch Branch</h2>
|
||||||
|
<p class="hint">默认从 main 创建非生产 patch 分支;main 写入仅通过明确授权的 GitOps Action。</p>
|
||||||
|
<form id="branch-form" class="stack">
|
||||||
|
<label>Branch <input name="branch" placeholder="patch/my-change" required /></label>
|
||||||
|
<label>Base <input name="base" value="main" /></label>
|
||||||
|
<label>Message <input name="message" value="Create GitOps patch branch" /></label>
|
||||||
|
<button class="primary require-operator" type="submit">创建 Branch</button>
|
||||||
|
</form>
|
||||||
|
<pre id="branch-output">等待操作...</pre>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h2>自然语言 GitOps Plan</h2>
|
||||||
|
<p class="hint">仅生成可审计 plan;高风险操作必须审批。</p>
|
||||||
|
<form id="plan-form" class="stack">
|
||||||
|
<label>目标 Branch <input name="branch" value="patch/web-ui-complete-gitops-dashboard-20260706" /></label>
|
||||||
|
<label>Plan Text <textarea name="text" rows="5" placeholder="描述要执行的 GitOps 变更" required></textarea></label>
|
||||||
|
<button class="primary require-operator" type="submit">生成 Plan</button>
|
||||||
|
</form>
|
||||||
|
<pre id="plan-output">等待操作...</pre>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="tab-model" class="tab">
|
||||||
|
<div class="grid two">
|
||||||
|
<article class="card">
|
||||||
|
<h2>模型注册表</h2>
|
||||||
|
<button id="refresh-model" class="secondary">刷新模型</button>
|
||||||
|
<pre id="model-output">loading...</pre>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h2>模型优先级图</h2>
|
||||||
|
<div id="model-priority-graph" class="graph">loading...</div>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h2>注册自定义模型</h2>
|
||||||
|
<p class="hint">仅允许 secret_ref;禁止真实 API key 明文。</p>
|
||||||
|
<form id="model-form" class="stack">
|
||||||
|
<label>Provider <input name="provider_name" placeholder="openai / one-api / compatible" required /></label>
|
||||||
|
<label>Base URL <input name="base_url" placeholder="https://example.com/v1" required /></label>
|
||||||
|
<label>Model Name <input name="model_name" placeholder="model-id" required /></label>
|
||||||
|
<label>Display Name <input name="display_name" placeholder="Human readable name" /></label>
|
||||||
|
<label>API Key Secret Ref <input name="api_key_secret_ref" placeholder="secret_ref://..." required /></label>
|
||||||
|
<button class="primary require-admin" type="submit">注册模型</button>
|
||||||
|
</form>
|
||||||
|
<pre id="model-register-output">等待操作...</pre>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h2>模型优先级 / Fallback</h2>
|
||||||
|
<form id="priority-form" class="stack">
|
||||||
|
<label>Model ID <input name="model_id" placeholder="base.gpt-5.5" required /></label>
|
||||||
|
<label>Priority <input name="priority" type="number" value="100" required /></label>
|
||||||
|
<label>Enabled <select name="enabled"><option value="true">true</option><option value="false">false</option></select></label>
|
||||||
|
<label>Fallback Policy <input name="fallback_policy" value="next_enabled" /></label>
|
||||||
|
<button class="primary require-admin" type="submit">保存优先级</button>
|
||||||
|
</form>
|
||||||
|
<pre id="priority-output">等待操作...</pre>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="tab-release" class="tab">
|
||||||
|
<div class="pipeline" id="release-pipeline">
|
||||||
|
<div class="stage">Patch</div><div class="arrow">→</div><div class="stage">Review</div><div class="arrow">→</div><div class="stage">Approval</div><div class="arrow">→</div><div class="stage guarded-stage">Tag Request</div><div class="arrow">→</div><div class="stage guarded-stage">Deploy</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid two">
|
||||||
|
<article class="card danger-zone">
|
||||||
|
<h2>Rollback Request</h2>
|
||||||
|
<p class="hint">只创建审批请求;不直接执行回滚。</p>
|
||||||
|
<form id="rollback-form" class="stack">
|
||||||
|
<label>Target Version <input name="target_version" placeholder="V1R1C3" required /></label>
|
||||||
|
<label>Reason <textarea name="reason" rows="4" required></textarea></label>
|
||||||
|
<label>Request Branch <input name="branch" value="patch/rollback-request-web-ui" /></label>
|
||||||
|
<button class="danger require-admin" type="submit">创建 Rollback 请求</button>
|
||||||
|
</form>
|
||||||
|
<pre id="rollback-output">等待操作...</pre>
|
||||||
|
</article>
|
||||||
|
<article class="card danger-zone">
|
||||||
|
<h2>Tag / Version Request</h2>
|
||||||
|
<p class="hint">默认创建 owner/admin approval request;UI 禁用直接执行。</p>
|
||||||
|
<form id="tag-form" class="stack">
|
||||||
|
<label>Version <input name="version" placeholder="V1R1C5" required /></label>
|
||||||
|
<label>Message <input name="message" placeholder="Release request" /></label>
|
||||||
|
<label class="disabled"><input name="execute" type="checkbox" disabled /> 直接执行(禁用)</label>
|
||||||
|
<button class="danger require-admin" type="submit">创建 Tag 请求</button>
|
||||||
|
</form>
|
||||||
|
<pre id="tag-output">等待操作...</pre>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="tab-audit" class="tab">
|
||||||
|
<article class="card">
|
||||||
|
<h2>审计中心</h2>
|
||||||
|
<div class="toolbar">
|
||||||
|
<input id="audit-filter" placeholder="过滤 action / target / status" />
|
||||||
|
<button id="export-audit" class="secondary">导出 JSON</button>
|
||||||
|
<button id="refresh-audit" class="secondary">刷新</button>
|
||||||
|
</div>
|
||||||
|
<div id="audit-table" class="table-wrap">loading...</div>
|
||||||
|
<pre id="audit-output">暂无操作。</pre>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script src="./app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "gitops-dashboard",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"mount": "/portal/me/#gitops-dashboard",
|
||||||
|
"description": "Complete GitOps Web UI module",
|
||||||
|
"features": [
|
||||||
|
"baseline_view",
|
||||||
|
"version_list",
|
||||||
|
"diff_view",
|
||||||
|
"patch_view",
|
||||||
|
"model_registry",
|
||||||
|
"runtime_status"
|
||||||
|
],
|
||||||
|
"api": {
|
||||||
|
"base": "/api/base",
|
||||||
|
"ver": "/api/ver",
|
||||||
|
"diff": "/api/diff",
|
||||||
|
"patch": "/api/patch",
|
||||||
|
"model": "/api/model",
|
||||||
|
"runtime": "/api/run"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
body{font-family:Arial,Helvetica,sans-serif;background:#0b0f14;color:#e6edf3;margin:0}
|
||||||
|
header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;background:#111826;border-bottom:1px solid #222}
|
||||||
|
nav button{margin-right:8px;padding:8px 10px;background:#1f2937;color:#e6edf3;border:1px solid #2b3645;cursor:pointer}
|
||||||
|
nav button:hover{background:#2b3645}
|
||||||
|
main{padding:16px}
|
||||||
|
pre{background:#0f172a;padding:12px;border:1px solid #1f2937;overflow:auto;min-height:400px}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"type": "branch",
|
||||||
|
"branch": "patch/web-ui-complete-gitops-dashboard-20260706",
|
||||||
|
"base": "main",
|
||||||
|
"created_by": "gitops-write-bot",
|
||||||
|
"created_at": "2026-07-06T15:33:08Z",
|
||||||
|
"direct_main_push": false,
|
||||||
|
"owner_approval_required": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
BRANCH=$1
|
||||||
|
BASE=${2:-main}
|
||||||
|
|
||||||
|
if [ -z "$BRANCH" ]; then
|
||||||
|
echo "Usage: autonomous-merge-gate.sh <branch> <base>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[Autonomous Gate] Evaluating: $BRANCH vs $BASE"
|
||||||
|
|
||||||
|
REPORT_DIR="reports/codex"
|
||||||
|
mkdir -p "$REPORT_DIR"
|
||||||
|
|
||||||
|
# Run validation and capture exit code correctly
|
||||||
|
bash scripts/validate-gitops-platform-ui.sh "$BRANCH" "$BASE"
|
||||||
|
VALIDATION_RC=$?
|
||||||
|
|
||||||
|
if [ $VALIDATION_RC -ne 0 ]; then
|
||||||
|
echo "FAIL: validation failed"
|
||||||
|
exit $VALIDATION_RC
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Secret safety re-check
|
||||||
|
if grep -R "BEGIN PRIVATE KEY" -n overlays/ >/dev/null 2>&1; then
|
||||||
|
echo "BLOCKED: secret leak"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create merge candidate artifact
|
||||||
|
cat > "$REPORT_DIR/merge-candidate.json" <<EOF
|
||||||
|
{
|
||||||
|
"branch": "$BRANCH",
|
||||||
|
"base": "$BASE",
|
||||||
|
"status": "merge_candidate",
|
||||||
|
"created_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "PASS: merge candidate ready"
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Enqueue a WeChat notification into WebOps message_outbox.
|
||||||
|
|
||||||
|
Business modules should call this helper or the equivalent WebOps API instead of
|
||||||
|
calling OpenClaw / WeChat adapters directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(os.environ.get("AIOS_WEBOPS_ROOT", "/data/aios-gitops/workspace"))
|
||||||
|
DB = Path(os.environ.get("AIOS_WEBOPS_DB", str(ROOT / "data" / "webops.sqlite3")))
|
||||||
|
MIGRATION = ROOT / "db" / "migrations" / "20260707_message_outbox.sql"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_schema(con: sqlite3.Connection) -> None:
|
||||||
|
if MIGRATION.exists():
|
||||||
|
con.executescript(MIGRATION.read_text(encoding="utf-8"))
|
||||||
|
else:
|
||||||
|
con.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS message_outbox (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
channel TEXT NOT NULL,
|
||||||
|
recipient_user_id TEXT NOT NULL,
|
||||||
|
recipient_role TEXT,
|
||||||
|
message_type TEXT NOT NULL,
|
||||||
|
priority TEXT NOT NULL DEFAULT 'normal',
|
||||||
|
title TEXT,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
payload TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
sent_at TEXT
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Enqueue a WeChat notification")
|
||||||
|
parser.add_argument("--recipient", "--recipient-user-id", dest="recipient_user_id", required=True)
|
||||||
|
parser.add_argument("--recipient-role", default="owner")
|
||||||
|
parser.add_argument("--message-type", default="alert")
|
||||||
|
parser.add_argument("--priority", default="normal", choices=["critical", "high", "normal", "low"])
|
||||||
|
parser.add_argument("--title", default="AIOS 通知")
|
||||||
|
parser.add_argument("--content", required=True)
|
||||||
|
parser.add_argument("--payload", default="{}")
|
||||||
|
parser.add_argument("--id")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
DB.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
msg_id = args.id or f"msg_{int(time.time() * 1000)}"
|
||||||
|
|
||||||
|
with sqlite3.connect(DB) as con:
|
||||||
|
ensure_schema(con)
|
||||||
|
con.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO message_outbox (
|
||||||
|
id, channel, recipient_user_id, recipient_role, message_type,
|
||||||
|
priority, title, content, payload, status, created_at
|
||||||
|
) VALUES (?, 'wechat', ?, ?, ?, ?, ?, ?, ?, 'queued', datetime('now'))
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
msg_id,
|
||||||
|
args.recipient_user_id,
|
||||||
|
args.recipient_role,
|
||||||
|
args.message_type,
|
||||||
|
args.priority,
|
||||||
|
args.title,
|
||||||
|
args.content,
|
||||||
|
args.payload,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
con.commit()
|
||||||
|
|
||||||
|
print(json.dumps({"status": "queued", "id": msg_id, "db": str(DB)}, ensure_ascii=False))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Ensure WebOps message outbox schema.
|
||||||
|
|
||||||
|
This schema is the only supported active outbound notification entrypoint for
|
||||||
|
business modules and external systems. Delivery is performed by the worker.
|
||||||
|
|
||||||
|
The migration is backward-compatible with already-created SQLite tables:
|
||||||
|
columns are added before indexes that reference them, and UNIQUE constraints are
|
||||||
|
created as indexes because SQLite cannot add a UNIQUE column via ALTER TABLE.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(os.environ.get("AIOS_WEBOPS_ROOT", "/data/aios-gitops/workspace"))
|
||||||
|
DB = Path(os.environ.get("AIOS_WEBOPS_DB", str(ROOT / "data" / "webops.sqlite3")))
|
||||||
|
|
||||||
|
BASE_TABLES_SQL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS message_outbox (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
idempotency_key TEXT,
|
||||||
|
channel TEXT NOT NULL,
|
||||||
|
recipient_user_id TEXT,
|
||||||
|
recipient_role TEXT,
|
||||||
|
message_type TEXT NOT NULL,
|
||||||
|
priority TEXT NOT NULL DEFAULT 'normal',
|
||||||
|
title TEXT,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
source_system TEXT,
|
||||||
|
source_event_id TEXT,
|
||||||
|
require_approval INTEGER NOT NULL DEFAULT 0,
|
||||||
|
payload TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
sent_at TEXT,
|
||||||
|
cancelled_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_bindings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
user_role TEXT,
|
||||||
|
channel_type TEXT NOT NULL,
|
||||||
|
external_chat_id TEXT NOT NULL,
|
||||||
|
notification_prefs TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
target TEXT,
|
||||||
|
detail TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
INDEX_SQL = """
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_message_outbox_idempotency
|
||||||
|
ON message_outbox(idempotency_key)
|
||||||
|
WHERE idempotency_key IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_message_outbox_dispatch
|
||||||
|
ON message_outbox(channel, status, priority, created_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channel_bindings_lookup
|
||||||
|
ON channel_bindings(user_id, channel_type, status, updated_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channel_bindings_role_lookup
|
||||||
|
ON channel_bindings(user_role, channel_type, status, updated_at);
|
||||||
|
"""
|
||||||
|
|
||||||
|
COLUMNS = {
|
||||||
|
"message_outbox": {
|
||||||
|
"idempotency_key": "TEXT",
|
||||||
|
"recipient_user_id": "TEXT",
|
||||||
|
"recipient_role": "TEXT",
|
||||||
|
"source_system": "TEXT",
|
||||||
|
"source_event_id": "TEXT",
|
||||||
|
"require_approval": "INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"cancelled_at": "TEXT",
|
||||||
|
},
|
||||||
|
"channel_bindings": {
|
||||||
|
"user_role": "TEXT",
|
||||||
|
"notification_prefs": "TEXT",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def existing_columns(con: sqlite3.Connection, table: str) -> set[str]:
|
||||||
|
return {row[1] for row in con.execute(f"PRAGMA table_info({table})")}
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_schema(db_path: Path = DB) -> None:
|
||||||
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with sqlite3.connect(db_path) as con:
|
||||||
|
con.executescript(BASE_TABLES_SQL)
|
||||||
|
for table, cols in COLUMNS.items():
|
||||||
|
existing = existing_columns(con, table)
|
||||||
|
for name, ddl in cols.items():
|
||||||
|
if name not in existing:
|
||||||
|
con.execute(f"ALTER TABLE {table} ADD COLUMN {name} {ddl}")
|
||||||
|
con.executescript(INDEX_SQL)
|
||||||
|
con.commit()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
ensure_schema()
|
||||||
|
print(f"schema_ok db={DB}")
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
BRANCH=$1
|
||||||
|
BASE=${2:-main}
|
||||||
|
|
||||||
|
if [ -z "$BRANCH" ]; then
|
||||||
|
echo "Usage: validate-gitops-platform-ui.sh <branch> <base>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[Codex] Validating branch: $BRANCH against $BASE"
|
||||||
|
|
||||||
|
echo "[1/5] Git diff check"
|
||||||
|
git diff --stat $BASE...$BRANCH
|
||||||
|
|
||||||
|
echo "[2/5] Acceptance test load"
|
||||||
|
if [ ! -f tests/acceptance/gitops-platform-ui.acceptance.json ]; then
|
||||||
|
echo "FAIL: acceptance file missing"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[3/5] Secret scan"
|
||||||
|
if grep -R "BEGIN PRIVATE KEY" -n overlays/ >/dev/null 2>&1; then
|
||||||
|
echo "FAIL: private key detected"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -R "Bearer " -n overlays/ >/dev/null 2>&1; then
|
||||||
|
echo "WARN: bearer token pattern found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[4/5] UI structural validation"
|
||||||
|
FILES=(
|
||||||
|
overlays/user-control-center/portal/gitops-dashboard/index.html
|
||||||
|
overlays/user-control-center/portal/gitops-dashboard/app.js
|
||||||
|
)
|
||||||
|
for f in "${FILES[@]}"; do
|
||||||
|
if [ ! -f "$f" ]; then
|
||||||
|
echo "FAIL: missing $f"
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[5/5] Policy validation"
|
||||||
|
grep -R "direct_main_push" -n overlays/user-control-center/portal/gitops-dashboard/ || true
|
||||||
|
|
||||||
|
echo "PASS: Codex validation completed"
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Production WeChat notify worker for AIOS / Xiamayiti.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
message_outbox -> channel_bindings -> wechat_send_backend -> sent/failed/retry/audit
|
||||||
|
|
||||||
|
Business modules must not call OpenClaw or UCC Adapter directly. They only
|
||||||
|
enqueue messages. The pluggable backend chooses OpenClaw channel or UCC Adapter
|
||||||
|
at runtime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
ROOT = Path(os.environ.get("AIOS_WEBOPS_ROOT", "/data/aios-gitops/workspace"))
|
||||||
|
DB = Path(os.environ.get("AIOS_WEBOPS_DB", str(ROOT / "data" / "webops.sqlite3")))
|
||||||
|
DEFAULT_LIMIT = int(os.environ.get("AIOS_WECHAT_WORKER_LIMIT", "20"))
|
||||||
|
|
||||||
|
PRIORITY_SQL = """
|
||||||
|
CASE priority
|
||||||
|
WHEN 'critical' THEN 1
|
||||||
|
WHEN 'high' THEN 2
|
||||||
|
WHEN 'normal' THEN 3
|
||||||
|
ELSE 4
|
||||||
|
END
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def load_module(path: Path, name: str):
|
||||||
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
|
if not spec or not spec.loader:
|
||||||
|
raise RuntimeError(f"cannot load module: {path}")
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod) # type: ignore[attr-defined]
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_schema(con: sqlite3.Connection) -> None:
|
||||||
|
schema_path = ROOT / "scripts" / "message_outbox_schema.py"
|
||||||
|
if schema_path.exists():
|
||||||
|
mod = load_module(schema_path, "message_outbox_schema")
|
||||||
|
mod.ensure_schema(DB)
|
||||||
|
return
|
||||||
|
con.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS message_outbox (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
idempotency_key TEXT,
|
||||||
|
channel TEXT NOT NULL,
|
||||||
|
recipient_user_id TEXT,
|
||||||
|
recipient_role TEXT,
|
||||||
|
message_type TEXT NOT NULL,
|
||||||
|
priority TEXT NOT NULL DEFAULT 'normal',
|
||||||
|
title TEXT,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
source_system TEXT,
|
||||||
|
source_event_id TEXT,
|
||||||
|
require_approval INTEGER NOT NULL DEFAULT 0,
|
||||||
|
payload TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
sent_at TEXT,
|
||||||
|
cancelled_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_bindings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
user_role TEXT,
|
||||||
|
channel_type TEXT NOT NULL,
|
||||||
|
external_chat_id TEXT NOT NULL,
|
||||||
|
notification_prefs TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
target TEXT,
|
||||||
|
detail TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def audit(con: sqlite3.Connection, action: str, target: str, detail: Dict[str, Any]) -> None:
|
||||||
|
con.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO audit_logs(id, actor, action, target, detail, created_at)
|
||||||
|
VALUES(?, 'system', ?, ?, ?, datetime('now'))
|
||||||
|
""",
|
||||||
|
(f"audit_{int(time.time() * 1000)}", action, target, json.dumps(detail, ensure_ascii=False)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_wechat_target(con: sqlite3.Connection, row: sqlite3.Row) -> str:
|
||||||
|
recipient_user_id = row["recipient_user_id"]
|
||||||
|
recipient_role = row["recipient_role"]
|
||||||
|
|
||||||
|
if recipient_user_id:
|
||||||
|
found = con.execute(
|
||||||
|
"""
|
||||||
|
SELECT external_chat_id
|
||||||
|
FROM channel_bindings
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND channel_type = 'wechat'
|
||||||
|
AND status = 'active'
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(recipient_user_id,),
|
||||||
|
).fetchone()
|
||||||
|
if found:
|
||||||
|
return found["external_chat_id"]
|
||||||
|
|
||||||
|
if recipient_role:
|
||||||
|
found = con.execute(
|
||||||
|
"""
|
||||||
|
SELECT external_chat_id
|
||||||
|
FROM channel_bindings
|
||||||
|
WHERE (user_role = ? OR user_id = ?)
|
||||||
|
AND channel_type = 'wechat'
|
||||||
|
AND status = 'active'
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(recipient_role, recipient_role),
|
||||||
|
).fetchone()
|
||||||
|
if found:
|
||||||
|
return found["external_chat_id"]
|
||||||
|
|
||||||
|
raise RuntimeError(f"wechat binding not found for recipient_user_id={recipient_user_id} role={recipient_role}")
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_content(row: sqlite3.Row) -> str:
|
||||||
|
content = row["content"] or ""
|
||||||
|
blocked_markers = ["BEGIN PRIVATE KEY", "Bearer ", "PASSWORD=", "TOKEN=", "/data/aios-gitops/runtime/secrets"]
|
||||||
|
for marker in blocked_markers:
|
||||||
|
if marker in content:
|
||||||
|
content = content.replace(marker, "[REDACTED]")
|
||||||
|
title = row["title"] or "AIOS 通知"
|
||||||
|
return f"{title}\n\n{content}"
|
||||||
|
|
||||||
|
|
||||||
|
def send_with_backend(target: str, content: str, row: sqlite3.Row, dry_run: bool = False) -> Dict[str, Any]:
|
||||||
|
backend_path = ROOT / "scripts" / "wechat_send_backend.py"
|
||||||
|
if not backend_path.exists():
|
||||||
|
raise RuntimeError("wechat_send_backend.py not found")
|
||||||
|
backend = load_module(backend_path, "wechat_send_backend")
|
||||||
|
metadata = {}
|
||||||
|
if row["payload"]:
|
||||||
|
try:
|
||||||
|
metadata = json.loads(row["payload"])
|
||||||
|
except Exception:
|
||||||
|
metadata = {"raw_payload": row["payload"][:1000]}
|
||||||
|
return backend.send_message(
|
||||||
|
target=target,
|
||||||
|
content=content,
|
||||||
|
account_id=os.environ.get("AIOS_WECHAT_ACCOUNT_ID", ""),
|
||||||
|
message_id=row["id"],
|
||||||
|
metadata=metadata,
|
||||||
|
dry_run=dry_run,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_dispatching(con: sqlite3.Connection, msg_id: str) -> bool:
|
||||||
|
cur = con.execute("UPDATE message_outbox SET status='dispatching' WHERE id=? AND status='queued'", (msg_id,))
|
||||||
|
return cur.rowcount == 1
|
||||||
|
|
||||||
|
|
||||||
|
def process_row(con: sqlite3.Connection, row: sqlite3.Row, dry_run: bool = False) -> None:
|
||||||
|
msg_id = row["id"]
|
||||||
|
if int(row["require_approval"] or 0) == 1 and row["message_type"] == "approval_request":
|
||||||
|
con.execute("UPDATE message_outbox SET status='approval_pending' WHERE id=?", (msg_id,))
|
||||||
|
audit(con, "wechat_outbound_approval_pending", row["recipient_user_id"] or row["recipient_role"] or "unknown", {"message_id": msg_id})
|
||||||
|
return
|
||||||
|
|
||||||
|
if not mark_dispatching(con, msg_id):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
target = load_wechat_target(con, row)
|
||||||
|
content = sanitize_content(row)
|
||||||
|
result = send_with_backend(target, content, row, dry_run=dry_run)
|
||||||
|
ok = bool(result.get("ok"))
|
||||||
|
if not ok:
|
||||||
|
code = result.get("code") or result.get("status") or "send_failed"
|
||||||
|
raise RuntimeError(f"wechat send failed via {result.get('backend')}: {code}; {result.get('error') or result}")
|
||||||
|
status = "sent" if not dry_run else "queued"
|
||||||
|
con.execute(
|
||||||
|
"""
|
||||||
|
UPDATE message_outbox
|
||||||
|
SET status=?,
|
||||||
|
sent_at=CASE WHEN ?='sent' THEN datetime('now') ELSE sent_at END,
|
||||||
|
payload=json_set(COALESCE(payload, '{}'), '$.send_result', ?),
|
||||||
|
last_error=NULL
|
||||||
|
WHERE id=?
|
||||||
|
""",
|
||||||
|
(status, status, json.dumps(result, ensure_ascii=False), msg_id),
|
||||||
|
)
|
||||||
|
audit(con, "wechat_outbound_sent" if not dry_run else "wechat_outbound_dry_run", row["recipient_user_id"] or row["recipient_role"] or "unknown", {"message_id": msg_id, "backend": result.get("backend"), "result": result})
|
||||||
|
except Exception as exc:
|
||||||
|
retry_count = int(row["retry_count"] or 0) + 1
|
||||||
|
status = "failed" if retry_count >= 5 else "queued"
|
||||||
|
con.execute(
|
||||||
|
"UPDATE message_outbox SET retry_count=?, status=?, last_error=? WHERE id=?",
|
||||||
|
(retry_count, status, str(exc)[:1000], msg_id),
|
||||||
|
)
|
||||||
|
audit(con, "wechat_outbound_failed", row["recipient_user_id"] or row["recipient_role"] or "unknown", {"message_id": msg_id, "retry_count": retry_count, "error": str(exc)[:1000]})
|
||||||
|
|
||||||
|
|
||||||
|
def run_once(limit: int, dry_run: bool = False) -> Dict[str, Any]:
|
||||||
|
DB.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with sqlite3.connect(DB) as con:
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
ensure_schema(con)
|
||||||
|
rows = con.execute(
|
||||||
|
f"SELECT * FROM message_outbox WHERE channel='wechat' AND status='queued' ORDER BY {PRIORITY_SQL}, created_at LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
process_row(con, row, dry_run=dry_run)
|
||||||
|
con.commit()
|
||||||
|
return {"status": "ok", "processed": len(rows), "db": str(DB), "dry_run": dry_run}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Dispatch message_outbox WeChat notifications through the selected backend")
|
||||||
|
parser.add_argument("--once", action="store_true", help="Run one dispatch pass and exit")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Run backend dry-run if supported and keep messages queued")
|
||||||
|
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
|
||||||
|
parser.add_argument("--interval", type=int, default=int(os.environ.get("AIOS_WECHAT_WORKER_INTERVAL", "10")))
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.once:
|
||||||
|
print(json.dumps(run_once(args.limit, dry_run=args.dry_run), ensure_ascii=False))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
print(json.dumps(run_once(args.limit, dry_run=args.dry_run), ensure_ascii=False), flush=True)
|
||||||
|
time.sleep(args.interval)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Pluggable WeChat send backend for AIOS / Xiamayiti.
|
||||||
|
|
||||||
|
Selection order:
|
||||||
|
1. AIOS_WECHAT_SEND_BACKEND, if explicitly set;
|
||||||
|
2. UCC adapter backend, when ucc-wechat-qr-adapter is alive;
|
||||||
|
3. OpenClaw channel backend, only when channel discovery finds wechat/weixin;
|
||||||
|
4. unavailable with explicit diagnostics.
|
||||||
|
|
||||||
|
This module must not print runtime secrets and must not hard-code account,
|
||||||
|
target, adapter credentials, or channel names.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
OPENCLAW_BIN = os.environ.get("AIOS_OPENCLAW_BIN", "openclaw")
|
||||||
|
OPENCLAW_GATEWAY = os.environ.get("AIOS_OPENCLAW_GATEWAY", "http://127.0.0.1:18789")
|
||||||
|
OPENCLAW_AUTH_MODE = os.environ.get("AIOS_OPENCLAW_AUTH_MODE", "")
|
||||||
|
OPENCLAW_AUTH_VALUE = os.environ.get("AIOS_OPENCLAW_AUTH_VALUE", "")
|
||||||
|
WECHAT_ACCOUNT_ID = os.environ.get("AIOS_WECHAT_ACCOUNT_ID", "")
|
||||||
|
WECHAT_CHANNEL = os.environ.get("AIOS_WECHAT_CHANNEL", "")
|
||||||
|
SEND_BACKEND = os.environ.get("AIOS_WECHAT_SEND_BACKEND", "")
|
||||||
|
UCC_ADAPTER_URL = os.environ.get("AIOS_UCC_WECHAT_ADAPTER_URL", "http://127.0.0.1:18800")
|
||||||
|
UCC_ADAPTER_TOKEN = os.environ.get("AIOS_UCC_WECHAT_ADAPTER_TOKEN") or os.environ.get("AIOS_WECHAT_QR_ADAPTER_TOKEN", "")
|
||||||
|
UCC_SEND_PATH = os.environ.get("AIOS_UCC_WECHAT_SEND_PATH", "/api/internal/wechat/send")
|
||||||
|
|
||||||
|
|
||||||
|
class BackendResult:
|
||||||
|
def __init__(self, ok: bool, backend: str, status: str, data: Dict[str, Any]) -> None:
|
||||||
|
self.ok = ok
|
||||||
|
self.backend = backend
|
||||||
|
self.status = status
|
||||||
|
self.data = data
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {"ok": self.ok, "backend": self.backend, "status": self.status, **self.data}
|
||||||
|
|
||||||
|
|
||||||
|
def _openclaw_base_args() -> List[str]:
|
||||||
|
args = [OPENCLAW_BIN]
|
||||||
|
if OPENCLAW_GATEWAY:
|
||||||
|
args.extend(["--gateway", OPENCLAW_GATEWAY])
|
||||||
|
if OPENCLAW_AUTH_MODE and OPENCLAW_AUTH_VALUE:
|
||||||
|
if OPENCLAW_AUTH_MODE == "token":
|
||||||
|
args.extend(["--token", OPENCLAW_AUTH_VALUE])
|
||||||
|
elif OPENCLAW_AUTH_MODE == "password":
|
||||||
|
args.extend(["--password", OPENCLAW_AUTH_VALUE])
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def _run_openclaw(args: List[str], timeout: int = 30) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(args, text=True, capture_output=True, timeout=timeout, check=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_or_raw(text: str) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return json.loads(text or "{}")
|
||||||
|
except Exception:
|
||||||
|
return {"raw": (text or "")[:1000]}
|
||||||
|
|
||||||
|
|
||||||
|
def discover_openclaw_wechat_channel() -> Optional[str]:
|
||||||
|
if WECHAT_CHANNEL:
|
||||||
|
return WECHAT_CHANNEL
|
||||||
|
cmd = _openclaw_base_args() + ["channels", "list", "--json"]
|
||||||
|
proc = _run_openclaw(cmd, timeout=15)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return None
|
||||||
|
data = _json_or_raw(proc.stdout)
|
||||||
|
candidates: List[str] = []
|
||||||
|
|
||||||
|
def walk(value: Any) -> None:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
name = str(value.get("name") or value.get("channel") or value.get("id") or "")
|
||||||
|
typ = str(value.get("type") or value.get("kind") or "")
|
||||||
|
joined = f"{name} {typ}".lower()
|
||||||
|
if ("wechat" in joined or "weixin" in joined) and name:
|
||||||
|
candidates.append(name)
|
||||||
|
for child in value.values():
|
||||||
|
walk(child)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for child in value:
|
||||||
|
walk(child)
|
||||||
|
|
||||||
|
walk(data)
|
||||||
|
return candidates[0] if candidates else None
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers() -> Dict[str, str]:
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if UCC_ADAPTER_TOKEN:
|
||||||
|
headers["Authorization"] = "Bearer " + UCC_ADAPTER_TOKEN
|
||||||
|
headers["X-AIOS-Token"] = UCC_ADAPTER_TOKEN
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_paths(paths: List[str]) -> List[str]:
|
||||||
|
seen = set()
|
||||||
|
out = []
|
||||||
|
for path in paths:
|
||||||
|
if not path.startswith("/"):
|
||||||
|
path = "/" + path
|
||||||
|
if path not in seen:
|
||||||
|
seen.add(path)
|
||||||
|
out.append(path)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _ucc_send_paths() -> List[str]:
|
||||||
|
return _unique_paths([UCC_SEND_PATH, "/api/internal/wechat/send", "/internal/channels/wechat/send"])
|
||||||
|
|
||||||
|
|
||||||
|
def ucc_adapter_alive() -> bool:
|
||||||
|
for path in ("/health", "/api/internal/health", "/api/health", "/status", "/api/internal/wechat/status"):
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(UCC_ADAPTER_URL.rstrip("/") + path, headers=_auth_headers(), method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
if 200 <= resp.status < 500:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def select_backend() -> Dict[str, Any]:
|
||||||
|
explicit = SEND_BACKEND.strip().lower()
|
||||||
|
if explicit:
|
||||||
|
return {"backend": explicit, "reason": "explicit"}
|
||||||
|
|
||||||
|
if ucc_adapter_alive():
|
||||||
|
return {"backend": "ucc-adapter", "reason": "ucc_adapter_alive"}
|
||||||
|
|
||||||
|
channel = discover_openclaw_wechat_channel()
|
||||||
|
if channel:
|
||||||
|
return {"backend": "openclaw-channel", "channel": channel, "reason": "openclaw_channel_discovered"}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"backend": "unavailable",
|
||||||
|
"reason": "wechat outbound backend unavailable: ucc adapter not reachable; openclaw channel not registered",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def send_openclaw_channel(
|
||||||
|
target: str,
|
||||||
|
content: str,
|
||||||
|
account_id: str = "",
|
||||||
|
message_id: str = "",
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> BackendResult:
|
||||||
|
channel = WECHAT_CHANNEL or discover_openclaw_wechat_channel()
|
||||||
|
if not channel:
|
||||||
|
return BackendResult(False, "openclaw-channel", "failed", {"code": "openclaw_wechat_channel_not_registered"})
|
||||||
|
account = account_id or WECHAT_ACCOUNT_ID
|
||||||
|
if not account:
|
||||||
|
return BackendResult(False, "openclaw-channel", "failed", {"code": "openclaw_account_not_configured"})
|
||||||
|
|
||||||
|
cmd = _openclaw_base_args() + [
|
||||||
|
"message",
|
||||||
|
"send",
|
||||||
|
"--channel",
|
||||||
|
channel,
|
||||||
|
"--account",
|
||||||
|
account,
|
||||||
|
"--target",
|
||||||
|
target,
|
||||||
|
"-m",
|
||||||
|
content,
|
||||||
|
]
|
||||||
|
if dry_run:
|
||||||
|
cmd.append("--dry-run")
|
||||||
|
cmd.append("--json")
|
||||||
|
|
||||||
|
proc = _run_openclaw(cmd)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return BackendResult(
|
||||||
|
False,
|
||||||
|
"openclaw-channel",
|
||||||
|
"failed",
|
||||||
|
{"code": "openclaw_send_failed", "error": (proc.stderr or proc.stdout)[:1000], "channel": channel},
|
||||||
|
)
|
||||||
|
return BackendResult(
|
||||||
|
True,
|
||||||
|
"openclaw-channel",
|
||||||
|
"sent" if not dry_run else "dry_run",
|
||||||
|
{"channel": channel, "result": _json_or_raw(proc.stdout)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _post_ucc(path: str, payload: Dict[str, Any]) -> tuple[int, Dict[str, Any]]:
|
||||||
|
url = UCC_ADAPTER_URL.rstrip("/") + path
|
||||||
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
req = urllib.request.Request(url, data=body, headers=_auth_headers(), method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||||
|
return resp.status, _json_or_raw(resp.read().decode("utf-8", errors="replace"))
|
||||||
|
|
||||||
|
|
||||||
|
def _ucc_result_is_ok(data: Dict[str, Any], dry_run: bool) -> bool:
|
||||||
|
status = str(data.get("status") or data.get("delivery_status") or "").lower()
|
||||||
|
if dry_run:
|
||||||
|
return bool(data.get("ok") or data.get("accepted")) and status in {"dry_run", "sent", "accepted", "ok"}
|
||||||
|
return bool(data.get("ok")) and status == "sent"
|
||||||
|
|
||||||
|
|
||||||
|
def send_ucc_adapter(
|
||||||
|
target: str,
|
||||||
|
content: str,
|
||||||
|
account_id: str = "",
|
||||||
|
message_id: str = "",
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> BackendResult:
|
||||||
|
payload = {
|
||||||
|
"target": target,
|
||||||
|
"content": content,
|
||||||
|
"account_id": account_id or WECHAT_ACCOUNT_ID,
|
||||||
|
"message_id": message_id,
|
||||||
|
"metadata": metadata or {},
|
||||||
|
"dry_run": dry_run,
|
||||||
|
}
|
||||||
|
last_error: Dict[str, Any] = {}
|
||||||
|
for path in _ucc_send_paths():
|
||||||
|
try:
|
||||||
|
http_status, data = _post_ucc(path, payload)
|
||||||
|
ok = _ucc_result_is_ok(data, dry_run)
|
||||||
|
status = str(data.get("status") or ("sent" if ok and not dry_run else "dry_run" if ok else "failed"))
|
||||||
|
code = str(data.get("code") or status)
|
||||||
|
return BackendResult(
|
||||||
|
ok,
|
||||||
|
"ucc-adapter",
|
||||||
|
status if ok else "failed",
|
||||||
|
{"code": code, "http_status": http_status, "endpoint": path, "result": data},
|
||||||
|
)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
data = _json_or_raw(exc.read().decode("utf-8", errors="replace"))
|
||||||
|
last_error = {"code": data.get("code") or "ucc_adapter_http_error", "http_status": exc.code, "endpoint": path, "error": data}
|
||||||
|
if exc.code in {404, 405}:
|
||||||
|
continue
|
||||||
|
return BackendResult(False, "ucc-adapter", "failed", last_error)
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = {"code": "ucc_adapter_unavailable", "endpoint": path, "error": str(exc)[:1000]}
|
||||||
|
continue
|
||||||
|
return BackendResult(False, "ucc-adapter", "failed", last_error or {"code": "ucc_adapter_unavailable"})
|
||||||
|
|
||||||
|
|
||||||
|
def send_message(
|
||||||
|
target: str,
|
||||||
|
content: str,
|
||||||
|
account_id: str = "",
|
||||||
|
message_id: str = "",
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
selected = select_backend()
|
||||||
|
backend = selected.get("backend")
|
||||||
|
if backend == "ucc-adapter":
|
||||||
|
return send_ucc_adapter(target, content, account_id, message_id, metadata, dry_run).to_dict()
|
||||||
|
if backend == "openclaw-channel":
|
||||||
|
return send_openclaw_channel(target, content, account_id, message_id, metadata, dry_run).to_dict()
|
||||||
|
if backend == "unavailable":
|
||||||
|
return BackendResult(False, "unavailable", "failed", {"code": "wechat_outbound_backend_unavailable", "error": selected.get("reason")}).to_dict()
|
||||||
|
return BackendResult(False, backend or "unknown", "failed", {"code": "unsupported_backend", "backend": backend}).to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(json.dumps(select_backend(), ensure_ascii=False, indent=2))
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""AIOS Autonomous GitOps Daemon.
|
||||||
|
|
||||||
|
Low-impact sidecar daemon for:
|
||||||
|
- reading GitOps events from a file-backed event bus;
|
||||||
|
- running safe validation/self-healing commands;
|
||||||
|
- sending owner notifications through a runtime-injected WeChat/WeCom webhook;
|
||||||
|
- creating audit records and request artifacts instead of bypassing production gates.
|
||||||
|
|
||||||
|
No credentials are stored in this file. Runtime secrets must be injected by the host
|
||||||
|
through environment variables or secret-mounted files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime as _dt
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(os.environ.get("AIOS_REPO_ROOT", ".")).resolve()
|
||||||
|
CONFIG_PATH = ROOT / "config" / "codex" / "self-optimizing-orchestrator.json"
|
||||||
|
PROD_POLICY_PATH = ROOT / "config" / "codex" / "autonomous-production-policy.json"
|
||||||
|
MERGE_POLICY_PATH = ROOT / "config" / "codex" / "autonomous-merge-policy.json"
|
||||||
|
EVENT_DIR = pathlib.Path(os.environ.get("AIOS_EVENT_DIR", str(ROOT / "runtime" / "gitops" / "events")))
|
||||||
|
AUDIT_DIR = pathlib.Path(os.environ.get("AIOS_AUDIT_DIR", str(ROOT / "runtime" / "gitops" / "audit")))
|
||||||
|
REQUEST_DIR = pathlib.Path(os.environ.get("AIOS_REQUEST_DIR", str(ROOT / "runtime" / "gitops" / "requests")))
|
||||||
|
REPORT_DIR = pathlib.Path(os.environ.get("AIOS_REPORT_DIR", str(ROOT / "reports" / "codex")))
|
||||||
|
PREAPPROVAL_DIR = pathlib.Path(os.environ.get("AIOS_PREAPPROVAL_DIR", str(ROOT / "approvals" / "preapproved")))
|
||||||
|
|
||||||
|
SAFE_AUTONOMOUS_EVENTS = {
|
||||||
|
"git_push",
|
||||||
|
"patch_created",
|
||||||
|
"validation_failed",
|
||||||
|
"validation_passed",
|
||||||
|
"merge_candidate_created",
|
||||||
|
"runtime_health_degraded",
|
||||||
|
}
|
||||||
|
|
||||||
|
OWNER_NOTIFY_EVENTS = {
|
||||||
|
"main_version_changed",
|
||||||
|
"baseline_changed",
|
||||||
|
"merge_candidate_created",
|
||||||
|
"validation_failed",
|
||||||
|
"rollback_request_created",
|
||||||
|
"approval_required",
|
||||||
|
"daily_digest",
|
||||||
|
}
|
||||||
|
|
||||||
|
PRODUCTION_ACTIONS = {
|
||||||
|
"merge_to_main",
|
||||||
|
"create_release_tag",
|
||||||
|
"activate_new_baseline",
|
||||||
|
"production_runtime_change",
|
||||||
|
"production_rollback_execution",
|
||||||
|
}
|
||||||
|
|
||||||
|
ALWAYS_BLOCKED = {
|
||||||
|
"force_push",
|
||||||
|
"purge_baseline",
|
||||||
|
"delete_version_history",
|
||||||
|
"delete_audit_records",
|
||||||
|
"commit_real_secret",
|
||||||
|
"disable_auth_or_gitea_protection",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return _dt.datetime.now(_dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path: pathlib.Path, default: Any) -> Any:
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return default
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise RuntimeError(f"Invalid JSON: {path}: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(path: pathlib.Path, data: Any) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
tmp.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def event_id(event: Dict[str, Any]) -> str:
|
||||||
|
raw = json.dumps(event, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||||||
|
return hashlib.sha256(raw).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def audit(action: str, status: str, detail: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
record = {
|
||||||
|
"ts": utc_now(),
|
||||||
|
"action": action,
|
||||||
|
"status": status,
|
||||||
|
"detail": redact(detail),
|
||||||
|
}
|
||||||
|
write_json(AUDIT_DIR / f"{record['ts'].replace(':', '-')}-{action}.json", record)
|
||||||
|
print(json.dumps(record, ensure_ascii=False), flush=True)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def redact(value: Any) -> Any:
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [redact(v) for v in value]
|
||||||
|
if isinstance(value, dict):
|
||||||
|
out: Dict[str, Any] = {}
|
||||||
|
for key, val in value.items():
|
||||||
|
lowered = key.lower()
|
||||||
|
sensitive = any(word in lowered for word in ["api_key", "apikey", "password", "secret", "private_key", "credential", "webhook"])
|
||||||
|
if sensitive and not (isinstance(val, str) and val.startswith("secret_ref://")):
|
||||||
|
out[key] = "[REDACTED]"
|
||||||
|
else:
|
||||||
|
out[key] = redact(val)
|
||||||
|
return out
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def run_cmd(cmd: List[str], timeout: int = 300) -> Tuple[int, str, str]:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=str(ROOT),
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
timeout=timeout,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
return result.returncode, result.stdout, result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def read_event_files(limit: int = 100) -> List[pathlib.Path]:
|
||||||
|
EVENT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
return sorted(EVENT_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime)[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def archive_event(path: pathlib.Path, status: str) -> None:
|
||||||
|
target = EVENT_DIR / status / path.name
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.move(str(path), str(target))
|
||||||
|
|
||||||
|
|
||||||
|
def get_webhook_url() -> Optional[str]:
|
||||||
|
# Runtime-only injection. Do not commit real values.
|
||||||
|
return os.environ.get("AIOS_OWNER_WECHAT_WEBHOOK_URL") or os.environ.get("AIOS_OWNER_WECOM_WEBHOOK_URL")
|
||||||
|
|
||||||
|
|
||||||
|
def sign_payload(payload: Dict[str, Any]) -> Optional[str]:
|
||||||
|
secret = os.environ.get("AIOS_OWNER_ACTION_SIGNING_SECRET")
|
||||||
|
if not secret:
|
||||||
|
return None
|
||||||
|
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||||||
|
return hmac.new(secret.encode("utf-8"), raw, hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def send_owner_wechat(event: Dict[str, Any], summary: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
url = get_webhook_url()
|
||||||
|
if not url:
|
||||||
|
return {"status": "skipped", "reason": "wechat_webhook_not_configured"}
|
||||||
|
|
||||||
|
content = format_owner_message(event, summary)
|
||||||
|
body = {
|
||||||
|
"msgtype": "markdown",
|
||||||
|
"markdown": {"content": content},
|
||||||
|
}
|
||||||
|
signature = sign_payload(body)
|
||||||
|
if signature:
|
||||||
|
body["aios_signature"] = signature
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
data = resp.read().decode("utf-8", errors="replace")
|
||||||
|
return {"status": "sent", "http_status": resp.status, "response": data[:500]}
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
return {"status": "failed", "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
def format_owner_message(event: Dict[str, Any], summary: Dict[str, Any]) -> str:
|
||||||
|
title = event.get("type", "gitops_event")
|
||||||
|
branch = summary.get("branch") or event.get("branch") or "-"
|
||||||
|
from_version = summary.get("from_version") or event.get("from_version") or "-"
|
||||||
|
to_version = summary.get("to_version") or event.get("to_version") or "-"
|
||||||
|
changed = summary.get("changed_features") or event.get("changed_features") or []
|
||||||
|
if isinstance(changed, list):
|
||||||
|
changed_text = ", ".join(str(x) for x in changed[:8]) or "-"
|
||||||
|
else:
|
||||||
|
changed_text = str(changed)
|
||||||
|
return "\n".join([
|
||||||
|
f"### AIOS GitOps 通报:{title}",
|
||||||
|
f"- Branch: `{branch}`",
|
||||||
|
f"- Version: `{from_version}` → `{to_version}`",
|
||||||
|
f"- Code Δ: +{summary.get('code_lines_added', 0)} / -{summary.get('code_lines_deleted', 0)}",
|
||||||
|
f"- Config Δ: +{summary.get('config_lines_added', 0)} / -{summary.get('config_lines_deleted', 0)}",
|
||||||
|
f"- Elapsed: {summary.get('elapsed_since_previous_main_change', '-')}",
|
||||||
|
f"- Risk: {summary.get('risk', event.get('risk', 'unknown'))}",
|
||||||
|
f"- Test: {summary.get('test_status', '-')}",
|
||||||
|
f"- Rollback: `{summary.get('rollback_target', 'V1R1C4')}`",
|
||||||
|
f"- Changed: {changed_text}",
|
||||||
|
f"- Web UI: {summary.get('web_ui_audit_url', '/portal/me/#gitops-audit')}",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def diff_stats(base: str, branch: str) -> Dict[str, int]:
|
||||||
|
code_add = code_del = config_add = config_del = 0
|
||||||
|
rc, stdout, _ = run_cmd(["git", "diff", "--numstat", f"{base}...{branch}"], timeout=60)
|
||||||
|
if rc != 0:
|
||||||
|
return {"code_lines_added": 0, "code_lines_deleted": 0, "config_lines_added": 0, "config_lines_deleted": 0}
|
||||||
|
config_ext = {".json", ".yaml", ".yml", ".toml", ".ini", ".conf", ".service"}
|
||||||
|
for line in stdout.splitlines():
|
||||||
|
parts = line.split("\t")
|
||||||
|
if len(parts) < 3 or parts[0] == "-" or parts[1] == "-":
|
||||||
|
continue
|
||||||
|
add, delete, path = int(parts[0]), int(parts[1]), parts[2]
|
||||||
|
if pathlib.Path(path).suffix in config_ext or path.startswith("config/"):
|
||||||
|
config_add += add
|
||||||
|
config_del += delete
|
||||||
|
else:
|
||||||
|
code_add += add
|
||||||
|
code_del += delete
|
||||||
|
return {
|
||||||
|
"code_lines_added": code_add,
|
||||||
|
"code_lines_deleted": code_del,
|
||||||
|
"config_lines_added": config_add,
|
||||||
|
"config_lines_deleted": config_del,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_summary(event: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
branch = event.get("branch") or "patch/web-ui-complete-gitops-dashboard-20260706"
|
||||||
|
base = event.get("base") or "main"
|
||||||
|
stats = diff_stats(base, branch)
|
||||||
|
return {
|
||||||
|
"event_id": event.get("id") or event_id(event),
|
||||||
|
"branch": branch,
|
||||||
|
"from_rev": event.get("from_rev", base),
|
||||||
|
"to_rev": event.get("to_rev", branch),
|
||||||
|
"from_version": event.get("from_version", "V1R1C4"),
|
||||||
|
"to_version": event.get("to_version", "V1R1C5"),
|
||||||
|
"elapsed_since_previous_main_change": event.get("elapsed_since_previous_main_change", "unknown"),
|
||||||
|
"changed_features": event.get("changed_features", ["GitOps autonomous orchestration", "Owner notifications", "event bus", "safe daemon"]),
|
||||||
|
"test_status": event.get("test_status", "pending"),
|
||||||
|
"risk": event.get("risk", "high"),
|
||||||
|
"approval_status": event.get("approval_status", "required"),
|
||||||
|
"rollback_target": event.get("rollback_target", "V1R1C4"),
|
||||||
|
"web_ui_audit_url": event.get("web_ui_audit_url", "/portal/me/#gitops-audit"),
|
||||||
|
**stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def has_preapproval(action: str, event: Dict[str, Any]) -> bool:
|
||||||
|
target_version = event.get("to_version") or "V1R1C5"
|
||||||
|
rollback_target = event.get("rollback_target") or "V1R1C4"
|
||||||
|
now_dt = _dt.datetime.now(_dt.timezone.utc)
|
||||||
|
for path in PREAPPROVAL_DIR.glob("*.json"):
|
||||||
|
try:
|
||||||
|
doc = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
allowed = doc.get("allowed_actions", [])
|
||||||
|
if action not in allowed:
|
||||||
|
continue
|
||||||
|
if doc.get("target_version") != target_version:
|
||||||
|
continue
|
||||||
|
if doc.get("rollback_target") != rollback_target:
|
||||||
|
continue
|
||||||
|
if doc.get("approved_by_role") not in ["owner", "admin"]:
|
||||||
|
continue
|
||||||
|
expires_at = doc.get("expires_at")
|
||||||
|
if not expires_at:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
exp = _dt.datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if exp > now_dt:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def create_request(event: Dict[str, Any], reason: str) -> Dict[str, Any]:
|
||||||
|
req = {
|
||||||
|
"id": f"req-{utc_now().replace(':', '-')}-{event_id(event)}",
|
||||||
|
"created_at": utc_now(),
|
||||||
|
"event": redact(event),
|
||||||
|
"reason": reason,
|
||||||
|
"status": "pending_owner_review",
|
||||||
|
"web_ui": "/portal/me/#gitops-owner-actions",
|
||||||
|
}
|
||||||
|
write_json(REQUEST_DIR / f"{req['id']}.json", req)
|
||||||
|
return req
|
||||||
|
|
||||||
|
|
||||||
|
def run_validation(event: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
branch = event.get("branch") or "patch/web-ui-complete-gitops-dashboard-20260706"
|
||||||
|
base = event.get("base") or "main"
|
||||||
|
script = ROOT / "scripts" / "validate-gitops-platform-ui.sh"
|
||||||
|
if not script.exists():
|
||||||
|
return {"status": "blocked", "reason": "validation_script_missing"}
|
||||||
|
rc, out, err = run_cmd(["bash", str(script), branch, base], timeout=600)
|
||||||
|
REPORT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
(REPORT_DIR / "validation-daemon.log").write_text(out + "\n" + err, encoding="utf-8")
|
||||||
|
return {"status": "passed" if rc == 0 else "failed", "exit_code": rc, "log": "reports/codex/validation-daemon.log"}
|
||||||
|
|
||||||
|
|
||||||
|
def run_merge_gate(event: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
branch = event.get("branch") or "patch/web-ui-complete-gitops-dashboard-20260706"
|
||||||
|
base = event.get("base") or "main"
|
||||||
|
script = ROOT / "scripts" / "autonomous-merge-gate.sh"
|
||||||
|
if not script.exists():
|
||||||
|
return {"status": "blocked", "reason": "merge_gate_script_missing"}
|
||||||
|
rc, out, err = run_cmd(["bash", str(script), branch, base], timeout=600)
|
||||||
|
(REPORT_DIR / "merge-gate-daemon.log").write_text(out + "\n" + err, encoding="utf-8")
|
||||||
|
return {"status": "passed" if rc == 0 else "failed", "exit_code": rc, "log": "reports/codex/merge-gate-daemon.log"}
|
||||||
|
|
||||||
|
|
||||||
|
def handle_event(event: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
event_type = event.get("type", "unknown")
|
||||||
|
action = event.get("action", event_type)
|
||||||
|
|
||||||
|
if action in ALWAYS_BLOCKED:
|
||||||
|
req = create_request(event, f"Blocked permanently by autonomous production policy: {action}")
|
||||||
|
audit("event.blocked", "blocked", {"event": event, "request": req})
|
||||||
|
return {"status": "blocked", "request": req}
|
||||||
|
|
||||||
|
summary = build_summary(event)
|
||||||
|
|
||||||
|
if event_type in OWNER_NOTIFY_EVENTS:
|
||||||
|
notify_result = send_owner_wechat(event, summary)
|
||||||
|
audit("owner.notify", notify_result.get("status", "unknown"), {"event": event, "summary": summary, "result": notify_result})
|
||||||
|
|
||||||
|
if event_type in {"git_push", "patch_created", "validation_failed"}:
|
||||||
|
validation = run_validation(event)
|
||||||
|
audit("codex.validation", validation["status"], {"event": event, "validation": validation})
|
||||||
|
if validation["status"] != "passed":
|
||||||
|
req = create_request(event, "Validation failed; GPT repair loop required.")
|
||||||
|
return {"status": "validation_failed", "request": req, "validation": validation}
|
||||||
|
gate = run_merge_gate(event)
|
||||||
|
audit("codex.merge_gate", gate["status"], {"event": event, "gate": gate})
|
||||||
|
return {"status": "merge_candidate" if gate["status"] == "passed" else "gate_failed", "validation": validation, "gate": gate}
|
||||||
|
|
||||||
|
if action in PRODUCTION_ACTIONS:
|
||||||
|
if has_preapproval(action, event):
|
||||||
|
# This daemon deliberately records intent. The actual protected write must be
|
||||||
|
# performed by configured GitOps Actions with server-side authorization.
|
||||||
|
req = create_request(event, f"Preapproval found for {action}; execute through protected GitOps Action.")
|
||||||
|
audit("production.preapproved", "request_created", {"event": event, "request": req})
|
||||||
|
return {"status": "preapproved_request_created", "request": req}
|
||||||
|
req = create_request(event, f"Production action requires preapproved runbook: {action}")
|
||||||
|
audit("production.approval_required", "pending", {"event": event, "request": req})
|
||||||
|
return {"status": "approval_required", "request": req}
|
||||||
|
|
||||||
|
audit("event.record", "ok", {"event": event, "summary": summary})
|
||||||
|
return {"status": "recorded", "summary": summary}
|
||||||
|
|
||||||
|
|
||||||
|
def process_once() -> int:
|
||||||
|
count = 0
|
||||||
|
for path in read_event_files():
|
||||||
|
try:
|
||||||
|
event = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
result = handle_event(event)
|
||||||
|
archive_event(path, "done")
|
||||||
|
count += 1
|
||||||
|
audit("event.processed", "ok", {"path": str(path), "result": result})
|
||||||
|
except Exception as exc: # noqa: BLE001 - daemon must not crash on single event
|
||||||
|
archive_event(path, "failed")
|
||||||
|
audit("event.processed", "failed", {"path": str(path), "error": str(exc)})
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def emit_event(args: argparse.Namespace) -> None:
|
||||||
|
event = {
|
||||||
|
"id": args.id or f"evt-{utc_now().replace(':', '-')}",
|
||||||
|
"type": args.type,
|
||||||
|
"action": args.action or args.type,
|
||||||
|
"branch": args.branch,
|
||||||
|
"base": args.base,
|
||||||
|
"from_version": args.from_version,
|
||||||
|
"to_version": args.to_version,
|
||||||
|
"risk": args.risk,
|
||||||
|
"created_at": utc_now(),
|
||||||
|
}
|
||||||
|
write_json(EVENT_DIR / f"{event['id']}.json", event)
|
||||||
|
print(json.dumps(redact(event), ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[Iterable[str]] = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="AIOS Autonomous GitOps Daemon")
|
||||||
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
run = sub.add_parser("run")
|
||||||
|
run.add_argument("--interval", type=int, default=30)
|
||||||
|
run.add_argument("--once", action="store_true")
|
||||||
|
|
||||||
|
emit = sub.add_parser("emit")
|
||||||
|
emit.add_argument("--id")
|
||||||
|
emit.add_argument("--type", required=True)
|
||||||
|
emit.add_argument("--action")
|
||||||
|
emit.add_argument("--branch", default="patch/web-ui-complete-gitops-dashboard-20260706")
|
||||||
|
emit.add_argument("--base", default="main")
|
||||||
|
emit.add_argument("--from-version", default="V1R1C4")
|
||||||
|
emit.add_argument("--to-version", default="V1R1C5")
|
||||||
|
emit.add_argument("--risk", default="high")
|
||||||
|
|
||||||
|
args = parser.parse_args(list(argv) if argv is not None else None)
|
||||||
|
|
||||||
|
if args.cmd == "emit":
|
||||||
|
emit_event(args)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.cmd == "run":
|
||||||
|
EVENT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
AUDIT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
REQUEST_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
if args.once:
|
||||||
|
return 0 if process_once() >= 0 else 1
|
||||||
|
audit("daemon.start", "ok", {"event_dir": str(EVENT_DIR)})
|
||||||
|
while True:
|
||||||
|
process_once()
|
||||||
|
time.sleep(args.interval)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""AIOS Multi-Agent Consensus Controller
|
||||||
|
|
||||||
|
This service aggregates decisions from OpenClaw, Codex, and ChatGPT agents
|
||||||
|
and computes a bounded consensus decision for GitOps operations.
|
||||||
|
|
||||||
|
It does NOT directly modify production state. It only produces decision artifacts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import time
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(os.environ.get("AIOS_REPO_ROOT", ".")).resolve()
|
||||||
|
CONSENSUS_DIR = ROOT / "reports" / "consensus"
|
||||||
|
AGENT_DIRS = {
|
||||||
|
"openclaw": ROOT / "reports" / "openclaw",
|
||||||
|
"codex": ROOT / "reports" / "codex",
|
||||||
|
"chatgpt": ROOT / "reports" / "chatgpt",
|
||||||
|
}
|
||||||
|
|
||||||
|
CONSENSUS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def load_latest(path: pathlib.Path) -> Dict[str, Any]:
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
files = sorted(path.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||||
|
if not files:
|
||||||
|
return {}
|
||||||
|
return json.loads(files[0].read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def compute_consensus(reports: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
weights = {"openclaw": 2, "codex": 2, "chatgpt": 1}
|
||||||
|
|
||||||
|
score = 0
|
||||||
|
for agent, report in reports.items():
|
||||||
|
status = report.get("status", "unknown")
|
||||||
|
if status in ("PASS", "approved", "merge_candidate"):
|
||||||
|
score += weights.get(agent, 1)
|
||||||
|
elif status in ("FAIL", "blocked"):
|
||||||
|
score -= weights.get(agent, 1)
|
||||||
|
|
||||||
|
if score >= 4:
|
||||||
|
decision = "approved"
|
||||||
|
elif score >= 2:
|
||||||
|
decision = "conditional"
|
||||||
|
else:
|
||||||
|
decision = "rejected"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"decision": decision,
|
||||||
|
"score": score,
|
||||||
|
"timestamp": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
reports = {
|
||||||
|
"openclaw": load_latest(AGENT_DIRS["openclaw"]),
|
||||||
|
"codex": load_latest(AGENT_DIRS["codex"]),
|
||||||
|
"chatgpt": load_latest(AGENT_DIRS["chatgpt"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = compute_consensus(reports)
|
||||||
|
|
||||||
|
out_path = CONSENSUS_DIR / "decision.json"
|
||||||
|
out_path.write_text(json.dumps(result, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
print("CONSENSUS:", result)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""AIOS Event Ingress Server
|
||||||
|
|
||||||
|
Receives GitOps events via HTTP and writes them into the local event bus directory.
|
||||||
|
No authentication secrets are embedded; optional signature validation can be enabled via env.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import time
|
||||||
|
|
||||||
|
EVENT_DIR = pathlib.Path(os.environ.get("AIOS_EVENT_DIR", "runtime/gitops/events"))
|
||||||
|
EVENT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def do_POST(self):
|
||||||
|
length = int(self.headers.get('Content-Length', 0))
|
||||||
|
body = self.rfile.read(length).decode('utf-8')
|
||||||
|
try:
|
||||||
|
event = json.loads(body)
|
||||||
|
except Exception:
|
||||||
|
self.send_response(400)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
|
||||||
|
event_id = event.get("id", f"evt-{int(time.time())}")
|
||||||
|
path = EVENT_DIR / f"{event_id}.json"
|
||||||
|
path.write_text(json.dumps(event, indent=2), encoding='utf-8')
|
||||||
|
|
||||||
|
self.send_response(200)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b"OK")
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
return
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
port = int(os.environ.get("AIOS_INGRESS_PORT", "8787"))
|
||||||
|
server = HTTPServer(("0.0.0.0", port), Handler)
|
||||||
|
print(f"[AIOS] Event ingress running on {port}")
|
||||||
|
server.serve_forever()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""V1R1 Personal WeChat Endpoint Discovery
|
||||||
|
|
||||||
|
Helps locate active V1R1 personal WeChat runtime endpoints on the host.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import re
|
||||||
|
|
||||||
|
CANDIDATES = [8788, 8789, 8790, 8088, 8000]
|
||||||
|
|
||||||
|
print("[AIOS] Scanning for V1R1 WeChat endpoints...")
|
||||||
|
|
||||||
|
for port in CANDIDATES:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["bash", "-lc", f"ss -ltnp | grep {port}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
if result.stdout:
|
||||||
|
print(f"[FOUND] Port {port}: {result.stdout.strip()}")
|
||||||
|
else:
|
||||||
|
print(f"[MISS] Port {port}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] Port {port}: {e}")
|
||||||
|
|
||||||
|
print("[AIOS] Discovery complete. Please map discovered endpoint to AIOS_V1R1_WECHAT_OUTBOUND_URL")
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""AIOS V1R1 Personal WeChat Outbound Worker.
|
||||||
|
|
||||||
|
This worker is the bridge between the Xiamayiti self-evolution loop and the
|
||||||
|
already-deployed V1R1 personal WeChat capability.
|
||||||
|
|
||||||
|
Design rules:
|
||||||
|
- Do not embed credentials.
|
||||||
|
- Do not access runtime/secrets directly.
|
||||||
|
- Read owner-notification outbox JSON files.
|
||||||
|
- Deliver to a runtime-configured V1R1 notification endpoint, or try local V1R1 candidates.
|
||||||
|
- Write delivery status for audit/Web UI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(os.environ.get("AIOS_REPO_ROOT", "/data/aios-gitops/workspace"))
|
||||||
|
BASE = pathlib.Path(os.environ.get("AIOS_RUNTIME_ROOT", "/data/aios-gitops/runtime"))
|
||||||
|
OUTBOX = pathlib.Path(os.environ.get("AIOS_V1R1_WECHAT_OUTBOX", str(BASE / "integrations" / "v1r1-personal-wechat" / "outbox")))
|
||||||
|
SENT = pathlib.Path(os.environ.get("AIOS_V1R1_WECHAT_SENT", str(BASE / "integrations" / "v1r1-personal-wechat" / "sent")))
|
||||||
|
FAILED = pathlib.Path(os.environ.get("AIOS_V1R1_WECHAT_FAILED", str(BASE / "integrations" / "v1r1-personal-wechat" / "failed")))
|
||||||
|
STATUS = pathlib.Path(os.environ.get("AIOS_V1R1_WECHAT_STATUS", str(BASE / "integrations" / "v1r1-personal-wechat" / "status")))
|
||||||
|
AUDIT = pathlib.Path(os.environ.get("AIOS_AUDIT_DIR", str(BASE / "audit")))
|
||||||
|
INTERVAL = int(os.environ.get("AIOS_V1R1_WECHAT_INTERVAL", "10"))
|
||||||
|
|
||||||
|
# Runtime-configured endpoint. This can point to the existing V1R1 personal-WeChat module.
|
||||||
|
PRIMARY_ENDPOINT = os.environ.get("AIOS_V1R1_WECHAT_OUTBOUND_URL", "")
|
||||||
|
|
||||||
|
LOCAL_CANDIDATES = [
|
||||||
|
"http://127.0.0.1:8788/api/v1r1/personal-wechat/send",
|
||||||
|
"http://127.0.0.1:8788/api/v1r1/notification/owner",
|
||||||
|
"http://127.0.0.1:8789/api/owner/wechat/send",
|
||||||
|
"http://127.0.0.1:8790/owner/notify",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def now() -> str:
|
||||||
|
return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(path: pathlib.Path, value: Dict[str, Any]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
tmp.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def audit(action: str, status: str, detail: Dict[str, Any]) -> None:
|
||||||
|
record = {"ts": now(), "action": action, "status": status, "detail": detail}
|
||||||
|
safe_name = record["ts"].replace(":", "-") + f"-{action}.json"
|
||||||
|
write_json(AUDIT / safe_name, record)
|
||||||
|
print(json.dumps(record, ensure_ascii=False), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_message(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
title = item.get("title") or "AIOS GitOps 通报"
|
||||||
|
content = item.get("content") or item.get("message") or json.dumps(item, ensure_ascii=False)
|
||||||
|
priority = item.get("priority") or "normal"
|
||||||
|
return {
|
||||||
|
"event_id": item.get("event_id") or item.get("id") or f"evt-{int(time.time())}",
|
||||||
|
"channel": "personal_wechat",
|
||||||
|
"priority": priority,
|
||||||
|
"title": title,
|
||||||
|
"content": content,
|
||||||
|
"source": item.get("source") or "aios-self-evolution",
|
||||||
|
"branch": item.get("branch"),
|
||||||
|
"from_version": item.get("from_version"),
|
||||||
|
"to_version": item.get("to_version"),
|
||||||
|
"rollback_target": item.get("rollback_target") or "V1R1C4",
|
||||||
|
"web_ui_audit_url": item.get("web_ui_audit_url") or "/portal/me/#gitops-audit",
|
||||||
|
"created_at": item.get("created_at") or now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def endpoints() -> List[str]:
|
||||||
|
values: List[str] = []
|
||||||
|
if PRIMARY_ENDPOINT:
|
||||||
|
values.append(PRIMARY_ENDPOINT)
|
||||||
|
values.extend(LOCAL_CANDIDATES)
|
||||||
|
# Preserve order but remove duplicates.
|
||||||
|
result: List[str] = []
|
||||||
|
seen = set()
|
||||||
|
for value in values:
|
||||||
|
if value and value not in seen:
|
||||||
|
result.append(value)
|
||||||
|
seen.add(value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json"}, method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||||
|
text = resp.read().decode("utf-8", errors="replace")[:500]
|
||||||
|
return {"ok": 200 <= resp.status < 300, "status": resp.status, "response": text, "url": url}
|
||||||
|
|
||||||
|
|
||||||
|
def deliver(message: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
attempts = []
|
||||||
|
for url in endpoints():
|
||||||
|
try:
|
||||||
|
result = post_json(url, message)
|
||||||
|
attempts.append(result)
|
||||||
|
if result.get("ok"):
|
||||||
|
return {"status": "sent", "endpoint": url, "attempts": attempts}
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
attempts.append({"ok": False, "url": url, "error": str(exc)})
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
attempts.append({"ok": False, "url": url, "error": str(exc)})
|
||||||
|
return {"status": "failed", "attempts": attempts, "reason": "no_endpoint_accepted_message"}
|
||||||
|
|
||||||
|
|
||||||
|
def process_file(path: pathlib.Path) -> None:
|
||||||
|
try:
|
||||||
|
item = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
failed_target = FAILED / path.name
|
||||||
|
FAILED.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.move(str(path), str(failed_target))
|
||||||
|
audit("v1r1.wechat.parse", "failed", {"path": str(path), "error": str(exc)})
|
||||||
|
return
|
||||||
|
|
||||||
|
message = normalize_message(item)
|
||||||
|
result = deliver(message)
|
||||||
|
status_record = {"ts": now(), "file": path.name, "message": message, "delivery": result}
|
||||||
|
write_json(STATUS / (path.stem + ".status.json"), status_record)
|
||||||
|
|
||||||
|
if result.get("status") == "sent":
|
||||||
|
SENT.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.move(str(path), str(SENT / path.name))
|
||||||
|
audit("v1r1.wechat.deliver", "sent", status_record)
|
||||||
|
else:
|
||||||
|
FAILED.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.move(str(path), str(FAILED / path.name))
|
||||||
|
audit("v1r1.wechat.deliver", "failed", status_record)
|
||||||
|
|
||||||
|
|
||||||
|
def process_once() -> int:
|
||||||
|
OUTBOX.mkdir(parents=True, exist_ok=True)
|
||||||
|
count = 0
|
||||||
|
for path in sorted(OUTBOX.glob("*.json"), key=lambda p: p.stat().st_mtime):
|
||||||
|
process_file(path)
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def emit_startup_notice() -> pathlib.Path:
|
||||||
|
OUTBOX.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = OUTBOX / f"startup-{int(time.time())}.json"
|
||||||
|
write_json(path, {
|
||||||
|
"event_id": f"startup-{int(time.time())}",
|
||||||
|
"type": "owner_notification",
|
||||||
|
"channel": "personal_wechat",
|
||||||
|
"priority": "high",
|
||||||
|
"title": "AIOS 自我进化系统已启动",
|
||||||
|
"content": "AIOS 虾马一体 self-evolution loop 已进入运行态。daemon、event bus、GitOps 写链路已恢复;后续将通过 V1R1 个人微信模块与 Web UI 审计同步进展。",
|
||||||
|
"source": "aios-v1r1-wechat-outbound-worker",
|
||||||
|
"branch": "patch/web-ui-complete-gitops-dashboard-20260706",
|
||||||
|
"from_version": "V1R1C4",
|
||||||
|
"to_version": "V1R1C5",
|
||||||
|
"rollback_target": "V1R1C4",
|
||||||
|
"web_ui_audit_url": "/portal/me/#gitops-audit",
|
||||||
|
"created_at": now()
|
||||||
|
})
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
import argparse
|
||||||
|
parser = argparse.ArgumentParser(description="AIOS V1R1 personal WeChat outbound worker")
|
||||||
|
parser.add_argument("--once", action="store_true")
|
||||||
|
parser.add_argument("--emit-startup-notice", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
OUTBOX.mkdir(parents=True, exist_ok=True)
|
||||||
|
SENT.mkdir(parents=True, exist_ok=True)
|
||||||
|
FAILED.mkdir(parents=True, exist_ok=True)
|
||||||
|
STATUS.mkdir(parents=True, exist_ok=True)
|
||||||
|
AUDIT.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if args.emit_startup_notice:
|
||||||
|
path = emit_startup_notice()
|
||||||
|
audit("v1r1.wechat.outbox", "created", {"path": str(path)})
|
||||||
|
|
||||||
|
if args.once:
|
||||||
|
process_once()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
audit("v1r1.wechat.worker.start", "ok", {"outbox": str(OUTBOX), "has_primary_endpoint": bool(PRIMARY_ENDPOINT)})
|
||||||
|
while True:
|
||||||
|
process_once()
|
||||||
|
time.sleep(INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Skill: GitOps Platform Validation
|
||||||
|
|
||||||
|
## Name
|
||||||
|
gitops-platform-validation
|
||||||
|
|
||||||
|
## Version
|
||||||
|
1.0.0
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Provide reusable Codex execution behavior for validating GitOps platform UI branches and enabling self-healing CI loops.
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
- branch matches: patch/*
|
||||||
|
- contains: gitops-dashboard
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
1. Run validation script
|
||||||
|
```bash
|
||||||
|
bash scripts/validate-gitops-platform-ui.sh <branch> main
|
||||||
|
```
|
||||||
|
|
||||||
|
2. If FAIL:
|
||||||
|
- generate failure report
|
||||||
|
- invoke GPT repair loop
|
||||||
|
- re-run validation
|
||||||
|
|
||||||
|
3. If PASS:
|
||||||
|
- run autonomous merge gate
|
||||||
|
```bash
|
||||||
|
bash scripts/autonomous-merge-gate.sh <branch> main
|
||||||
|
```
|
||||||
|
|
||||||
|
4. If merge candidate created:
|
||||||
|
- propose V1R1C5 baseline update
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- no secrets in logs
|
||||||
|
- no direct main write
|
||||||
|
- destructive operations forbidden
|
||||||
|
- all merges must pass policy gate
|
||||||
|
|
||||||
|
## Self-Healing Behavior
|
||||||
|
- max iterations: 5
|
||||||
|
- stop on:
|
||||||
|
- PASS
|
||||||
|
- security failure
|
||||||
|
- manual override
|
||||||
|
|
||||||
|
## Output
|
||||||
|
- validation report
|
||||||
|
- merge candidate artifact
|
||||||
|
- optional baseline proposal
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
{
|
||||||
|
"suite": "gitops-platform-ui",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"branch_under_test": "patch/web-ui-complete-gitops-dashboard-20260706",
|
||||||
|
"base_branch": "main",
|
||||||
|
"purpose": "Validate AIOS GitOps Platform Control Plane UI before merge to main and before V1R1C5 baseline proposal.",
|
||||||
|
"required_result": "PASS",
|
||||||
|
"tests": [
|
||||||
|
{
|
||||||
|
"id": "STATIC-001",
|
||||||
|
"group": "static_files",
|
||||||
|
"required": true,
|
||||||
|
"description": "Platform UI files exist.",
|
||||||
|
"assert": {
|
||||||
|
"files_exist": [
|
||||||
|
"overlays/user-control-center/portal/gitops-dashboard/index.html",
|
||||||
|
"overlays/user-control-center/portal/gitops-dashboard/app.js",
|
||||||
|
"overlays/user-control-center/portal/gitops-dashboard/styles.css",
|
||||||
|
"overlays/user-control-center/portal/gitops-dashboard/manifest.json",
|
||||||
|
"overlays/user-control-center/portal/gitops-dashboard/gitops-ui-policy.json",
|
||||||
|
"overlays/user-control-center/portal/gitops-dashboard/gitops-enterprise-api.contract.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "STATIC-002",
|
||||||
|
"group": "static_files",
|
||||||
|
"required": true,
|
||||||
|
"description": "Enterprise tabs exist in HTML.",
|
||||||
|
"assert": {
|
||||||
|
"file_contains": {
|
||||||
|
"overlays/user-control-center/portal/gitops-dashboard/index.html": [
|
||||||
|
"tab-overview",
|
||||||
|
"tab-rbac",
|
||||||
|
"tab-approval",
|
||||||
|
"tab-diff",
|
||||||
|
"tab-patch",
|
||||||
|
"tab-model",
|
||||||
|
"tab-release",
|
||||||
|
"tab-audit"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "RUNTIME-001",
|
||||||
|
"group": "frontend_runtime",
|
||||||
|
"required": true,
|
||||||
|
"description": "Platform runtime bootstraps on DOMContentLoaded.",
|
||||||
|
"assert": {
|
||||||
|
"file_contains": {
|
||||||
|
"overlays/user-control-center/portal/gitops-dashboard/app.js": [
|
||||||
|
"Platform.bootstrap",
|
||||||
|
"DOMContentLoaded",
|
||||||
|
"guardAction",
|
||||||
|
"createApproval",
|
||||||
|
"renderDiffTree",
|
||||||
|
"audit(" ,
|
||||||
|
"redact("
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "SEC-001",
|
||||||
|
"group": "security",
|
||||||
|
"required": true,
|
||||||
|
"description": "No real credentials are committed.",
|
||||||
|
"assert": {
|
||||||
|
"secret_scan": {
|
||||||
|
"paths": [
|
||||||
|
"overlays/user-control-center/portal/gitops-dashboard",
|
||||||
|
"docs/codex",
|
||||||
|
"tests/acceptance",
|
||||||
|
"scripts",
|
||||||
|
"skills"
|
||||||
|
],
|
||||||
|
"allow_patterns": [
|
||||||
|
"secret_ref://",
|
||||||
|
"[REDACTED]",
|
||||||
|
"api_key_secret_ref",
|
||||||
|
"forbiddenFields",
|
||||||
|
"secret_redaction",
|
||||||
|
"No credentials"
|
||||||
|
],
|
||||||
|
"deny_patterns": [
|
||||||
|
"Bearer [A-Za-z0-9._-]{20,}",
|
||||||
|
"sk-[A-Za-z0-9]{20,}",
|
||||||
|
"ghp_[A-Za-z0-9]{20,}",
|
||||||
|
"AKIA[0-9A-Z]{16}",
|
||||||
|
"BEGIN PRIVATE KEY",
|
||||||
|
"BEGIN RSA PRIVATE KEY"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "SEC-002",
|
||||||
|
"group": "security",
|
||||||
|
"required": true,
|
||||||
|
"description": "Model registration requires secret_ref and never raw api_key.",
|
||||||
|
"assert": {
|
||||||
|
"file_contains": {
|
||||||
|
"overlays/user-control-center/portal/gitops-dashboard/app.js": [
|
||||||
|
"api_key_secret_ref",
|
||||||
|
"startsWith('secret_ref://')",
|
||||||
|
"api_key_secret_ref_required"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"file_not_contains": {
|
||||||
|
"overlays/user-control-center/portal/gitops-dashboard/index.html": [
|
||||||
|
"name=\"api_key\"",
|
||||||
|
"name=\"token\"",
|
||||||
|
"name=\"password\""
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "POLICY-001",
|
||||||
|
"group": "gitops_policy",
|
||||||
|
"required": true,
|
||||||
|
"description": "Direct main writes and destructive actions are disabled or blocked.",
|
||||||
|
"assert": {
|
||||||
|
"file_contains": {
|
||||||
|
"overlays/user-control-center/portal/gitops-dashboard/app.js": [
|
||||||
|
"main:write",
|
||||||
|
"destructive_action_blocked",
|
||||||
|
"high_risk_requires_approval"
|
||||||
|
],
|
||||||
|
"overlays/user-control-center/portal/gitops-dashboard/gitops-ui-policy.json": [
|
||||||
|
"direct_main_write",
|
||||||
|
"disabled_in_ui",
|
||||||
|
"bulk_delete",
|
||||||
|
"forbidden"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "API-001",
|
||||||
|
"group": "api_contract",
|
||||||
|
"required": true,
|
||||||
|
"description": "Read/write endpoint contract remains compatible with existing GitOps APIs.",
|
||||||
|
"assert": {
|
||||||
|
"file_contains": {
|
||||||
|
"overlays/user-control-center/portal/gitops-dashboard/gitops-enterprise-api.contract.json": [
|
||||||
|
"GET /api/base",
|
||||||
|
"GET /api/ver",
|
||||||
|
"GET /api/run",
|
||||||
|
"GET /api/patch",
|
||||||
|
"GET /api/model",
|
||||||
|
"POST /api/branch",
|
||||||
|
"POST /api/patch/plan",
|
||||||
|
"POST /api/model",
|
||||||
|
"PUT /api/model-priority",
|
||||||
|
"POST /api/rollback-request",
|
||||||
|
"POST /api/tag-request"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "REGRESSION-001",
|
||||||
|
"group": "indirect_regression",
|
||||||
|
"required": true,
|
||||||
|
"description": "This branch must not alter auth, gitea owner sync, baseline version, or destructive history files.",
|
||||||
|
"assert": {
|
||||||
|
"diff_not_touch_paths": [
|
||||||
|
"config/auth/",
|
||||||
|
"config/casdoor/",
|
||||||
|
"config/gitea/",
|
||||||
|
"versions/V1R1C4.json",
|
||||||
|
"runtime/gitops/current.json"
|
||||||
|
],
|
||||||
|
"diff_not_contain": [
|
||||||
|
"active_version\": \"V1R1C5",
|
||||||
|
"force push",
|
||||||
|
"delete tag",
|
||||||
|
"purge baseline",
|
||||||
|
"clear audit"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BASELINE-001",
|
||||||
|
"group": "baseline_proposal",
|
||||||
|
"required": true,
|
||||||
|
"description": "A V1R1C5 proposal exists but is not activated before merge.",
|
||||||
|
"assert": {
|
||||||
|
"files_exist": [
|
||||||
|
"versions/proposals/V1R1C5-gitops-platform-ui.proposed.json"
|
||||||
|
],
|
||||||
|
"file_contains": {
|
||||||
|
"versions/proposals/V1R1C5-gitops-platform-ui.proposed.json": [
|
||||||
|
"V1R1C5",
|
||||||
|
"V1R1C4",
|
||||||
|
"approval_required"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "SKILL-001",
|
||||||
|
"group": "auto_loaded_skill",
|
||||||
|
"required": true,
|
||||||
|
"description": "Codex/GPT reusable skill exists and references the validation workflow.",
|
||||||
|
"assert": {
|
||||||
|
"files_exist": [
|
||||||
|
"skills/gitops-platform-validation/SKILL.md"
|
||||||
|
],
|
||||||
|
"file_contains": {
|
||||||
|
"skills/gitops-platform-validation/SKILL.md": [
|
||||||
|
"validate-gitops-platform-ui.sh",
|
||||||
|
"gitops-platform-ui.acceptance.json",
|
||||||
|
"self-healing"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"merge_gate": {
|
||||||
|
"allow_merge_to_main_when": [
|
||||||
|
"all required tests PASS",
|
||||||
|
"gray deployment PASS",
|
||||||
|
"no secret leak",
|
||||||
|
"no direct main write",
|
||||||
|
"V1R1C5 proposal reviewed",
|
||||||
|
"owner/admin approval recorded"
|
||||||
|
],
|
||||||
|
"block_merge_when": [
|
||||||
|
"any required test FAIL",
|
||||||
|
"any required test BLOCKED without explicit owner override",
|
||||||
|
"secret leak detected",
|
||||||
|
"destructive operation detected",
|
||||||
|
"baseline active version changed before merge"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"version": "V1R1C5",
|
||||||
|
"status": "proposed",
|
||||||
|
"activation": "approval_required",
|
||||||
|
"base_version": "V1R1C4",
|
||||||
|
"rollback_target": "V1R1C4",
|
||||||
|
"purpose": "Promote AIOS GitOps Platform Control Plane with autonomous CI, self-healing validation, bounded production policy, event bus, daemon, owner notification, Web UI audit and Codex validation skills.",
|
||||||
|
"risk": "high",
|
||||||
|
"approval": {
|
||||||
|
"required": true,
|
||||||
|
"roles": ["owner", "admin"],
|
||||||
|
"reason": "Introduces autonomous orchestration and production-adjacent daemon. Main merge, tag creation and baseline activation remain gated."
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"direct_changes": [
|
||||||
|
"overlays/user-control-center/portal/gitops-dashboard",
|
||||||
|
"services/aios-autonomous-daemon",
|
||||||
|
"config/codex",
|
||||||
|
"scripts",
|
||||||
|
"tests/acceptance",
|
||||||
|
"skills",
|
||||||
|
"docs/codex",
|
||||||
|
"docs/ops",
|
||||||
|
"deploy/systemd"
|
||||||
|
],
|
||||||
|
"indirect_impact": [
|
||||||
|
"Portal GitOps UI visibility",
|
||||||
|
"Owner notification workflow",
|
||||||
|
"Codex validation workflow",
|
||||||
|
"Self-healing patch loop",
|
||||||
|
"Web UI audit trail"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"non_goals": [
|
||||||
|
"No direct main push",
|
||||||
|
"No committed secrets",
|
||||||
|
"No destructive history deletion",
|
||||||
|
"No production rollback execution without approval or preapproved runbook"
|
||||||
|
],
|
||||||
|
"validation": {
|
||||||
|
"acceptance_suite": "tests/acceptance/gitops-platform-ui.acceptance.json",
|
||||||
|
"script": "scripts/validate-gitops-platform-ui.sh",
|
||||||
|
"merge_gate": "scripts/autonomous-merge-gate.sh",
|
||||||
|
"gray_validation": "scripts/gray-validate-gitops-platform-ui.sh"
|
||||||
|
},
|
||||||
|
"owner_notification": {
|
||||||
|
"required_on_main_version_change": true,
|
||||||
|
"channels": ["wechat_or_wecom", "web_ui_audit"],
|
||||||
|
"webhook_secret_ref": "secret_ref://aios/notifications/owner-wechat-webhook"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user