Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0b6c9b78e | ||
|
|
187dcc3554 | ||
|
|
75fd3c6274 | ||
|
|
ac5518588f | ||
|
|
54a2b7ea41 | ||
|
|
3803f33d7b | ||
|
|
62fb0613a0 | ||
|
|
64e3e087f2 | ||
|
|
5c470b8833 | ||
|
|
89a0a26b3b | ||
|
|
0a6d50fff3 | ||
|
|
6c4f0a988d | ||
|
|
7d79f40324 | ||
|
|
1fec447ca2 | ||
|
|
c30d5aa12b | ||
|
|
90b50a853e | ||
|
|
a1de052ff8 | ||
|
|
b3f9bdb759 | ||
|
|
2d3061d825 | ||
|
|
9becff85ee | ||
|
|
b63a893d37 | ||
|
|
363a061cd3 | ||
|
|
9ce79275e0 | ||
|
|
087366bafb | ||
|
|
cd495c15fe | ||
|
|
f211fc1a96 | ||
|
|
6b879c7539 | ||
|
|
5ea27d7529 | ||
|
|
af01b48f11 | ||
|
|
84c327816b | ||
|
|
edbc1397c1 | ||
|
|
86e3bc351d | ||
|
|
60cefc1a60 | ||
|
|
81c9a3759a | ||
|
|
ab9a744d63 | ||
|
|
65f88f6a7f | ||
|
|
08a1c3f24d | ||
|
|
730fbb61ab |
@@ -1,76 +0,0 @@
|
||||
{
|
||||
"contract_commit": "cbe33ecab8e9794ba5f318bea43aeaf447a56fbc",
|
||||
"created_at": "2026-07-18T04:58:00+00:00",
|
||||
"schema_version": 2,
|
||||
"state_writer": "gateway",
|
||||
"work_units": [
|
||||
{
|
||||
"id": "R0-DISCOVER-SECRET-PROVIDER",
|
||||
"track": "R_RUNNER",
|
||||
"gate": "R0_RUNNER_LIVE_RECOVERY",
|
||||
"primary": "openclaw",
|
||||
"fallback": "codex",
|
||||
"status": "READY",
|
||||
"quorum": "requires_gpt_plus_one_executor"
|
||||
},
|
||||
{
|
||||
"id": "P2-INVENTORY-REAL-EVIDENCE",
|
||||
"track": "P_PERSONAL_WEIXIN",
|
||||
"gate": "P2_REAL_B3_EVIDENCE",
|
||||
"primary": "openclaw",
|
||||
"fallback": "codex",
|
||||
"status": "READY",
|
||||
"quorum": "requires_gpt_plus_one_executor"
|
||||
},
|
||||
{
|
||||
"id": "P1-POST-CONTRACT-REACK",
|
||||
"track": "P_PERSONAL_WEIXIN",
|
||||
"gate": "P1_REPAIR3_CODE",
|
||||
"primary": "codex",
|
||||
"fallback": "openclaw",
|
||||
"status": "READY",
|
||||
"quorum": "requires_gpt_plus_one_executor"
|
||||
},
|
||||
{
|
||||
"id": "Q0-GPT-HEALTH-INIT",
|
||||
"track": "_QUORUM",
|
||||
"gate": "Q0_GPT_AND_EXECUTOR_HEALTH",
|
||||
"primary": "gateway",
|
||||
"fallback": null,
|
||||
"status": "READY",
|
||||
"quorum": "gateway_initiated"
|
||||
},
|
||||
{
|
||||
"id": "Q1-ACTIVE-EXECUTOR-ELECTION",
|
||||
"track": "_QUORUM",
|
||||
"gate": "Q1_ELECT_ACTIVE_EXECUTOR",
|
||||
"primary": "gateway",
|
||||
"fallback": null,
|
||||
"status": "PENDING",
|
||||
"depends_on": "Q0-GPT-HEALTH-INIT",
|
||||
"quorum": "gateway_initiated"
|
||||
},
|
||||
{
|
||||
"id": "Q2-FAILOVER-HEALTHCHECK",
|
||||
"track": "_QUORUM",
|
||||
"gate": "Q2_STANDBY_HEALTH_MONITOR",
|
||||
"primary": "gateway",
|
||||
"fallback": null,
|
||||
"status": "PENDING",
|
||||
"depends_on": "Q1-ACTIVE-EXECUTOR-ELECTION",
|
||||
"quorum": "log_only_if_active_ok"
|
||||
},
|
||||
{
|
||||
"id": "Q3-UNATTENDED-ACCEPTANCE-MATRIX",
|
||||
"track": "_QUORUM",
|
||||
"gate": "A0_AUTOMATION_CLOSED",
|
||||
"primary": "gateway",
|
||||
"fallback": null,
|
||||
"status": "PENDING_LIVE_EVIDENCE",
|
||||
"depends_on": "Q2-FAILOVER-HEALTHCHECK",
|
||||
"quorum": "requires_gpt_plus_one_executor",
|
||||
"required_scenarios": 16,
|
||||
"may_mark_pass_without_live_evidence": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
{
|
||||
"adaptive_dispatch": {
|
||||
"codex_first": [
|
||||
"complex_code",
|
||||
"security_boundary",
|
||||
"architecture",
|
||||
"high_risk_review",
|
||||
"strong_model_materially_better"
|
||||
],
|
||||
"default_orchestrator": "openclaw",
|
||||
"dynamic_executor_selection": {
|
||||
"large_repetitive_infrastructure_monitoring": "openclaw",
|
||||
"complex_code_security_architecture": "codex",
|
||||
"both_healthy_active": "assign_by_preference",
|
||||
"one_healthy_active": "use_healthy_only",
|
||||
"both_unhealthy_blocked": true
|
||||
},
|
||||
"max_attempts_per_work_unit": 2,
|
||||
"on_timeout": [
|
||||
"mark_TIMEOUT",
|
||||
"preserve_logs",
|
||||
"switch_agent"
|
||||
],
|
||||
"openclaw_first": [
|
||||
"quality_close",
|
||||
"large_or_repetitive",
|
||||
"long_running",
|
||||
"infra_or_git_inventory",
|
||||
"cost_sensitive"
|
||||
],
|
||||
"single_state_writer_lease_required": true,
|
||||
"strict_role_partition": false,
|
||||
"state_writer": "gateway",
|
||||
"timeout_can_pass": false
|
||||
},
|
||||
"authoritative_state": {
|
||||
"b3": "BLOCKED_EVIDENCE_REQUIRED",
|
||||
"b4": "NOT_STARTED",
|
||||
"baseline": "V1R4C6",
|
||||
"production_release": "NOT_STARTED",
|
||||
"release": "paused",
|
||||
"repair3": {
|
||||
"base": "8d9c6da90fc8caa69b691d243c6fefb40b6b0575",
|
||||
"candidate": "PASS_CANDIDATE_PREPARED",
|
||||
"commit": "e110cda9968cadc5c7b7179ec5003b05aad11ce4",
|
||||
"exact4": "PASS",
|
||||
"result": "49a9c2e0cc898ecf2bbd45f454e007644d7f3045"
|
||||
},
|
||||
"runner": {
|
||||
"artifact_sha256": "85c472c05a1e55a5899b215d3cd41df53912787de529ac9cb3d5308da4158130",
|
||||
"commit": "bbd27cf06acf1f589eef291eab54d4b127aa3aea",
|
||||
"live": "BLOCKED_GATEWAY_SECRET_REF_UNAVAILABLE",
|
||||
"offline": "PASS",
|
||||
"result": "e789632e359e9232d8ae081e7c4f23ed7b615a9d"
|
||||
}
|
||||
},
|
||||
"central_gpt": {
|
||||
"required": "HEALTHY",
|
||||
"health_definition": {
|
||||
"live_probe_required": true,
|
||||
"workspace_agent_validated_live_or_responses_validated_live": true,
|
||||
"configuration_or_credential_presence_is_insufficient": true,
|
||||
"unknown_is_not_healthy": true
|
||||
},
|
||||
"independent_review": {
|
||||
"single_executor_mode": true,
|
||||
"required_evidence": [
|
||||
"committed_git_tree",
|
||||
"full_commit_oid",
|
||||
"test_logs_with_rc",
|
||||
"gitleaks",
|
||||
"artifact_digest",
|
||||
"worktree_bare_gitea_oid",
|
||||
"live_runtime_evidence"
|
||||
],
|
||||
"model_judgment_insufficient": true
|
||||
}
|
||||
},
|
||||
"contract_id": "V2R1-PERSONAL-WEIXIN-PRODUCTION-EXECUTION-CONTRACT",
|
||||
"executor_quorum": {
|
||||
"minimum_healthy": 1,
|
||||
"candidates": [
|
||||
"openclaw",
|
||||
"codex"
|
||||
],
|
||||
"quorum_pass": [
|
||||
"GPT_HEALTHY_AND_OPENCLAW_HEALTHY",
|
||||
"GPT_HEALTHY_AND_CODEX_HEALTHY"
|
||||
],
|
||||
"quorum_block": [
|
||||
"GPT_UNHEALTHY",
|
||||
"GPT_HEALTHY_BUT_BOTH_EXECUTORS_UNAVAILABLE",
|
||||
"GPT_OR_SOLE_EXECUTOR_UNKNOWN"
|
||||
]
|
||||
},
|
||||
"automation_closed_success_conditions": [
|
||||
"CENTRAL_GPT_HEALTH=PASS",
|
||||
"EXECUTOR_QUORUM=PASS",
|
||||
"ACTIVE_EXECUTOR=openclaw|codex",
|
||||
"EVENT_TRIGGER=PASS",
|
||||
"POLLING=PASS",
|
||||
"FAILOVER=PASS",
|
||||
"LIFECYCLE_SYNC=PASS",
|
||||
"TWO_CYCLE_UNATTENDED_LOOP=PASS",
|
||||
"RESTART_RECOVERY=PASS",
|
||||
"AUTOMATION_CLOSED=PASS"
|
||||
],
|
||||
"gateway_lifecycle_events": [
|
||||
"TASK_DISPATCHED",
|
||||
"TASK_STARTED",
|
||||
"TASK_ENDED",
|
||||
"DECISION_APPLIED",
|
||||
"PROJECT_SUMMARY",
|
||||
"PROJECT_COMPLETED"
|
||||
],
|
||||
"facts_policy": {
|
||||
"accept": [
|
||||
"committed_git_tree",
|
||||
"full_oid",
|
||||
"worktree_bare_gitea_oid_match",
|
||||
"test_log_with_rc",
|
||||
"immutable_digest",
|
||||
"result_only_commit",
|
||||
"live_evidence"
|
||||
],
|
||||
"reject": [
|
||||
"verbal_claim",
|
||||
"readme_only",
|
||||
"local_only_commit",
|
||||
"environment_variable_as_secret_ref",
|
||||
"compile_only",
|
||||
"timeout_as_pass",
|
||||
"unrelated_repo",
|
||||
"fabricated_evidence"
|
||||
]
|
||||
},
|
||||
"forbidden_git": [
|
||||
"reset",
|
||||
"amend",
|
||||
"rebase",
|
||||
"force_push",
|
||||
"direct_main_push",
|
||||
"direct_bare_ref_mutation",
|
||||
"unapproved_gitea_db_mutation",
|
||||
"reuse_accident_worktree",
|
||||
"delete_live_worktree_to_clear_collision"
|
||||
],
|
||||
"generated_at": "2026-07-18T04:58:00+00:00",
|
||||
"handoff": {
|
||||
"ack": "CONTRACT_ACK=<full_contract_commit_oid>",
|
||||
"advance": "independent_verification_required",
|
||||
"all_agents_match": true
|
||||
},
|
||||
"join_gate": {
|
||||
"requires": [
|
||||
"AUTOMATION_CLOSED",
|
||||
"RUNNER_RECOVERED",
|
||||
"B3_PASS",
|
||||
"B4_PASS",
|
||||
"INTEGRATION_PASS"
|
||||
]
|
||||
},
|
||||
"model_policy": {
|
||||
"codex": "strongest_model_actually_available_in_runtime",
|
||||
"gpt_5_6": "use_only_if_exposed",
|
||||
"no_unverified_model_claims": true,
|
||||
"openclaw_default": "DeepSeek V4 Flash"
|
||||
},
|
||||
"owner_authorization": {
|
||||
"adaptive_agent_assignment_authorized": true,
|
||||
"agent_substitution_authorized": true,
|
||||
"autonomous_execution": true,
|
||||
"may_bypass_release_controls": false,
|
||||
"may_bypass_secret_controls": false,
|
||||
"may_fabricate_evidence": false,
|
||||
"user_step_by_step_confirmation_required": false
|
||||
},
|
||||
"release_gates": [
|
||||
"AUTOMATION_CLOSED",
|
||||
"MAIN_MERGE",
|
||||
"STAGING_AND_CANARY",
|
||||
"PRODUCTION"
|
||||
],
|
||||
"schema_version": 4,
|
||||
"self_healing": {
|
||||
"priorities": {
|
||||
"p0": "central_gpt_failure",
|
||||
"p1": "both_executors_unavailable",
|
||||
"p2": "active_executor_failed_with_healthy_standby",
|
||||
"p3": "standby_executor_alone_failed"
|
||||
},
|
||||
"rules": {
|
||||
"standby_failure_when_active_ok": {
|
||||
"action": "log_only",
|
||||
"block_business": false,
|
||||
"create_blocking_gate": false,
|
||||
"pause_release": false
|
||||
},
|
||||
"active_executor_failure": {
|
||||
"action": "failover_to_standby",
|
||||
"inherit_contract_commit": true,
|
||||
"inherit_event_id": true,
|
||||
"inherit_state_version": true,
|
||||
"mark_original_task": "INTERRUPTED_AGENT_FAILOVER",
|
||||
"new_executor_continues_from_checkpoint": true,
|
||||
"block_duplicate_side_effects": true
|
||||
},
|
||||
"both_unavailable_block": "BLOCKED_NO_HEALTHY_EXECUTOR"
|
||||
}
|
||||
},
|
||||
"state_rules": [
|
||||
"tracks_run_parallel",
|
||||
"runner_block_does_not_block_offline_work_or_evidence_inventory",
|
||||
"join_requires_both_tracks",
|
||||
"failure_stops_dependents",
|
||||
"production_remains_V1R4C6_paused_until_production_pass",
|
||||
"gateway_persists_authoritative_state",
|
||||
"lifecycle_events_persisted_regardless_of_openclaw_online"
|
||||
],
|
||||
"status": "ACTIVE",
|
||||
"tracks": {
|
||||
"P_PERSONAL_WEIXIN": {
|
||||
"b3": "BLOCKED_EVIDENCE_REQUIRED",
|
||||
"b4": "NOT_STARTED",
|
||||
"parallel": true,
|
||||
"repair3": "PASS_CANDIDATE_PREPARED",
|
||||
"repair3_commit": "e110cda9968cadc5c7b7179ec5003b05aad11ce4"
|
||||
},
|
||||
"R_RUNNER": {
|
||||
"commit": "bbd27cf06acf1f589eef291eab54d4b127aa3aea",
|
||||
"current": "BLOCKED_GATEWAY_SECRET_REF_UNAVAILABLE",
|
||||
"fallback": "codex",
|
||||
"parallel": true,
|
||||
"primary": "openclaw",
|
||||
"required": [
|
||||
"artifact_verified",
|
||||
"approved_secret_provider_contract",
|
||||
"owner_probe_verified",
|
||||
"service_healthy",
|
||||
"heartbeat_healthy",
|
||||
"gateway_auth",
|
||||
"durable_task_create_read_cancel",
|
||||
"execution_channel",
|
||||
"live_rollback"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
# V2R1 Gate 1 — Clean-Room Final Evidence
|
||||
|
||||
## Summary
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Date | 2026-07-14 |
|
||||
| Source commit | `502ef30642597fb0b70b90ae2e8a0b6af217d80e` |
|
||||
| Source branch | `origin/work/v2r1-integration-quality-2000` |
|
||||
| Result branch | `fix/v2r1-g1-cleanroom-final-evidence-r2-20260714` |
|
||||
| Result commit reference | `HEAD` |
|
||||
| Gate | `G1` |
|
||||
| Variant | `openclaw-cleanroom` |
|
||||
| Status | `PASS` |
|
||||
|
||||
The immutable report uses `HEAD` for post-commit verification. The resolved commit object ID is recorded by the external acceptance result after this file is committed; a Git commit cannot reliably embed its own object ID because changing the file changes the tree and commit identity.
|
||||
|
||||
## Machine-Readable Result
|
||||
|
||||
```yaml
|
||||
gate: G1
|
||||
variant: openclaw-cleanroom
|
||||
date: 2026-07-14
|
||||
status: PASS
|
||||
source_commit: 502ef30642597fb0b70b90ae2e8a0b6af217d80e
|
||||
source_branch: origin/work/v2r1-integration-quality-2000
|
||||
result_branch: fix/v2r1-g1-cleanroom-final-evidence-r2-20260714
|
||||
result_commit_ref: HEAD
|
||||
tests_total: 32
|
||||
tests_passed: 32
|
||||
tests_failed: 0
|
||||
cleanup_scan: PASS
|
||||
secret_scan: PASS
|
||||
rollback_status: PASS
|
||||
recovery_verified: true
|
||||
workspace_clean_at_test_completion: true
|
||||
merge_performed: false
|
||||
tag_created: false
|
||||
baseline_promoted: false
|
||||
deployment_performed: false
|
||||
```
|
||||
|
||||
## Evidence Provenance
|
||||
|
||||
The test, cleanup, secret-scan, rollback, and recovery results below are execution evidence supplied by OpenClaw for the Gate 1 run. This correction commit changes only the evidence report and does not claim that the GitOps API reran the suites.
|
||||
|
||||
## HMAC Test-Key Length Verification
|
||||
|
||||
| Test file | Variable | Test-only value | Length |
|
||||
|---|---|---|---:|
|
||||
| `tests/v2r1/test_runtime_wiring.py:24` | `internal_key` | `b"runtime-internal-context-key-is-32bytes"` | 39 bytes |
|
||||
| `tests/v2r1/test_runtime_wiring.py:25` | `ingress_key` | `b"runtime-external-ingress-key-is-32bytes"` | 39 bytes |
|
||||
|
||||
Both values are test fixtures. The supplied evidence reports that no production secret was introduced.
|
||||
|
||||
## Test Results Supplied by OpenClaw
|
||||
|
||||
### V2R1 suite — 29 of 29 passed
|
||||
|
||||
```shell
|
||||
PYTHONPATH=active-runtime/app python3 -m pytest \
|
||||
tests/v2r1/test_hermes_fabric.py \
|
||||
tests/v2r1/test_message_ledger_append.py \
|
||||
tests/v2r1/test_router_isolation.py \
|
||||
tests/v2r1/test_runtime_identity_binding.py \
|
||||
tests/v2r1/test_runtime_wiring.py \
|
||||
tests/v2r1/test_tenant_context.py -v
|
||||
# Reported result: 29 passed, exit code 0
|
||||
```
|
||||
|
||||
### Integration suite — 3 of 3 passed
|
||||
|
||||
```shell
|
||||
PYTHONPATH=active-runtime/app python3 -m pytest tests/integration/test_runtime_ingress_flow.py -v
|
||||
# Reported result: 3 passed, exit code 0
|
||||
```
|
||||
|
||||
**Reported total: 32 of 32 passed.**
|
||||
|
||||
## Cleanup, Security, and Recovery Evidence
|
||||
|
||||
| Check | Supplied result |
|
||||
|---|---|
|
||||
| Conflict-marker scan | PASS |
|
||||
| Cleanup scan | PASS |
|
||||
| Secret scan | PASS |
|
||||
| Legitimate hardcoded test keys | Two 39-byte fixtures in `test_runtime_wiring.py` |
|
||||
| Rollback rehearsal | PASS; 13 of 13 core tests reported after rollback to `6077331` |
|
||||
| Recovery verification | PASS |
|
||||
|
||||
## Deployment State
|
||||
|
||||
| Operation | Performed |
|
||||
|---|---|
|
||||
| Merge | No |
|
||||
| Tag creation | No |
|
||||
| Baseline promotion | No |
|
||||
| Staging deployment | No |
|
||||
| Production deployment | No |
|
||||
|
||||
## Post-Commit Verification
|
||||
|
||||
Run from the repository root after the commit is created:
|
||||
|
||||
```shell
|
||||
set -eu
|
||||
|
||||
SOURCE=502ef30642597fb0b70b90ae2e8a0b6af217d80e
|
||||
RESULT_FILE=.agent/results/openclaw/RESULT-V2R1-G1-OPENCLAW-FINAL-EVIDENCE-20260714.md
|
||||
|
||||
git rev-parse HEAD
|
||||
|
||||
test "$(git diff --name-only "$SOURCE" HEAD | wc -l | tr -d ' ')" = "1"
|
||||
test "$(git diff --name-only "$SOURCE" HEAD)" = "$RESULT_FILE"
|
||||
|
||||
git ls-tree -r HEAD -- "$RESULT_FILE"
|
||||
|
||||
python3 - "$RESULT_FILE" <<'PY'
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
text = path.read_text(encoding="utf-8")
|
||||
|
||||
encoded_tokens = [
|
||||
[84, 66, 68],
|
||||
[80, 69, 78, 68, 73, 78, 71],
|
||||
[84, 79, 68, 79],
|
||||
[70, 73, 88, 77, 69],
|
||||
[91, 123, 99, 111, 109, 109, 105, 116, 125, 93],
|
||||
]
|
||||
tokens = ["".join(chr(value) for value in item) for item in encoded_tokens]
|
||||
angle_token = re.compile(chr(60) + r"[^>\n]+" + chr(62))
|
||||
|
||||
hits = [token for token in tokens if token in text]
|
||||
if angle_token.search(text):
|
||||
hits.append("angle-delimited-token")
|
||||
|
||||
if hits:
|
||||
print("TOKEN_SCAN=FAIL", hits)
|
||||
raise SystemExit(1)
|
||||
|
||||
print("TOKEN_SCAN=PASS")
|
||||
PY
|
||||
```
|
||||
|
||||
## Acceptance Conditions
|
||||
|
||||
Gate 1 is eligible to proceed only when the external acceptance record confirms all of the following against the resolved `HEAD` commit:
|
||||
|
||||
- the commit exists and has source commit `502ef30642597fb0b70b90ae2e8a0b6af217d80e` as its parent;
|
||||
- exactly this one report file changed;
|
||||
- the committed report contains no unresolved legacy token;
|
||||
- the result tree is not identical to rejected commit `71239b4d187e683e05fd22fc9d5df8ae324e3390`;
|
||||
- no merge, tag, baseline promotion, or deployment occurred.
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"b3": "BLOCKED_EVIDENCE_REQUIRED",
|
||||
"b4": "NOT_STARTED",
|
||||
"baseline": "V1R4C6",
|
||||
"contract_branch": "task/governance/v2r1-personal-weixin-production-contract-20260717",
|
||||
"contract_commit": "cbe33ecab8e9794ba5f318bea43aeaf447a56fbc",
|
||||
"dispatch_commit": null,
|
||||
"executor_quorum": {
|
||||
"candidates": ["openclaw", "codex"],
|
||||
"minimum_healthy": 1,
|
||||
"openclaw": "UNKNOWN",
|
||||
"codex": "UNKNOWN"
|
||||
},
|
||||
"active_executor": null,
|
||||
"standby_executor": null,
|
||||
"central_gpt": "UNKNOWN",
|
||||
"automation_closed": {
|
||||
"central_gpt_health": "UNKNOWN",
|
||||
"executor_quorum": "UNKNOWN",
|
||||
"active_executor": null,
|
||||
"event_trigger": "UNKNOWN",
|
||||
"polling": "UNKNOWN",
|
||||
"failover": "UNKNOWN",
|
||||
"lifecycle_sync": "UNKNOWN",
|
||||
"two_cycle_unattended_loop": "UNKNOWN",
|
||||
"restart_recovery": "UNKNOWN",
|
||||
"result": "NOT_STARTED"
|
||||
},
|
||||
"lifecycle_events": [],
|
||||
"production_release": "NOT_STARTED",
|
||||
"release": "paused",
|
||||
"repair3": "PASS_CANDIDATE_PREPARED",
|
||||
"runner": "BLOCKED_GATEWAY_SECRET_REF_UNAVAILABLE",
|
||||
"schema_version": 2,
|
||||
"single_state_writer": "gateway",
|
||||
"single_state_writer_lease_required": true,
|
||||
"quorum_controller": {
|
||||
"implementation": "CODE_COMMITTED_TESTED_PENDING_LIVE_ACCEPTANCE",
|
||||
"live_acceptance": "NOT_STARTED",
|
||||
"health_source": "ROOT_OWNED_METADATA_ONLY_LIVE_PROBE_RECEIPTS",
|
||||
"unknown_counts_as_healthy": false
|
||||
},
|
||||
"updated_at": "2026-07-18T04:58:00+00:00"
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
# Codex task: apply and verify AIOS Orchestrator Gateway v1
|
||||
|
||||
Contract: `cbe33ecab8e9794ba5f318bea43aeaf447a56fbc`
|
||||
|
||||
Use the strongest actually available Codex model for the initial security review.
|
||||
If unavailable or timed out, record TIMEOUT/NOT_EXECUTED and let OpenClaw run
|
||||
the deterministic test path. Do not count a timeout as PASS.
|
||||
|
||||
## Apply
|
||||
|
||||
1. Create a new clean append-only branch and isolated worktree.
|
||||
2. Validate the delivery tarball and `SHA256SUMS`.
|
||||
3. Run the external `scripts/apply_gateway_patch.sh` with `SOURCE_DIR` and
|
||||
`WORKTREE`; do not alter unrelated paths.
|
||||
4. Review all added code, especially:
|
||||
- exact contract and optimistic state-version checks;
|
||||
- event/decision idempotency;
|
||||
- SQLite leases and retry behavior;
|
||||
- Workspace Agent `202` asynchronous callback flow;
|
||||
- Responses background creation and polling;
|
||||
- file-inbox and Git-ref polling;
|
||||
- traversal/symlink protections;
|
||||
- OpenClaw lifecycle synchronization;
|
||||
- systemd sandbox;
|
||||
- install and rollback transactions.
|
||||
|
||||
## Tests
|
||||
|
||||
Run from the final source tree:
|
||||
|
||||
- Python compile for all gateway and helper files;
|
||||
- unit tests;
|
||||
- JSON schema/config parsing;
|
||||
- Bash syntax;
|
||||
- `systemd-analyze verify`;
|
||||
- event duplicate and payload-conflict tests;
|
||||
- stale state-version rejection;
|
||||
- lease/double-claim test;
|
||||
- Workspace trigger failure and Responses fallback;
|
||||
- response queued/in-progress/completed polling;
|
||||
- file-inbox recovery after HTTP failure;
|
||||
- Git-ref initial baseline and subsequent-change event;
|
||||
- task start/end and project summary synchronization;
|
||||
- dispatch traversal and symlink rejection;
|
||||
- retry/backoff/dead-letter;
|
||||
- install dry-run;
|
||||
- rollback test in an isolated fixture;
|
||||
- `git diff --check`;
|
||||
- full repository tests;
|
||||
- final Gitleaks.
|
||||
|
||||
## Commit and review
|
||||
|
||||
1. Create an append-only implementation commit.
|
||||
2. Push without force.
|
||||
3. Confirm `WORKTREE_OID == BARE_OID == GITEA_OID`.
|
||||
4. Create an independent RESULT-only branch and exact-one result commit.
|
||||
5. Record the source bundle SHA-256, implementation OID, RESULT OID, tests,
|
||||
Gitleaks, systemd validation and remaining external configuration blockers.
|
||||
|
||||
## Forbidden
|
||||
|
||||
No reset, amend, rebase, force push, direct main push, controller mutation,
|
||||
secret disclosure, fabricated test result, production deployment or B3 evidence
|
||||
fabrication.
|
||||
|
||||
Success:
|
||||
|
||||
`GATEWAY_CODE_REVIEW=PASS IMPLEMENTATION=<oid> RESULT=<oid> OID_MATCH=YES`
|
||||
@@ -1,13 +0,0 @@
|
||||
# Codex V2R1 adaptive execution
|
||||
CONTRACT_ACK=cbe33ecab8e9794ba5f318bea43aeaf447a56fbc
|
||||
Read `.agent/policies/V2R1-PERSONAL-WEIXIN-PRODUCTION-EXECUTION-CONTRACT.yaml`.
|
||||
Execute P1-POST-CONTRACT-REACK now:
|
||||
- read-only verify Repair3 e110cda9968cadc5c7b7179ec5003b05aad11ce4
|
||||
- read-only verify RESULT 49a9c2e0cc898ecf2bbd45f454e007644d7f3045
|
||||
- verify lineage from 8d9c6da90fc8caa69b691d243c6fefb40b6b0575, exact4, tests, Gitleaks and RESULT-only scope
|
||||
- do not modify Repair3 code in this unit
|
||||
Act as fallback for OpenClaw only on timeout/unavailability. Use GPT-5.6 only if actually exposed; otherwise strongest visible model. Prefer OpenClaw when quality is comparable or volume is high; prefer Codex for complex/security-critical work.
|
||||
Forbidden: reset, amend, rebase, force push, direct main push, controller mutation, secret disclosure, fabricated evidence, skipped gates.
|
||||
Report:
|
||||
CONTRACT=cbe33ecab8e9794ba5f318bea43aeaf447a56fbc WORK_UNIT=<id> TRACK=<id> GATE=<id> AGENT=codex STATUS=<status> IMPLEMENTATION=<oid-or-NONE> RESULT=<oid-or-NONE> OID_MATCH=<YES-or-NO> BLOCKER=<reason-or-NONE>
|
||||
Keep BASELINE=V1R4C6 RELEASE=paused PRODUCTION_RELEASE=NOT_STARTED until production gate passes.
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
# OpenClaw A0 Three-Party Self-Healing and Release Supervision
|
||||
|
||||
CONTRACT_ACK=cbe33ecab8e9794ba5f318bea43aeaf447a56fbc
|
||||
|
||||
## Mission
|
||||
|
||||
Close the GPT/OpenClaw/Codex unattended loop before any main merge or production release. OpenClaw is the default state writer and long-running supervisor, but work may be reassigned dynamically under the shared contract.
|
||||
|
||||
## Authoritative inputs
|
||||
|
||||
- Governance contract: `cbe33ecab8e9794ba5f318bea43aeaf447a56fbc`
|
||||
- Governance dispatch head before this task: `9195470ceb78fd57ffface3dcc38f40b75efa9d8`
|
||||
- Gateway integration implementation: `c3984ca2b4388875d2875b4fdfdf95a2984f6ab3`
|
||||
- Gateway integration RESULT: `4e63b1559aa3fc07b1384154c97b2c90b84353ed`
|
||||
- Three-party self-healer candidate branch: `patch/a0-three-party-self-healing-20260718-20260718T043256`
|
||||
- Production baseline: `V1R4C6`
|
||||
- Release status: `paused`
|
||||
|
||||
## Priority invariant
|
||||
|
||||
Continuously evaluate exactly three parties:
|
||||
|
||||
1. `central_gpt` — Orchestrator Gateway plus a valid Workspace Agent or Responses API decision channel.
|
||||
2. `openclaw` — OpenClaw watcher, lifecycle sync, durable task execution and heartbeat.
|
||||
3. `codex` — Codex watcher, auto-claim, lifecycle sync, durable task execution and heartbeat.
|
||||
|
||||
When exactly two parties are HEALTHY and the third is explicitly UNHEALTHY, create a P0 repair work unit for the unique unhealthy party before every other task. UNKNOWN is not UNHEALTHY and must fail closed. Do not repair when fewer than two are healthy or any health result is ambiguous.
|
||||
|
||||
## A0-SH1: supervise implementation completion
|
||||
|
||||
Codex owns the high-risk code changes. Independently verify that the final implementation contains:
|
||||
|
||||
- strict root-owned configuration validation and no symlink chain;
|
||||
- fixed built-in party adapters rather than arbitrary untrusted repair commands;
|
||||
- bounded stdout/stderr redaction so tokens and secrets cannot enter audit logs;
|
||||
- exclusive lease, cooldown, circuit breaker and stale lease recovery;
|
||||
- event trigger plus periodic polling;
|
||||
- OpenClaw lifecycle events: `TASK_DISPATCHED`, `TASK_STARTED`, `TASK_ENDED`, `DECISION_APPLIED`, `PROJECT_SUMMARY`, `PROJECT_COMPLETED`;
|
||||
- durable file outbox fallback when HTTP callback is unavailable;
|
||||
- systemd service and timer;
|
||||
- health and repair adapters for central GPT, OpenClaw and Codex;
|
||||
- install, verify and rollback scripts;
|
||||
- deterministic tests for all health combinations and failures.
|
||||
|
||||
Reject the current candidate if it only contains `automation/three_party_self_healer.py` without these controls.
|
||||
|
||||
## A0-SH2: live three-party validation
|
||||
|
||||
After Codex implementation RESULT passes, deploy only to a non-production acceptance context and run:
|
||||
|
||||
1. All-three healthy steady state for at least three poll intervals.
|
||||
2. GPT unhealthy while OpenClaw and Codex healthy: GPT repair becomes P0 and recovers.
|
||||
3. OpenClaw unhealthy while GPT and Codex healthy: OpenClaw repair becomes P0 and recovers.
|
||||
4. Codex unhealthy while GPT and OpenClaw healthy: Codex repair becomes P0 and recovers.
|
||||
5. One healthy plus two unhealthy: no repair; fail closed.
|
||||
6. Any UNKNOWN health: no repair; fail closed.
|
||||
7. Repair timeout: retry with backoff, then circuit open.
|
||||
8. Service restart: state, lease and pending work recover.
|
||||
9. HTTP event path unavailable: file inbox polling recovers.
|
||||
10. Git webhook unavailable: Git ref polling detects change.
|
||||
11. Duplicate event ID: no duplicate dispatch.
|
||||
12. Main Workspace Agent channel unavailable: Responses fallback takes over only with a valid approved credential.
|
||||
13. OpenClaw receives every task start/end and project summary notification.
|
||||
14. Two consecutive unattended decision/execution/result cycles complete with no chat message or manual dispatch.
|
||||
|
||||
Do not use dummy API keys or environment-variable presence as proof of a valid central decision channel.
|
||||
|
||||
## A0-SH3: authoritative closure
|
||||
|
||||
Only after independent verification of the above create one append-only state update and an exact-one RESULT file. Required output:
|
||||
|
||||
```text
|
||||
AUTOMATION_CLOSED=PASS
|
||||
CENTRAL_GPT_HEALTH=PASS
|
||||
OPENCLAW_AUTO_CLAIM=PASS
|
||||
CODEX_AUTO_CLAIM=PASS
|
||||
THREE_PARTY_SELF_HEALING=PASS
|
||||
EVENT_TRIGGER=PASS
|
||||
POLLING=PASS
|
||||
OPENCLAW_LIFECYCLE_SYNC=PASS
|
||||
TWO_CYCLE_UNATTENDED_LOOP=PASS
|
||||
RESTART_RECOVERY=PASS
|
||||
ROLLBACK=PASS
|
||||
```
|
||||
|
||||
Any missing item keeps `AUTOMATION_CLOSED=NO`.
|
||||
|
||||
## Release continuation
|
||||
|
||||
After and only after `AUTOMATION_CLOSED=PASS`, automatically resume the governed release sequence:
|
||||
|
||||
1. Real B3 evidence intake and independent review.
|
||||
2. B4 implementation and validation.
|
||||
3. Full integration, regression, security and migration tests.
|
||||
4. Normal PR/GitOps merge to main; never direct-push main.
|
||||
5. Immutable release artifact, digest, SBOM and signature.
|
||||
6. Staging and rollback exercise.
|
||||
7. Canary and monitoring validation.
|
||||
8. Owner/admin GitOps production deployment using existing approved release controls.
|
||||
9. Production smoke tests and production RESULT-only commit.
|
||||
|
||||
External facts that cannot be fabricated — real WeChat credentials/raw-wire, valid secret references and owner-only release approval — remain fail-closed blockers. The loop must monitor them and resume automatically when they become available.
|
||||
|
||||
## Forbidden
|
||||
|
||||
- reset, amend, rebase, force push;
|
||||
- direct push to main;
|
||||
- controller mutation;
|
||||
- secret disclosure;
|
||||
- dummy credentials presented as production credentials;
|
||||
- fabricated B3 evidence;
|
||||
- timeout or skipped checks recorded as PASS;
|
||||
- production release before `AUTOMATION_CLOSED=PASS`.
|
||||
|
||||
Progress format:
|
||||
|
||||
```text
|
||||
CONTRACT=cbe33ecab8e9794ba5f318bea43aeaf447a56fbc WORK_UNIT=<id> AGENT=openclaw STATUS=<status> COMMIT=<oid-or-NONE> BLOCKER=<stable-reason-or-NONE>
|
||||
```
|
||||
@@ -1,68 +0,0 @@
|
||||
# OpenClaw task: deploy and supervise AIOS Orchestrator Gateway v1
|
||||
|
||||
Contract: `cbe33ecab8e9794ba5f318bea43aeaf447a56fbc`
|
||||
|
||||
You are the default orchestrator and deployment supervisor.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Codex has applied and independently reviewed this source bundle on a clean,
|
||||
append-only branch.
|
||||
- Implementation and RESULT OIDs are identical across worktree, bare repo and
|
||||
Gitea.
|
||||
- Final Gitleaks and repository tests are PASS.
|
||||
- No force push, reset, amend or rebase occurred.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
1. Validate the source bundle SHA-256 and `SHA256SUMS`.
|
||||
2. Review the generated systemd service, poll timer, tmpfiles and sysusers files.
|
||||
3. Install through `scripts/install_aios_orchestrator_gateway.sh`.
|
||||
4. Configure one or both central-model channels:
|
||||
- published Workspace Agent API channel with `agtch_...` trigger ID and a
|
||||
Workspace Agent access token stored outside Git; or
|
||||
- Responses API fallback with an approved model and API key stored outside Git.
|
||||
5. Publish the callback endpoint securely and configure the Workspace Agent
|
||||
action from `workspace-agent/orchestrator-decision-action.openapi.yaml`.
|
||||
6. Confirm that the Workspace Agent instructions match
|
||||
`workspace-agent/CENTRAL_AGENT_INSTRUCTIONS.md`.
|
||||
7. Enable both:
|
||||
- the continuously running gateway service;
|
||||
- the independent `aios-orchestrator-gateway-poll.timer`.
|
||||
8. Verify HTTP event ingestion, file-inbox polling, Git-ref polling,
|
||||
Workspace Agent trigger acceptance, callback decision, Responses background
|
||||
polling fallback, dispatch queue generation, retry leases and loop guards.
|
||||
9. Verify OpenClaw receives and synchronizes:
|
||||
- TASK_DISPATCHED
|
||||
- TASK_STARTED
|
||||
- TASK_ENDED
|
||||
- PROJECT_SUMMARY
|
||||
- PROJECT_COMPLETED
|
||||
10. Run a harmless bootstrap work unit through OpenClaw and Codex and confirm
|
||||
duplicate event submission is idempotent.
|
||||
11. Run rollback, reinstall and heartbeat/health verification.
|
||||
12. Create one RESULT-only review recording exact commits, artifact digest,
|
||||
tests, systemd security, polling recovery, lifecycle synchronization and
|
||||
rollback.
|
||||
|
||||
## Fail-closed boundaries
|
||||
|
||||
- Do not put Workspace Agent tokens, API keys, callback tokens or gateway
|
||||
credentials in Git, logs, command arguments or RESULT files.
|
||||
- Do not claim the gateway resolves the existing B3 credential/raw-wire blocker.
|
||||
- Do not advance B4 or production merely because this gateway is healthy.
|
||||
- Do not bypass owner/admin production release approvals.
|
||||
- Do not use reset, amend, rebase, force push, direct main push or bare-ref edits.
|
||||
|
||||
## Required final status
|
||||
|
||||
Success:
|
||||
|
||||
`ORCHESTRATOR_GATEWAY_LIVE=PASS POLLING=PASS OPENCLAW_SYNC=PASS ROLLBACK=PASS`
|
||||
|
||||
Blocker:
|
||||
|
||||
`ORCHESTRATOR_GATEWAY_LIVE=BLOCKED reason=<stable_reason>`
|
||||
|
||||
Keep `BASELINE=V1R4C6 RELEASE=paused PRODUCTION_RELEASE=NOT_STARTED` until the
|
||||
existing production contract advances all release gates.
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
# OpenClaw V2R1 adaptive orchestration
|
||||
CONTRACT_ACK=cbe33ecab8e9794ba5f318bea43aeaf447a56fbc
|
||||
Read `.agent/policies/V2R1-PERSONAL-WEIXIN-PRODUCTION-EXECUTION-CONTRACT.yaml`.
|
||||
You are default orchestrator and sole authoritative state writer.
|
||||
Execute immediately:
|
||||
1. R0-DISCOVER-SECRET-PROVIDER: discover approved provider metadata and owner-probe source without reading or outputting secrets. Environment variables alone are not approved secret refs.
|
||||
2. P2-INVENTORY-REAL-EVIDENCE: inventory approved restricted B3 evidence locations; validate existing facts; never synthesize evidence.
|
||||
3. Independently verify Codex results before advancing.
|
||||
Prefer OpenClaw for comparable-quality, large, repetitive, long-running, infrastructure and cost-sensitive work. Prefer Codex strongest actually available model for complex code, security boundaries, architecture and high-risk review. On timeout mark TIMEOUT and switch agent; never infer PASS.
|
||||
Forbidden: reset, amend, rebase, force push, direct main push, controller mutation, secret disclosure, fabricated evidence, skipped gates.
|
||||
Report:
|
||||
CONTRACT=cbe33ecab8e9794ba5f318bea43aeaf447a56fbc WORK_UNIT=<id> TRACK=<id> GATE=<id> AGENT=openclaw STATUS=<status> COMMIT=<oid-or-NONE> BLOCKER=<reason-or-NONE>
|
||||
Keep BASELINE=V1R4C6 RELEASE=paused PRODUCTION_RELEASE=NOT_STARTED until production gate passes.
|
||||
@@ -10,4 +10,3 @@ node_modules/
|
||||
runtime/tmp/
|
||||
runtime/cache/
|
||||
logs/
|
||||
config/test_write_perms
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# AIOS Codex automatic command watcher
|
||||
|
||||
The watcher polls the governed `commands/active` queue without a chat trigger. It uses SQLite transactions for exclusive leases, records the exact command digest, rejects contract drift, retries transient failures, dead-letters exhausted work, and resumes from the last durable phase so an end-event retry does not execute a work unit twice.
|
||||
|
||||
Commands are JSON documents validated by `schemas/codex-command.schema.json`. `acceptance_probe` is the only built-in action and has no external side effect. Production `codex` work requires an administrator-provisioned absolute executor path; the instruction is passed on standard input, never argv or environment. The executor receives a sanitized environment and must obtain credentials through its approved file or exec provider. If the executor is absent, the task fails closed and follows the retry/dead-letter policy.
|
||||
|
||||
The watcher provides at-most-once execution. It writes the `EXECUTING` phase before launching an external executor; if the host dies after an external effect but before a result is durably recorded, recovery does not replay the work and instead dead-letters the ambiguity for human review. Start and end event JSON is persisted before transmission, so response-loss retries reuse the exact event ID and payload and are deduplicated by the Gateway.
|
||||
|
||||
Lifecycle evidence is appended to `/var/log/aios-codex-watcher/audit.jsonl` as `CODEX_AUTO_CLAIM`, `TASK_STARTED`, `TASK_EXECUTED`, `TASK_ENDED`, and `EVENT_POSTED`. Inputs, tokens, stdout, and stderr are not logged; only result digests are retained.
|
||||
|
||||
Install from a reviewed commit with `sudo scripts/install_aios_codex_watcher.sh`. The installer creates a content-addressed release, atomically updates `current`, writes a receipt, and enables the five-second systemd timer. The existing Gateway inbound token file is referenced in place and never copied.
|
||||
@@ -1,107 +0,0 @@
|
||||
# AIOS Orchestrator Gateway v0.2
|
||||
|
||||
This component turns the current passive GPT orchestration pattern into a
|
||||
durable, unattended loop:
|
||||
|
||||
```text
|
||||
Gitea/OpenClaw/Codex event
|
||||
-> HTTP event endpoint or polled file inbox
|
||||
-> durable SQLite state machine
|
||||
-> Workspace Agent API trigger
|
||||
-> strict callback decision
|
||||
-> Responses API background fallback and polling
|
||||
-> OpenClaw/Codex dispatch queues
|
||||
-> start/end/result events
|
||||
-> next orchestration cycle
|
||||
```
|
||||
|
||||
## Reliability mechanisms
|
||||
|
||||
The gateway does not depend on one timer, webhook or model call:
|
||||
|
||||
- HTTP event ingestion.
|
||||
- Periodic file-inbox polling for missed webhooks and offline producers.
|
||||
- Local bare-Git ref polling for `main`, governance, patch and result refs.
|
||||
- Workspace Agent retries with a stable event `Idempotency-Key`.
|
||||
- Workspace Agent callback timeout and Responses API background fallback.
|
||||
- Responses status polling until completion or fail-closed terminal status.
|
||||
- SQLite optimistic state versions, idempotency records and processing leases.
|
||||
- Exponential retry with a maximum retry count and dead-letter state.
|
||||
- Same-blocker and maximum-hop guards to stop non-progress loops.
|
||||
- A systemd timer runs `--poll-once` as an independent reliability watchdog.
|
||||
|
||||
Workspace Agent trigger runs are asynchronous and do not expose a retrievable
|
||||
final response. The central Workspace Agent must therefore call the
|
||||
`submit_orchestration_decision` action.
|
||||
|
||||
## OpenClaw synchronization
|
||||
|
||||
Every lifecycle stage is durably written to the OpenClaw synchronization queue:
|
||||
|
||||
- `TASK_DISPATCHED`
|
||||
- `TASK_STARTED`
|
||||
- `TASK_ENDED`
|
||||
- `DECISION_APPLIED`
|
||||
- `PROJECT_SUMMARY`
|
||||
- `PROJECT_COMPLETED`
|
||||
|
||||
An optional authenticated OpenClaw HTTP endpoint can receive the same records.
|
||||
A failed HTTP delivery remains in the lifecycle retry queue. OpenClaw remains
|
||||
the default project-status synchronizer and authoritative progress presenter.
|
||||
|
||||
## Agent execution contract
|
||||
|
||||
Each dispatched JSON envelope contains:
|
||||
|
||||
- exact contract commit;
|
||||
- source event;
|
||||
- clean base commit;
|
||||
- primary and fallback agent;
|
||||
- allowed paths;
|
||||
- required checks;
|
||||
- forbidden operations;
|
||||
- completion event callback format.
|
||||
|
||||
OpenClaw and Codex must send a `work_unit_started` event before execution and a
|
||||
`work_unit_completed`, `work_unit_blocked`, or `work_unit_failed` event when
|
||||
they stop. This guarantees that OpenClaw can synchronize every task start,
|
||||
finish, and overall project summary.
|
||||
|
||||
## Security
|
||||
|
||||
- Model and callback credentials are read only from root-owned secret files.
|
||||
- Credentials never enter Git, dispatch payloads or logs.
|
||||
- The gateway runs as a dedicated unprivileged user.
|
||||
- Dispatch and sync paths are protected against traversal and symlink targets.
|
||||
- All state changes require the exact contract commit and current state version.
|
||||
- Production evidence, credentials and approvals stay fail-closed.
|
||||
- Git history operations remain controlled by the committed governance contract.
|
||||
|
||||
## Deployment sequence
|
||||
|
||||
1. Apply this source bundle to a clean append-only branch.
|
||||
2. Run unit, repository, shell, JSON, systemd and Gitleaks checks.
|
||||
3. Configure one or both model channels:
|
||||
- published Workspace Agent API channel and Workspace Agent access token;
|
||||
- Responses API key and approved fallback model.
|
||||
4. Publish the decision callback as a Workspace Agent action or MCP tool.
|
||||
5. Install the service through `scripts/install_aios_orchestrator_gateway.sh`.
|
||||
6. Run `scripts/verify_aios_orchestrator_gateway.sh`.
|
||||
7. Submit a harmless event and verify:
|
||||
- trigger acceptance;
|
||||
- strict decision callback;
|
||||
- dispatch file;
|
||||
- OpenClaw task-start/task-end/project-summary synchronization;
|
||||
- duplicate idempotency;
|
||||
- polling recovery after suppressing the HTTP webhook.
|
||||
|
||||
The installer copies an explicit gateway-only allowlist into a release directory
|
||||
named by its SHA-256 digest, atomically updates `/opt/aios-orchestrator-gateway/current`,
|
||||
and persists `/var/lib/aios-orchestrator-gateway/install-receipt.json` with the
|
||||
current and previous release. `--dry-run` performs source validation and emits
|
||||
`AIOS_ORCHESTRATOR_INSTALL_DRY_RUN=PASS` without changing installed state.
|
||||
|
||||
Rollback reads that root-owned `0600` receipt, verifies both release directory
|
||||
names and `SOURCE_DIGEST` files, coordinates the gateway and poll units, and
|
||||
atomically switches `current` to the previous release. Repeating the same
|
||||
rollback is a no-op and emits `NOOP_ALREADY_ROLLED_BACK`.
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Adaptive policy engine for self-loop optimization.
|
||||
|
||||
Optimizes retry, cooldown, and low-risk automation choices from failure history.
|
||||
It never disables owner gates or security controls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
from .failure_memory import FailureMemory
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdaptiveDecision:
|
||||
retry_limit: int
|
||||
cooldown_seconds: int
|
||||
automation_level: str
|
||||
risk_level: str
|
||||
owner_approval_required: bool
|
||||
reasons: tuple[str, ...]
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class AdaptivePolicy:
|
||||
def __init__(self, default_retry_limit: int = 3, default_cooldown_seconds: int = 30):
|
||||
self.default_retry_limit = default_retry_limit
|
||||
self.default_cooldown_seconds = default_cooldown_seconds
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
memory: FailureMemory,
|
||||
runtime: dict[str, Any],
|
||||
baseline: dict[str, Any],
|
||||
goal: str,
|
||||
) -> AdaptiveDecision:
|
||||
reasons: list[str] = []
|
||||
retry_limit = self.default_retry_limit
|
||||
cooldown = self.default_cooldown_seconds
|
||||
risk = "low"
|
||||
automation = "safe"
|
||||
|
||||
top = memory.top_failures(1)
|
||||
if top:
|
||||
score = float(top[0].get("recurrence_score", 0))
|
||||
if score >= 0.7:
|
||||
retry_limit = 1
|
||||
cooldown = 300
|
||||
risk = "high"
|
||||
automation = "analysis_only"
|
||||
reasons.append("recurrent failure detected; reduce automatic retries")
|
||||
elif score >= 0.4:
|
||||
cooldown = 120
|
||||
reasons.append("failure recurrence increasing; slow down loop")
|
||||
|
||||
if runtime.get("status") != "active":
|
||||
risk = "high"
|
||||
automation = "analysis_only"
|
||||
reasons.append("runtime not healthy")
|
||||
|
||||
if baseline.get("codex_gitops", {}).get("direct_main_push"):
|
||||
risk = "high"
|
||||
automation = "analysis_only"
|
||||
reasons.append("direct main push policy drift")
|
||||
|
||||
owner_gate = True
|
||||
reasons.append("owner gate preserved")
|
||||
|
||||
if not reasons:
|
||||
reasons.append(f"optimize policy for goal: {goal}")
|
||||
|
||||
return AdaptiveDecision(
|
||||
retry_limit=retry_limit,
|
||||
cooldown_seconds=cooldown,
|
||||
automation_level=automation,
|
||||
risk_level=risk,
|
||||
owner_approval_required=owner_gate,
|
||||
reasons=tuple(reasons),
|
||||
)
|
||||
|
||||
def apply_to_scheduler(self, decision: AdaptiveDecision) -> dict[str, Any]:
|
||||
return {
|
||||
"max_attempts": decision.retry_limit,
|
||||
"cooldown_seconds": decision.cooldown_seconds,
|
||||
"automation_level": decision.automation_level,
|
||||
"owner_approval_required": decision.owner_approval_required,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["AdaptiveDecision", "AdaptivePolicy"]
|
||||
@@ -1,56 +0,0 @@
|
||||
"""API adapter for agent context routing and visibility."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
|
||||
from .agent_context_dashboard import AgentContextDashboard
|
||||
from .agent_context_rbac import AgentContextRBAC
|
||||
from .agent_context_resolver import AgentContextResolver
|
||||
|
||||
resolver = AgentContextResolver()
|
||||
rbac = AgentContextRBAC()
|
||||
dashboard = AgentContextDashboard()
|
||||
|
||||
|
||||
def resolve_agent_context(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
route = rbac.route(payload["user_id"], payload.get("group_id"), payload.get("role", "user"))
|
||||
context = resolver.resolve(
|
||||
user_id=payload["user_id"],
|
||||
group_id=payload.get("group_id", ""),
|
||||
session=payload.get("session"),
|
||||
user_profile=payload.get("user_profile"),
|
||||
group_policy=payload.get("group_policy"),
|
||||
agent_default=payload.get("agent_default"),
|
||||
owner_policy=payload.get("owner_policy"),
|
||||
owner=payload.get("role") == "owner",
|
||||
)
|
||||
return {"routing": route.to_dict(), "context": context.to_dict()}
|
||||
|
||||
|
||||
def get_context_dashboard() -> dict[str, Any]:
|
||||
return dashboard.overview()
|
||||
|
||||
|
||||
def build_router():
|
||||
if APIRouter is None:
|
||||
return None
|
||||
router = APIRouter(prefix="/admin/ai-evolution/context", tags=["context-isolation"])
|
||||
|
||||
@router.post("/resolve", operation_id="resolveAgentContext")
|
||||
def resolve(payload: dict[str, Any]):
|
||||
return resolve_agent_context(payload)
|
||||
|
||||
@router.get("/overview", operation_id="getAgentContextDashboard")
|
||||
def overview():
|
||||
return get_context_dashboard()
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = ["router", "resolve_agent_context", "get_context_dashboard"]
|
||||
@@ -1,23 +0,0 @@
|
||||
"""Audit context resolution decisions."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class AgentContextAudit:
|
||||
def record(self, context: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"event": "context_loaded",
|
||||
"agent_id": context.get("agent_id"),
|
||||
"user_id": context.get("user_id"),
|
||||
"group_id": context.get("group_id"),
|
||||
"layers": [x.get("name") for x in context.get("layers", [])],
|
||||
"timestamp": utc_now(),
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["AgentContextAudit"]
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Web UI model for context isolation visibility."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AgentContextDashboard:
|
||||
def overview(self, contexts: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
contexts = contexts or []
|
||||
return {
|
||||
"module": "agent-context-isolation",
|
||||
"route": "/admin/ai-evolution/context",
|
||||
"title": "Agent Context Isolation",
|
||||
"panels": [
|
||||
{
|
||||
"name": "agent-routing",
|
||||
"title": "Agent 路由",
|
||||
"data": contexts,
|
||||
},
|
||||
{
|
||||
"name": "context-layers",
|
||||
"title": "Context 来源层级",
|
||||
"layers": [
|
||||
"safety_policy",
|
||||
"owner_policy",
|
||||
"group_policy",
|
||||
"role_policy",
|
||||
"user_profile",
|
||||
"session_context",
|
||||
"agent_default",
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "audit",
|
||||
"title": "Context 审计",
|
||||
"events": [
|
||||
"context_loaded",
|
||||
"profile_updated",
|
||||
"agent_selected",
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["AgentContextDashboard"]
|
||||
@@ -1,55 +0,0 @@
|
||||
"""RBAC binding for group main agents and owner agents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentRoutingDecision:
|
||||
user_id: str
|
||||
group_id: str
|
||||
role: str
|
||||
agent_id: str
|
||||
scope: str
|
||||
allowed: bool
|
||||
reason: str
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class AgentContextRBAC:
|
||||
"""Routes users to group agents and owners to dedicated owner agents."""
|
||||
|
||||
VALID_ROLES = {"user", "group_admin", "owner"}
|
||||
|
||||
def route(self, user_id: str, group_id: str | None, role: str) -> AgentRoutingDecision:
|
||||
if role not in self.VALID_ROLES:
|
||||
return AgentRoutingDecision(user_id, group_id or "", role, "", "none", False, "unknown_role")
|
||||
if role == "owner":
|
||||
return AgentRoutingDecision(
|
||||
user_id=user_id,
|
||||
group_id=group_id or "global",
|
||||
role=role,
|
||||
agent_id=f"owner-agent:{user_id}",
|
||||
scope="global",
|
||||
allowed=True,
|
||||
reason="owner_dedicated_agent",
|
||||
)
|
||||
if not group_id:
|
||||
return AgentRoutingDecision(user_id, "", role, "", "none", False, "group_required")
|
||||
scope = "group" if role == "group_admin" else "self"
|
||||
return AgentRoutingDecision(
|
||||
user_id=user_id,
|
||||
group_id=group_id,
|
||||
role=role,
|
||||
agent_id=f"group-main-agent:{group_id}",
|
||||
scope=scope,
|
||||
allowed=True,
|
||||
reason="group_agent_routing",
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["AgentRoutingDecision", "AgentContextRBAC"]
|
||||
@@ -1,82 +0,0 @@
|
||||
"""Multi-user agent context resolver.
|
||||
|
||||
Architecture:
|
||||
- shared agent kernel
|
||||
- group main agent
|
||||
- owner dedicated agent
|
||||
- user profile overlay
|
||||
- session overlay
|
||||
|
||||
Personal preferences never enter shared USER.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContextLayer:
|
||||
name: str
|
||||
priority: int
|
||||
data: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectiveContext:
|
||||
agent_id: str
|
||||
user_id: str
|
||||
group_id: str
|
||||
layers: list[ContextLayer]
|
||||
resolved: dict[str, Any]
|
||||
resolved_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"agent_id": self.agent_id,
|
||||
"user_id": self.user_id,
|
||||
"group_id": self.group_id,
|
||||
"layers": [asdict(x) for x in self.layers],
|
||||
"resolved": self.resolved,
|
||||
"resolved_at": self.resolved_at,
|
||||
}
|
||||
|
||||
|
||||
class AgentContextResolver:
|
||||
"""Resolves context without shared user-state pollution."""
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
user_id: str,
|
||||
group_id: str,
|
||||
session: dict[str, Any] | None = None,
|
||||
user_profile: dict[str, Any] | None = None,
|
||||
group_policy: dict[str, Any] | None = None,
|
||||
agent_default: dict[str, Any] | None = None,
|
||||
owner_policy: dict[str, Any] | None = None,
|
||||
owner: bool = False,
|
||||
) -> EffectiveContext:
|
||||
layers = [
|
||||
ContextLayer("agent_default", 10, agent_default or {}),
|
||||
ContextLayer("group_policy", 30, group_policy or {}),
|
||||
ContextLayer("user_profile", 50, user_profile or {}),
|
||||
ContextLayer("session", 70, session or {}),
|
||||
]
|
||||
agent_id = f"group-main-agent:{group_id}"
|
||||
if owner:
|
||||
agent_id = f"owner-agent:{user_id}"
|
||||
layers.append(ContextLayer("owner_policy", 90, owner_policy or {}))
|
||||
resolved: dict[str, Any] = {}
|
||||
# Higher priority overlays lower priority except safety policies handled upstream.
|
||||
for layer in sorted(layers, key=lambda x: x.priority):
|
||||
resolved.update(layer.data)
|
||||
return EffectiveContext(agent_id, user_id, group_id, layers, resolved)
|
||||
|
||||
|
||||
__all__ = ["ContextLayer", "EffectiveContext", "AgentContextResolver"]
|
||||
@@ -1,77 +0,0 @@
|
||||
"""Actions API for unified Agent Event Bus."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
|
||||
from .agent_event_bus import AgentEventBus
|
||||
|
||||
bus = AgentEventBus()
|
||||
|
||||
|
||||
def create_agent_event(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"status": "ok", "event": bus.publish(**payload)}
|
||||
|
||||
|
||||
def list_agent_events(kind: str | None = None, status: str = "pending") -> dict[str, Any]:
|
||||
return {"status": "ok", "events": bus.list_events(kind, status)}
|
||||
|
||||
|
||||
def get_agent_event(event_id: str) -> dict[str, Any]:
|
||||
return {"status": "ok", "event": bus.get(event_id)}
|
||||
|
||||
|
||||
def transition_agent_event(event_id: str, status: str, actor: str = "system") -> dict[str, Any]:
|
||||
return {"status": "ok", "event": bus.transition(event_id, status, actor)}
|
||||
|
||||
|
||||
def agent_event_stats() -> dict[str, Any]:
|
||||
return {"status": "ok", "stats": bus.stats()}
|
||||
|
||||
|
||||
# Compatibility adapters
|
||||
|
||||
def list_gpt_inbox_messages():
|
||||
return list_agent_events(kind="gpt_inbox")
|
||||
|
||||
|
||||
def ack_gpt_inbox_message(event_id: str):
|
||||
return transition_agent_event(event_id, "completed", "custom-gpt")
|
||||
|
||||
|
||||
def build_router():
|
||||
if APIRouter is None:
|
||||
return None
|
||||
router = APIRouter(prefix="/agent-events", tags=["agent-events"])
|
||||
|
||||
@router.post("", operation_id="createAgentEvent")
|
||||
def create(payload: dict[str, Any]):
|
||||
return create_agent_event(payload)
|
||||
|
||||
@router.get("", operation_id="listAgentEvents")
|
||||
def listing(kind: str | None = None, status: str = "pending"):
|
||||
return list_agent_events(kind, status)
|
||||
|
||||
@router.get("/{event_id}", operation_id="getAgentEvent")
|
||||
def get(event_id: str):
|
||||
return get_agent_event(event_id)
|
||||
|
||||
@router.post("/{event_id}/transition", operation_id="transitionAgentEvent")
|
||||
def transition(event_id: str, payload: dict[str, Any]):
|
||||
return transition_agent_event(event_id, payload["status"], payload.get("actor", "system"))
|
||||
|
||||
@router.get("/stats/overview", operation_id="getAgentEventStats")
|
||||
def stats():
|
||||
return agent_event_stats()
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = ["router", "create_agent_event", "list_agent_events", "get_agent_event", "transition_agent_event"]
|
||||
@@ -1,125 +0,0 @@
|
||||
"""Unified Agent Event Bus.
|
||||
|
||||
Unifies commands, GPT messages, replies, approvals, audit and reports into one
|
||||
state machine. Compatibility layers can filter events by kind instead of keeping
|
||||
separate queues.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentEvent:
|
||||
id: str
|
||||
kind: str
|
||||
source: str
|
||||
target: str
|
||||
summary: str
|
||||
payload: dict[str, Any]
|
||||
status: str = "pending"
|
||||
correlation_id: str | None = None
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
updated_at: str = field(default_factory=utc_now)
|
||||
actor: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class AgentEventBus:
|
||||
STATES = ("pending", "running", "completed", "failed")
|
||||
|
||||
def __init__(self, root: str = "agents/events"):
|
||||
self.root = Path(root)
|
||||
for state in self.STATES:
|
||||
(self.root / state).mkdir(parents=True, exist_ok=True)
|
||||
(self.root / "audit").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def publish(
|
||||
self,
|
||||
kind: str,
|
||||
source: str,
|
||||
target: str,
|
||||
summary: str,
|
||||
payload: dict[str, Any],
|
||||
correlation_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
event_id = "evt_" + sha256(
|
||||
json.dumps(
|
||||
{"kind": kind, "source": source, "summary": summary, "payload": payload},
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
).hexdigest()[:16]
|
||||
event = AgentEvent(event_id, kind, source, target, summary, payload, correlation_id=correlation_id)
|
||||
self._write("pending", event.to_dict())
|
||||
self._audit("created", event.to_dict())
|
||||
return event.to_dict()
|
||||
|
||||
def list_events(self, kind: str | None = None, status: str = "pending", limit: int = 100) -> list[dict[str, Any]]:
|
||||
result = []
|
||||
for path in sorted((self.root / status).glob("*.json")):
|
||||
event = self._read(path)
|
||||
if kind and event.get("kind") != kind:
|
||||
continue
|
||||
result.append(event)
|
||||
return result[:limit]
|
||||
|
||||
def get(self, event_id: str) -> dict[str, Any] | None:
|
||||
for state in self.STATES:
|
||||
path = self.root / state / f"{event_id}.json"
|
||||
if path.exists():
|
||||
return self._read(path)
|
||||
return None
|
||||
|
||||
def transition(self, event_id: str, status: str, actor: str | None = None) -> dict[str, Any]:
|
||||
event = self.get(event_id)
|
||||
if not event:
|
||||
return {"status": "not_found", "id": event_id}
|
||||
if status not in self.STATES:
|
||||
return {"status": "invalid_state"}
|
||||
old = event.get("status", "pending")
|
||||
old_path = self.root / old / f"{event_id}.json"
|
||||
event["status"] = status
|
||||
event["actor"] = actor
|
||||
event["updated_at"] = utc_now()
|
||||
self._write(status, event)
|
||||
if old_path.exists() and old != status:
|
||||
old_path.unlink()
|
||||
self._audit("transition", event, {"from": old, "to": status})
|
||||
return event
|
||||
|
||||
def stats(self) -> dict[str, Any]:
|
||||
return {
|
||||
"events": {state: len(list((self.root / state).glob("*.json"))) for state in self.STATES},
|
||||
"audit_events": len(list((self.root / "audit").glob("*.json"))),
|
||||
"status": "healthy",
|
||||
}
|
||||
|
||||
def _write(self, state: str, data: dict[str, Any]):
|
||||
(self.root / state / f"{data['id']}.json").write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
def _read(self, path: Path):
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
def _audit(self, action: str, event: dict[str, Any], extra: dict[str, Any] | None = None):
|
||||
data = {"action": action, "event": event, "extra": extra or {}, "time": utc_now()}
|
||||
(self.root / "audit" / f"{event['id']}-{action}.json").write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["AgentEvent", "AgentEventBus"]
|
||||
@@ -1,50 +0,0 @@
|
||||
"""Admin dashboard data model for the unified Agent Event Bus."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
|
||||
from .agent_event_bus import AgentEventBus
|
||||
|
||||
|
||||
class AgentEventDashboard:
|
||||
def __init__(self, bus: AgentEventBus | None = None):
|
||||
self.bus = bus or AgentEventBus()
|
||||
|
||||
def overview(self) -> dict[str, Any]:
|
||||
stats = self.bus.stats()
|
||||
pending = self.bus.list_events(status="pending")
|
||||
running = self.bus.list_events(status="running")
|
||||
completed = self.bus.list_events(status="completed")
|
||||
failed = self.bus.list_events(status="failed")
|
||||
all_visible = pending + running + completed + failed
|
||||
by_kind = Counter(event.get("kind", "unknown") for event in all_visible)
|
||||
by_target = Counter(event.get("target", "unknown") for event in all_visible)
|
||||
return {
|
||||
"module": "agent-event-bus",
|
||||
"title": "Unified Agent Event Bus",
|
||||
"route": "/admin/ai-evolution/events",
|
||||
"metrics": {
|
||||
**stats,
|
||||
"by_kind": dict(by_kind),
|
||||
"by_target": dict(by_target),
|
||||
"gpt_inbox_pending": by_kind.get("gpt_inbox", 0),
|
||||
"openclaw_pending": len([e for e in pending if e.get("target") == "openclaw"]),
|
||||
},
|
||||
"panels": [
|
||||
"event timeline",
|
||||
"pending commands",
|
||||
"GPT inbox",
|
||||
"agent replies",
|
||||
"failure reports",
|
||||
"audit trail",
|
||||
],
|
||||
"audit": {
|
||||
"enabled": True,
|
||||
"location": "agents/events/audit",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["AgentEventDashboard"]
|
||||
@@ -1,37 +0,0 @@
|
||||
"""Bridge Agent Event Bus events into Agent Observability metrics."""
|
||||
|
||||
from .agent_observability_hooks import AgentObservabilityHooks
|
||||
|
||||
|
||||
class AgentEventObservabilityBridge:
|
||||
def __init__(self, hooks: AgentObservabilityHooks | None = None):
|
||||
self.hooks = hooks or AgentObservabilityHooks()
|
||||
|
||||
def consume(self, event: dict):
|
||||
kind = event.get("kind")
|
||||
payload = event.get("payload", {})
|
||||
if kind == "agent_started":
|
||||
return self.hooks.start(
|
||||
user_id=payload.get("user_id", "unknown"),
|
||||
visible_scope=payload.get("scope", "self"),
|
||||
entry_agent=payload.get("agent", "unknown"),
|
||||
execution_id=event.get("id"),
|
||||
)
|
||||
if kind == "agent_delegated":
|
||||
return self.hooks.delegate(event["execution_id"], payload.get("agent", "unknown"))
|
||||
if kind == "token_consumed":
|
||||
return self.hooks.tokens(
|
||||
event["execution_id"],
|
||||
payload.get("input_tokens", 0),
|
||||
payload.get("output_tokens", 0),
|
||||
)
|
||||
if kind in {"agent_completed", "agent_failed"}:
|
||||
return self.hooks.finish(
|
||||
event["execution_id"],
|
||||
payload.get("closed_by"),
|
||||
"failed" if kind == "agent_failed" else "completed",
|
||||
)
|
||||
return {"status": "ignored", "kind": kind}
|
||||
|
||||
|
||||
__all__ = ["AgentEventObservabilityBridge"]
|
||||
@@ -1,80 +0,0 @@
|
||||
"""Agent orchestration observability model.
|
||||
|
||||
Tracks OpenClaw, Hermes and sub-agent execution chains. Token usage is optional
|
||||
and depends on model adapter instrumentation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentExecutionRecord:
|
||||
execution_id: str
|
||||
user_id: str
|
||||
visible_scope: str
|
||||
entry_agent: str
|
||||
path: list[str]
|
||||
used_hermes: bool
|
||||
closed_by: str
|
||||
duration_ms: int = 0
|
||||
input_tokens: int | None = None
|
||||
output_tokens: int | None = None
|
||||
total_tokens: int | None = None
|
||||
status: str = "completed"
|
||||
started_at: str = field(default_factory=utc_now)
|
||||
ended_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self):
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class AgentObservability:
|
||||
def __init__(self):
|
||||
self.records: list[AgentExecutionRecord] = []
|
||||
|
||||
def record(self, record: AgentExecutionRecord):
|
||||
self.records.append(record)
|
||||
return record.to_dict()
|
||||
|
||||
def summary(self, user_id: str | None = None) -> dict[str, Any]:
|
||||
records = [r for r in self.records if not user_id or r.user_id == user_id]
|
||||
hermes = [r for r in records if r.used_hermes]
|
||||
direct = [r for r in records if not r.used_hermes]
|
||||
return {
|
||||
"total_executions": len(records),
|
||||
"openclaw_independent_loops": len(direct),
|
||||
"hermes_orchestrated_loops": len(hermes),
|
||||
"average_duration_ms": (
|
||||
sum(r.duration_ms for r in records) / len(records) if records else 0
|
||||
),
|
||||
"token_usage": {
|
||||
"input": sum(r.input_tokens or 0 for r in records),
|
||||
"output": sum(r.output_tokens or 0 for r in records),
|
||||
"total": sum(r.total_tokens or 0 for r in records),
|
||||
},
|
||||
}
|
||||
|
||||
def hermes_tree(self, user_id: str | None = None):
|
||||
return [
|
||||
{
|
||||
"execution_id": r.execution_id,
|
||||
"entry": r.entry_agent,
|
||||
"used_hermes": r.used_hermes,
|
||||
"children": r.path,
|
||||
"duration_ms": r.duration_ms,
|
||||
"tokens": r.total_tokens,
|
||||
}
|
||||
for r in self.records
|
||||
if (not user_id or r.user_id == user_id)
|
||||
]
|
||||
|
||||
|
||||
__all__ = ["AgentExecutionRecord", "AgentObservability"]
|
||||
@@ -1,52 +0,0 @@
|
||||
"""API for agent orchestration observability."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
|
||||
from .agent_observability import AgentExecutionRecord
|
||||
from .agent_observability_dashboard import AgentObservabilityDashboard
|
||||
from .agent_observability_store import AgentObservabilityStore
|
||||
|
||||
store = AgentObservabilityStore()
|
||||
dashboard = AgentObservabilityDashboard(store.load())
|
||||
|
||||
|
||||
def record_agent_execution(payload: dict[str, Any]):
|
||||
record = AgentExecutionRecord(**payload)
|
||||
return {"status": "ok", "record": store.append(record)}
|
||||
|
||||
|
||||
def get_agent_observability(user_id: str | None = None, group_id: str | None = None, role: str = "user"):
|
||||
records = store.query(user_id, group_id, role)
|
||||
return {
|
||||
"status": "ok",
|
||||
"records": records,
|
||||
"summary": store.load().summary(user_id if role != "owner" else None),
|
||||
}
|
||||
|
||||
|
||||
def build_router():
|
||||
if APIRouter is None:
|
||||
return None
|
||||
router = APIRouter(prefix="/admin/ai-evolution/agents", tags=["agent-observability"])
|
||||
|
||||
@router.post("/execution", operation_id="recordAgentExecution")
|
||||
def record(payload: dict[str, Any]):
|
||||
return record_agent_execution(payload)
|
||||
|
||||
@router.get("/overview", operation_id="getAgentObservabilityOverview")
|
||||
def overview(user_id: str | None = None, group_id: str | None = None, role: str = "user"):
|
||||
return get_agent_observability(user_id, group_id, role)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = ["router", "record_agent_execution", "get_agent_observability"]
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Web UI dashboard model for agent execution visibility."""
|
||||
|
||||
from .agent_observability import AgentObservability
|
||||
|
||||
|
||||
class AgentObservabilityDashboard:
|
||||
def __init__(self, observability: AgentObservability | None = None):
|
||||
self.observability = observability or AgentObservability()
|
||||
|
||||
def overview(self, user_id: str | None = None):
|
||||
return {
|
||||
"route": "/admin/ai-evolution/agents",
|
||||
"title": "Agent Orchestration Observability",
|
||||
"summary": self.observability.summary(user_id),
|
||||
"views": [
|
||||
{
|
||||
"name": "scheduler_flow",
|
||||
"title": "OpenClaw/Hermes 调度流程",
|
||||
"data": self.observability.hermes_tree(user_id),
|
||||
},
|
||||
{
|
||||
"name": "agent_metrics",
|
||||
"title": "智能体指标",
|
||||
"fields": [
|
||||
"duration_ms",
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"total_tokens",
|
||||
"status",
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "loop_distribution",
|
||||
"title": "闭环分布",
|
||||
"fields": [
|
||||
"openclaw_independent_loops",
|
||||
"hermes_orchestrated_loops",
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["AgentObservabilityDashboard"]
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Runtime hooks for Agent Observability.
|
||||
|
||||
OpenClaw/Hermes/sub-agents can call these hooks at start/end/delegation points.
|
||||
The hooks store sanitized metadata only: no prompts, no secrets, no private message
|
||||
content.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from .agent_observability import AgentExecutionRecord
|
||||
from .agent_observability_store import AgentObservabilityStore
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def parse_time(value: str) -> datetime:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentRunContext:
|
||||
execution_id: str
|
||||
user_id: str
|
||||
visible_scope: str
|
||||
entry_agent: str
|
||||
path: list[str] = field(default_factory=list)
|
||||
used_hermes: bool = False
|
||||
started_at: str = field(default_factory=utc_now)
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
status: str = "running"
|
||||
|
||||
def delegate(self, agent_name: str) -> None:
|
||||
if agent_name not in self.path:
|
||||
self.path.append(agent_name)
|
||||
if agent_name.lower() == "hermes":
|
||||
self.used_hermes = True
|
||||
|
||||
def add_tokens(self, input_tokens: int = 0, output_tokens: int = 0) -> None:
|
||||
self.input_tokens += max(input_tokens, 0)
|
||||
self.output_tokens += max(output_tokens, 0)
|
||||
|
||||
|
||||
class AgentObservabilityHooks:
|
||||
def __init__(self, store: AgentObservabilityStore | None = None):
|
||||
self.store = store or AgentObservabilityStore()
|
||||
self.active: dict[str, AgentRunContext] = {}
|
||||
|
||||
def start(
|
||||
self,
|
||||
user_id: str,
|
||||
visible_scope: str,
|
||||
entry_agent: str,
|
||||
execution_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
run = AgentRunContext(
|
||||
execution_id=execution_id or f"exec_{uuid4().hex[:12]}",
|
||||
user_id=user_id,
|
||||
visible_scope=visible_scope,
|
||||
entry_agent=entry_agent,
|
||||
path=[entry_agent],
|
||||
used_hermes=entry_agent.lower() == "hermes",
|
||||
)
|
||||
self.active[run.execution_id] = run
|
||||
return {"status": "started", "execution_id": run.execution_id, "run": run.__dict__}
|
||||
|
||||
def delegate(self, execution_id: str, agent_name: str) -> dict[str, Any]:
|
||||
run = self.active[execution_id]
|
||||
run.delegate(agent_name)
|
||||
return {"status": "delegated", "execution_id": execution_id, "path": run.path}
|
||||
|
||||
def tokens(self, execution_id: str, input_tokens: int = 0, output_tokens: int = 0) -> dict[str, Any]:
|
||||
run = self.active[execution_id]
|
||||
run.add_tokens(input_tokens, output_tokens)
|
||||
return {
|
||||
"status": "tokens_recorded",
|
||||
"execution_id": execution_id,
|
||||
"input_tokens": run.input_tokens,
|
||||
"output_tokens": run.output_tokens,
|
||||
"total_tokens": run.input_tokens + run.output_tokens,
|
||||
}
|
||||
|
||||
def finish(self, execution_id: str, closed_by: str | None = None, status: str = "completed") -> dict[str, Any]:
|
||||
run = self.active.pop(execution_id)
|
||||
ended_at = utc_now()
|
||||
duration_ms = int((parse_time(ended_at) - parse_time(run.started_at)).total_seconds() * 1000)
|
||||
record = AgentExecutionRecord(
|
||||
execution_id=run.execution_id,
|
||||
user_id=run.user_id,
|
||||
visible_scope=run.visible_scope,
|
||||
entry_agent=run.entry_agent,
|
||||
path=run.path,
|
||||
used_hermes=run.used_hermes,
|
||||
closed_by=closed_by or run.path[-1],
|
||||
duration_ms=duration_ms,
|
||||
input_tokens=run.input_tokens,
|
||||
output_tokens=run.output_tokens,
|
||||
total_tokens=run.input_tokens + run.output_tokens,
|
||||
status=status,
|
||||
started_at=run.started_at,
|
||||
ended_at=ended_at,
|
||||
)
|
||||
return {"status": "stored", "record": self.store.append(record)}
|
||||
|
||||
|
||||
__all__ = ["AgentRunContext", "AgentObservabilityHooks"]
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Persistent store for agent observability records.
|
||||
|
||||
Stores sanitized execution metrics for OpenClaw/Hermes/sub-agents. No prompts,
|
||||
secrets, or private message contents are persisted here; only metrics and routing
|
||||
metadata are stored for Web UI statistics and audit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .agent_observability import AgentExecutionRecord, AgentObservability
|
||||
|
||||
|
||||
class AgentObservabilityStore:
|
||||
def __init__(self, root: str = "runtime/agent-observability"):
|
||||
self.root = Path(root)
|
||||
self.records_dir = self.root / "records"
|
||||
self.records_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def append(self, record: AgentExecutionRecord) -> dict[str, Any]:
|
||||
path = self.records_dir / f"{record.execution_id}.json"
|
||||
data = record.to_dict()
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return data
|
||||
|
||||
def load(self) -> AgentObservability:
|
||||
observability = AgentObservability()
|
||||
for path in sorted(self.records_dir.glob("*.json")):
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
observability.records.append(AgentExecutionRecord(**raw))
|
||||
return observability
|
||||
|
||||
def query(self, user_id: str | None = None, group_id: str | None = None, role: str = "user") -> list[dict[str, Any]]:
|
||||
records = [record.to_dict() for record in self.load().records]
|
||||
if role == "owner":
|
||||
return records
|
||||
if role == "group_admin" and group_id:
|
||||
return [r for r in records if r.get("visible_scope") == group_id or r.get("user_id") == user_id]
|
||||
return [r for r in records if r.get("user_id") == user_id]
|
||||
|
||||
|
||||
__all__ = ["AgentObservabilityStore"]
|
||||
Executable → Regular
+2
-100
@@ -15,7 +15,7 @@ TOKEN_FILE = Path(os.getenv('AIOS_GITOPS_TOKEN_FILE', str(SECRETS / 'gitops.toke
|
||||
HOST = os.getenv('AIOS_GITOPS_HOST', '127.0.0.1')
|
||||
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
|
||||
OPENAPI = WS / 'docs' / 'gitops' / 'openapi-chatgpt-actions.yaml'
|
||||
BARE_REPO = Path(os.getenv('AIOS_GITOPS_BARE_REPO', str(ROOT / 'runtime' / 'gitea-bin' / 'data' / 'repositories' / 'aios' / 'aios.git')))
|
||||
BARE_REPO = Path(os.getenv('AIOS_GITOPS_BARE_REPO', str(ROOT / 'runtime' / 'gitea-bin' / 'data' / 'repositories' / 'aios' / 'aios-v23.git')))
|
||||
monitor = ActionMonitor()
|
||||
|
||||
BASE_MODELS = [
|
||||
@@ -200,11 +200,6 @@ class H(BaseHTTPRequestHandler):
|
||||
latest=monitor.latest()
|
||||
ldict={'event_id':latest.event_id,'action':latest.action,'error':latest.error,'context':latest.context,'created_at':latest.created_at} if latest else None
|
||||
return self.sendj({'events':[{"event_id":e.event_id,"action":e.action,"error":e.error,"context":e.context,"created_at":e.created_at} for e in monitor.list_events()],'latest':ldict})
|
||||
# Admin AI Evolution Dashboard
|
||||
if path in ('/admin/ai-evolution','/admin/ai-evolution/'):
|
||||
return self.ai_evolution_page()
|
||||
if path in ('/admin/ai-evolution/api/data'):
|
||||
return self.ai_evolution_data()
|
||||
return self.sendj({'error':'not_found','path':path},404)
|
||||
except Exception as e:
|
||||
monitor.record_failure('do_GET', str(e), {'path':path})
|
||||
@@ -295,102 +290,9 @@ class H(BaseHTTPRequestHandler):
|
||||
elif 'model' in low or '模型' in text: intent='model_change'; plan=['use POST /api/model or PUT /api/model-priority','commit metadata']
|
||||
elif 'patch' in low or '补丁' in text: intent='patch_create'; plan=['call POST /api/patch']
|
||||
b=checkout(data.get('branch') or f'patch/nl-{uid("nl")}', 'main', True); doc={'id':uid('nl'),'text':text,'intent':intent,'action_plan':plan,'execute':False,'owner_approval_required':True,'created_by':a['account'],'created_at':now()}; r=write_json(f'patches/nl/{safe(doc["id"])}.json', doc); c=commit([r], f'chore(chatgpt): record nl plan {intent}'); self.sendj({'status':'planned','branch':b,'request':doc,'commit':c})
|
||||
_EVOLUTION_HTML = '''<!DOCTYPE html>
|
||||
<html lang=zh-CN>
|
||||
<head>
|
||||
<meta charset=UTF-8>
|
||||
<meta name=viewport content="width=device-width,initial-scale=1.0">
|
||||
<title>AIOS Evolution Dashboard</title>
|
||||
<script src=https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js></script>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:#0d1117;color:#c9d1d9;font-size:14px;line-height:1.7;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif}
|
||||
.c{max-width:1000px;margin:0 auto;padding:32px 24px}
|
||||
h1{font-size:24px;color:#58a6ff;margin-bottom:2px}
|
||||
.sub{color:#8b949e;font-size:13px;border-bottom:1px solid #21262d;padding-bottom:16px;margin-bottom:20px;display:flex;justify-content:space-between}
|
||||
.g{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:16px;margin-bottom:20px}
|
||||
.s{background:#161b22;border:1px solid #21262d;border-radius:8px;padding:18px;text-align:center}
|
||||
.s .v{font-size:28px;font-weight:700;color:#f0f6fc}
|
||||
.s .l{font-size:11px;color:#8b949e;margin-top:4px}
|
||||
.bx{background:#161b22;border:1px solid #21262d;border-radius:8px;margin-bottom:14px}
|
||||
.bh{border-bottom:1px solid #21262d;padding:10px 16px;font-weight:600;font-size:14px;display:flex;gap:6px;align-items:center}
|
||||
.bd{padding:12px 16px}
|
||||
.rw{display:grid;grid-template-columns:1fr 1fr 100px 60px;gap:8px;padding:8px 16px;border-bottom:1px solid #21262d;font-size:13px;align-items:center;cursor:pointer;transition:background .15s}
|
||||
.rw:hover{background:#1c2128}.rw:last-child{border-bottom:none}
|
||||
.tg{display:inline-block;padding:2px 10px;border-radius:99px;font-size:11px;font-weight:600}
|
||||
.ok{background:#3fb95033;color:#3fb950}.fl{background:#da363333;color:#f85149}.if{background:#1f6feb33;color:#58a6ff}
|
||||
pre{background:#0d1117;padding:12px;border-radius:6px;overflow:auto;font-size:12px;color:#8b949e;max-height:300px;margin-top:8px}select{padding:8px 10px;border:1px solid #30363d;border-radius:6px;background:#21262d;color:#c9d1d9;font-size:13px;max-width:400px;width:100%;margin-bottom:4px;cursor:pointer}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id=app class=c>
|
||||
<h1>AIOS Evolution Dashboard</h1>
|
||||
<div class=sub><span>Baseline: {{baseline}}</span><span style=color:#8b949e>{{refreshMsg}}</span></div>
|
||||
<div class=g><div class=s><div class=v>{{tot}}</div><div class=l>Commands</div></div><div class=s><div class=v style=color:#3fb950>{{sok}}</div><div class=l>Succeeded</div></div><div class=s><div class=v style=color:#f85149>{{sfail}}</div><div class=l>Failed</div></div><div class=s><div class=v>{{runs}}</div><div class=l>Executions</div></div></div>
|
||||
<div class=bx><div class=bh>Version Timeline</div><div class=bd><div class=rw style=font-weight:600;border-bottom:2px solid #30363d;cursor:default><span>Version</span><span>Time</span><span>Result</span></div><div v-for=v in vs class=rw @click=selVer(v)><span style=color:#58a6ff>{{v.tag}}</span><span>{{v.time||'-'}}</span><span :class="'tg '+v.result">{{v.result||'-'}}</span></div></div></div>
|
||||
<div class=bx><div class=bh>Commands</div><div class=bd><select v-model=cmd @change=loadCmd><option value>-- Select --</option><option v-for=c in cmds :value=c>{{c}}</option></select><pre v-if=cdet>{{cdet}}</pre></div></div>
|
||||
<div class=bx><div class=bh>Recent Agent Runs</div><div class=bd><div class=rw style=font-weight:600;border-bottom:2px solid #30363d;cursor:default><span>Agent</span><span>Command</span><span>Count</span><span>Result</span></div><div v-for=r in aruns class=rw><span>{{r.agent||'-'}}</span><span style=overflow:hidden;white-space:nowrap;text-overflow:ellipsis>{{r.command}}</span><span>{{r.interaction_count||0}}</span><span :class="'tg '+r.final_result">{{r.final_result||'-'}}</span></div></div></div></div>
|
||||
<script>
|
||||
const{createApp,ref,onMounted}=Vue;createApp({setup(){const vs=ref([]),cmds=ref([]),ar=ref([]),cmd=ref(''),cdet=ref(null),tot=ref(0),sok=ref(0),sfail=ref(0),runs=ref(0),baseline=ref('V1R3C1'),refreshMsg=ref('')
|
||||
async function ld(){try{const r=await fetch('/admin/ai-evolution/api/data'),d=await r.json();baseline.value=d.baseline||'V1R3C1';vs.value=d.versions||[];cmds.value=d.commands||[];ar.value=d.agent_runs||[];tot.value=d.stats?.total||0;sok.value=d.stats?.success||0;sfail.value=d.stats?.failed||0;runs.value=d.stats?.agent_runs||0;refreshMsg.value='Last updated: '+new Date().toLocaleString()}catch(e){}}
|
||||
async function loadCmd(){if(!cmd.value){cdet.value=null;return}try{const r=await fetch('/admin/ai-evolution/api/data?command='+encodeURIComponent(cmd.value)),d=await r.json();cdet.value=JSON.stringify(d,null,2)}catch(e){cdet.value='Error'}}
|
||||
function selVer(v){cmd.value=v.tag||v.id||v.version;loadCmd()}
|
||||
onMounted(()=>{ld();setInterval(ld,30000)})
|
||||
return{vs,cmds,ar,cmd,cdet,tot,sok,sfail,runs,baseline,refreshMsg,loadCmd,selVer}})mount('#app')
|
||||
</script></body></html>'''
|
||||
|
||||
def ai_evolution_page(self):
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'text/html; charset=utf-8')
|
||||
self.send_header('Content-Length', str(len(self._EVOLUTION_HTML)))
|
||||
self.end_headers()
|
||||
self.wfile.write(self._EVOLUTION_HTML.encode())
|
||||
|
||||
def ai_evolution_data(self):
|
||||
q=urllib.parse.urlparse(self.path).query
|
||||
params=urllib.parse.parse_qs(q)
|
||||
cmd_filter=(params.get('command') or [None])[0]
|
||||
bl=jload(WS/'baseline'/'current.json',{})
|
||||
current_ver=bl.get('current_baseline','V1R3C1')
|
||||
tags=sh(['tag','--list','V*'])['stdout'].splitlines() if WS.exists() else []
|
||||
versions=[]
|
||||
if (WS/'baseline'/'promotions').exists():
|
||||
for p in sorted((WS/'baseline'/'promotions').glob('*.json')):
|
||||
d=jload(p,{});v=d.get('version',p.stem)
|
||||
versions.append({'tag':v,'time':d.get('promoted_at','-'),'result':'ok'})
|
||||
for t in tags:
|
||||
if not any(v['tag']==t for v in versions):
|
||||
versions.append({'tag':t,'time':'-','result':'if'})
|
||||
agent_runs=[];ard=WS/'audit'/'agent-runs'
|
||||
if ard.exists():
|
||||
for f in sorted(ard.glob('*.json'),reverse=True)[:50]:
|
||||
d=jload(f,{});agent_runs.append(d) if isinstance(d,dict) else None
|
||||
cmd_dirs=[WS/'agents/commands/active',WS/'agents/commands/running',WS/'agents/commands/completed',WS/'agents/commands/failed']
|
||||
commands=[]
|
||||
for d in cmd_dirs:
|
||||
if d.exists():
|
||||
for f in sorted(d.glob('*.md')):
|
||||
commands.append(f.name)
|
||||
command_detail=None
|
||||
if cmd_filter:
|
||||
for d in cmd_dirs:
|
||||
if d.exists():
|
||||
fp=d/cmd_filter
|
||||
if fp.exists():
|
||||
command_detail={'command':cmd_filter,'content':fp.read_text(encoding='utf-8')}
|
||||
break
|
||||
if not command_detail:
|
||||
command_detail={'command':cmd_filter,'error':'not_found'}
|
||||
return self.sendj(command_detail)
|
||||
completed=list((WS/'agents/commands/completed').glob('*.md')) if (WS/'agents/commands/completed').exists() else []
|
||||
failed=list((WS/'agents/commands/failed').glob('*.md')) if (WS/'agents/commands/failed').exists() else []
|
||||
stats={'total':len(commands),'success':len(completed),'failed':len(failed),'agent_runs':len(agent_runs)}
|
||||
data={'baseline':current_ver,'versions':versions[-30:],'agent_runs':agent_runs,'commands':sorted(set(commands)),'stats':stats}
|
||||
self.sendj(data)
|
||||
|
||||
|
||||
def api_spec():
|
||||
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True}}
|
||||
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True}}
|
||||
|
||||
def main():
|
||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
"""Architect governance agent responsibilities.
|
||||
|
||||
This agent records requirement changes, maintains versioned human-readable
|
||||
requirement/architecture/deployment/test documents, reviews architecture changes,
|
||||
validates deployment docs against actual configuration, and produces version bump
|
||||
recommendations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequirementChange:
|
||||
id: str
|
||||
source: str
|
||||
actor: str
|
||||
requirement: str
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DocumentRefreshPlan:
|
||||
requirement_doc: bool
|
||||
architecture_doc: bool
|
||||
deployment_doc: bool
|
||||
test_case_doc: bool
|
||||
reason: str
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArchitectureReviewDecision:
|
||||
status: str
|
||||
findings: list[str]
|
||||
optimization_recommendations: list[str]
|
||||
version_bump: str
|
||||
docs_to_refresh: DocumentRefreshPlan
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"status": self.status,
|
||||
"findings": self.findings,
|
||||
"optimization_recommendations": self.optimization_recommendations,
|
||||
"version_bump": self.version_bump,
|
||||
"docs_to_refresh": self.docs_to_refresh.to_dict(),
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
|
||||
class ArchitectGovernanceAgent:
|
||||
"""Independent architecture governance role."""
|
||||
|
||||
def record_requirement_change(self, source: str, actor: str, requirement: str) -> RequirementChange:
|
||||
change_id = "REQ-" + datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
|
||||
return RequirementChange(change_id, source, actor, requirement)
|
||||
|
||||
def review_architecture_change(self, proposal: dict[str, Any]) -> ArchitectureReviewDecision:
|
||||
impact = proposal.get("impact", "medium")
|
||||
findings = [
|
||||
"requirement_traceability_checked",
|
||||
"architecture_consistency_checked",
|
||||
"deployment_doc_alignment_checked",
|
||||
"test_case_doc_required",
|
||||
"rollback_and_owner_gate_checked",
|
||||
]
|
||||
recommendations = [
|
||||
"refresh user requirements document",
|
||||
"refresh architecture design document",
|
||||
"refresh engineering deployment document",
|
||||
"generate acceptance test case document",
|
||||
]
|
||||
version_bump = self.decide_version_bump(proposal)
|
||||
return ArchitectureReviewDecision(
|
||||
status="review_required" if impact in {"high", "architecture"} else "pass",
|
||||
findings=findings,
|
||||
optimization_recommendations=recommendations,
|
||||
version_bump=version_bump,
|
||||
docs_to_refresh=DocumentRefreshPlan(True, True, True, True, "architecture or requirement changed"),
|
||||
)
|
||||
|
||||
def decide_version_bump(self, proposal: dict[str, Any]) -> str:
|
||||
if proposal.get("breaks_architecture") or proposal.get("major_capability"):
|
||||
return "V"
|
||||
if proposal.get("new_release_capability") or proposal.get("architecture_change"):
|
||||
return "R"
|
||||
return "C"
|
||||
|
||||
def validate_deployment_doc(self, deployment_doc: dict[str, Any], actual_config: dict[str, Any]) -> dict[str, Any]:
|
||||
expected = set(deployment_doc.get("services", []))
|
||||
actual = set(actual_config.get("services", []))
|
||||
missing = sorted(expected - actual)
|
||||
extra = sorted(actual - expected)
|
||||
return {
|
||||
"status": "pass" if not missing else "attention_required",
|
||||
"missing_services": missing,
|
||||
"extra_services": extra,
|
||||
"checked_at": utc_now(),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RequirementChange",
|
||||
"DocumentRefreshPlan",
|
||||
"ArchitectureReviewDecision",
|
||||
"ArchitectGovernanceAgent",
|
||||
]
|
||||
@@ -1,32 +0,0 @@
|
||||
"""API for Architect Governance Center."""
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
|
||||
from .architect_governance_dashboard import ArchitectGovernanceDashboard
|
||||
|
||||
|
||||
dashboard = ArchitectGovernanceDashboard()
|
||||
|
||||
|
||||
def get_architect_governance_overview():
|
||||
return dashboard.overview()
|
||||
|
||||
|
||||
def build_router():
|
||||
if APIRouter is None:
|
||||
return None
|
||||
router = APIRouter(prefix="/admin/ai-evolution/architect", tags=["architect-governance"])
|
||||
|
||||
@router.get("/overview", operation_id="getArchitectGovernanceOverview")
|
||||
def overview():
|
||||
return get_architect_governance_overview()
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = ["router", "get_architect_governance_overview"]
|
||||
@@ -1,153 +0,0 @@
|
||||
"""Web UI dashboard model for Architect Governance Agent.
|
||||
|
||||
Shows requirement changes, architecture review findings, document refresh status,
|
||||
deployment consistency, test case readiness, weekly research and V/R/C version
|
||||
recommendations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArchitectPanelMetric:
|
||||
key: str
|
||||
title: str
|
||||
value: Any
|
||||
status: str = "ok"
|
||||
detail: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArchitectPanel:
|
||||
key: str
|
||||
title: str
|
||||
description: str
|
||||
metrics: list[ArchitectPanelMetric] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"key": self.key,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"metrics": [m.to_dict() for m in self.metrics],
|
||||
}
|
||||
|
||||
|
||||
class ArchitectGovernanceDashboard:
|
||||
def overview(
|
||||
self,
|
||||
requirement_changes: list[dict[str, Any]] | None = None,
|
||||
architecture_reviews: list[dict[str, Any]] | None = None,
|
||||
document_status: dict[str, Any] | None = None,
|
||||
research_status: dict[str, Any] | None = None,
|
||||
version_recommendations: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
requirement_changes = requirement_changes or []
|
||||
architecture_reviews = architecture_reviews or []
|
||||
document_status = document_status or {}
|
||||
research_status = research_status or {}
|
||||
version_recommendations = version_recommendations or []
|
||||
panels = [
|
||||
self.requirement_panel(requirement_changes),
|
||||
self.architecture_panel(architecture_reviews),
|
||||
self.document_panel(document_status),
|
||||
self.research_panel(research_status),
|
||||
self.version_panel(version_recommendations),
|
||||
]
|
||||
return {
|
||||
"module": "architect-governance",
|
||||
"route": "/admin/ai-evolution/architect",
|
||||
"title": "架构师 Agent 治理中心",
|
||||
"status": "attention_required" if any(self._panel_warn(panel) for panel in panels) else "healthy",
|
||||
"generated_at": utc_now(),
|
||||
"panels": [panel.to_dict() for panel in panels],
|
||||
"requirement_changes": requirement_changes,
|
||||
"architecture_reviews": architecture_reviews,
|
||||
"version_recommendations": version_recommendations,
|
||||
}
|
||||
|
||||
def requirement_panel(self, changes: list[dict[str, Any]]) -> ArchitectPanel:
|
||||
return ArchitectPanel(
|
||||
key="requirements",
|
||||
title="需求变化记录",
|
||||
description="记录来自 Custom GPT、Codex、OpenClaw、Hermes 和用户反馈的需求变化。",
|
||||
metrics=[
|
||||
ArchitectPanelMetric("total_requirement_changes", "需求变化数量", len(changes)),
|
||||
ArchitectPanelMetric(
|
||||
"undocumented_changes",
|
||||
"待刷新到用户需求文档",
|
||||
len([c for c in changes if not c.get("documented")]),
|
||||
"warn" if any(not c.get("documented") for c in changes) else "ok",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def architecture_panel(self, reviews: list[dict[str, Any]]) -> ArchitectPanel:
|
||||
pending = [r for r in reviews if r.get("status") not in {"pass", "not_required"}]
|
||||
return ArchitectPanel(
|
||||
key="architecture_review",
|
||||
title="全量架构审视",
|
||||
description="展示架构一致性、扩展性、安全边界、可运维性和回滚策略审查。",
|
||||
metrics=[
|
||||
ArchitectPanelMetric("review_count", "架构审查数量", len(reviews)),
|
||||
ArchitectPanelMetric("pending_reviews", "待处理架构审查", len(pending), "warn" if pending else "ok"),
|
||||
],
|
||||
)
|
||||
|
||||
def document_panel(self, status: dict[str, Any]) -> ArchitectPanel:
|
||||
required_docs = ["requirements", "architecture", "deployment", "test_cases"]
|
||||
missing = [doc for doc in required_docs if status.get(doc) not in {"ready", "updated"}]
|
||||
return ArchitectPanel(
|
||||
key="documents",
|
||||
title="文档版本化状态",
|
||||
description="用户需求、架构设计、工程部署、测试例文档刷新状态。",
|
||||
metrics=[
|
||||
ArchitectPanelMetric("requirements_doc", "用户需求文档", status.get("requirements", "unknown"), "warn" if "requirements" in missing else "ok"),
|
||||
ArchitectPanelMetric("architecture_doc", "架构设计文档", status.get("architecture", "unknown"), "warn" if "architecture" in missing else "ok"),
|
||||
ArchitectPanelMetric("deployment_doc", "工程部署文档", status.get("deployment", "unknown"), "warn" if "deployment" in missing else "ok"),
|
||||
ArchitectPanelMetric("test_case_doc", "测试例文档", status.get("test_cases", "unknown"), "warn" if "test_cases" in missing else "ok"),
|
||||
],
|
||||
)
|
||||
|
||||
def research_panel(self, status: dict[str, Any]) -> ArchitectPanel:
|
||||
overdue = bool(status.get("overdue"))
|
||||
return ArchitectPanel(
|
||||
key="weekly_research",
|
||||
title="每周外部技术情报",
|
||||
description="联网搜索 Agent/模型版本、GitHub top 实现、潜在安全影响。",
|
||||
metrics=[
|
||||
ArchitectPanelMetric("last_research_at", "最近调研时间", status.get("last_research_at", "never"), "warn" if overdue else "ok"),
|
||||
ArchitectPanelMetric("findings", "发现数量", status.get("findings", 0)),
|
||||
ArchitectPanelMetric("optimization_candidates", "可借鉴优化项", status.get("optimization_candidates", 0)),
|
||||
],
|
||||
)
|
||||
|
||||
def version_panel(self, recommendations: list[dict[str, Any]]) -> ArchitectPanel:
|
||||
latest = recommendations[-1] if recommendations else {}
|
||||
return ArchitectPanel(
|
||||
key="version_decision",
|
||||
title="V/R/C 版本建议",
|
||||
description="由架构师 Agent 判断变更应进入 C、R 或 V。",
|
||||
metrics=[
|
||||
ArchitectPanelMetric("recommendation_count", "版本建议数量", len(recommendations)),
|
||||
ArchitectPanelMetric("latest_recommendation", "最近建议", latest.get("version_bump", "none")),
|
||||
ArchitectPanelMetric("reason", "建议原因", latest.get("reason", "")),
|
||||
],
|
||||
)
|
||||
|
||||
def _panel_warn(self, panel: ArchitectPanel) -> bool:
|
||||
return any(metric.status == "warn" for metric in panel.metrics)
|
||||
|
||||
|
||||
__all__ = ["ArchitectPanelMetric", "ArchitectPanel", "ArchitectGovernanceDashboard"]
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Web UI model for Architect weekly research."""
|
||||
|
||||
from .architect_weekly_research_agent import ArchitectWeeklyResearchAgent
|
||||
|
||||
|
||||
class ArchitectResearchDashboard:
|
||||
def __init__(self, agent=None):
|
||||
self.agent = agent or ArchitectWeeklyResearchAgent()
|
||||
|
||||
def overview(self):
|
||||
return {
|
||||
"module": "architect-weekly-research",
|
||||
"route": "/admin/ai-evolution/architect/research",
|
||||
"title": "架构师 Agent 技术情报中心",
|
||||
"panels": [
|
||||
{
|
||||
"title": "Research Targets",
|
||||
"data": self.agent.plan(),
|
||||
},
|
||||
{
|
||||
"title": "Evaluation",
|
||||
"fields": [
|
||||
"relevance",
|
||||
"impact",
|
||||
"recommendation",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "Version Decision",
|
||||
"fields": [
|
||||
"C",
|
||||
"R",
|
||||
"V",
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["ArchitectResearchDashboard"]
|
||||
@@ -1,122 +0,0 @@
|
||||
"""Architect research timeline dashboard.
|
||||
|
||||
Shows weekly external research findings as a traceable architecture-impact flow:
|
||||
research target -> finding -> impact -> recommendation -> version decision ->
|
||||
governance event.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResearchTimelineStep:
|
||||
id: str
|
||||
title: str
|
||||
step_type: str
|
||||
status: str
|
||||
source: str
|
||||
timestamp: str
|
||||
impact: str = "none"
|
||||
version_bump: str = "none"
|
||||
detail: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class ArchitectResearchTimeline:
|
||||
ORDER = {
|
||||
"research_planned": 10,
|
||||
"finding_created": 20,
|
||||
"impact_analyzed": 30,
|
||||
"recommendation_created": 40,
|
||||
"version_decision_created": 50,
|
||||
"architecture_review_event_created": 60,
|
||||
}
|
||||
|
||||
def build(self, findings: list[dict[str, Any]] | None = None, decisions: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
findings = findings or []
|
||||
decisions = decisions or []
|
||||
steps: list[ResearchTimelineStep] = [
|
||||
ResearchTimelineStep(
|
||||
id="research-plan",
|
||||
title="每周技术情报调研计划",
|
||||
step_type="research_planned",
|
||||
status="ok",
|
||||
source="architect-agent",
|
||||
timestamp=utc_now(),
|
||||
detail={"cadence": "weekly"},
|
||||
)
|
||||
]
|
||||
for idx, finding in enumerate(findings):
|
||||
steps.append(
|
||||
ResearchTimelineStep(
|
||||
id=finding.get("id", f"finding-{idx}"),
|
||||
title=finding.get("title", "research finding"),
|
||||
step_type="finding_created",
|
||||
status="ok",
|
||||
source=finding.get("source", "unknown"),
|
||||
timestamp=finding.get("created_at", utc_now()),
|
||||
impact=finding.get("impact", "none"),
|
||||
detail=finding,
|
||||
)
|
||||
)
|
||||
steps.append(
|
||||
ResearchTimelineStep(
|
||||
id=f"impact-{finding.get('id', idx)}",
|
||||
title="架构影响分析",
|
||||
step_type="impact_analyzed",
|
||||
status="attention_required" if finding.get("impact") in {"architecture", "breaking", "new_capability"} else "ok",
|
||||
source="architect-agent",
|
||||
timestamp=utc_now(),
|
||||
impact=finding.get("impact", "none"),
|
||||
detail={"relevance": finding.get("relevance"), "recommendation": finding.get("recommendation")},
|
||||
)
|
||||
)
|
||||
for idx, decision in enumerate(decisions):
|
||||
steps.append(
|
||||
ResearchTimelineStep(
|
||||
id=decision.get("id", f"decision-{idx}"),
|
||||
title="C/R/V 版本建议",
|
||||
step_type="version_decision_created",
|
||||
status=decision.get("status", "ok"),
|
||||
source="architect-agent",
|
||||
timestamp=decision.get("created_at", utc_now()),
|
||||
version_bump=decision.get("version_bump", "none"),
|
||||
detail=decision,
|
||||
)
|
||||
)
|
||||
steps.sort(key=lambda s: (self.ORDER.get(s.step_type, 999), s.timestamp))
|
||||
return {
|
||||
"module": "architect-research-timeline",
|
||||
"route": "/admin/ai-evolution/architect/research/timeline",
|
||||
"title": "架构师技术情报时间线",
|
||||
"status": self._status(steps),
|
||||
"generated_at": utc_now(),
|
||||
"steps": [step.to_dict() for step in steps],
|
||||
"summary": self._summary(steps),
|
||||
}
|
||||
|
||||
def _status(self, steps: list[ResearchTimelineStep]) -> str:
|
||||
if any(step.status == "attention_required" for step in steps):
|
||||
return "attention_required"
|
||||
return "healthy"
|
||||
|
||||
def _summary(self, steps: list[ResearchTimelineStep]) -> dict[str, Any]:
|
||||
return {
|
||||
"findings": len([s for s in steps if s.step_type == "finding_created"]),
|
||||
"architecture_impacts": len([s for s in steps if s.impact in {"architecture", "breaking", "new_capability"}]),
|
||||
"version_decisions": len([s for s in steps if s.step_type == "version_decision_created"]),
|
||||
"recommended_bumps": sorted({s.version_bump for s in steps if s.version_bump != "none"}),
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["ResearchTimelineStep", "ArchitectResearchTimeline"]
|
||||
@@ -1,32 +0,0 @@
|
||||
"""API for architect research timeline."""
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
|
||||
from .architect_research_timeline import ArchitectResearchTimeline
|
||||
|
||||
|
||||
timeline = ArchitectResearchTimeline()
|
||||
|
||||
|
||||
def get_architect_research_timeline():
|
||||
return timeline.build()
|
||||
|
||||
|
||||
def build_router():
|
||||
if APIRouter is None:
|
||||
return None
|
||||
router = APIRouter(prefix="/admin/ai-evolution/architect/research", tags=["architect-research"])
|
||||
|
||||
@router.get("/timeline", operation_id="getArchitectResearchTimeline")
|
||||
def get_timeline():
|
||||
return get_architect_research_timeline()
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = ["router", "get_architect_research_timeline"]
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Weekly external research loop for Architect Governance Agent.
|
||||
|
||||
The agent plans and normalizes weekly research tasks. Actual internet access is
|
||||
provided by the runtime/search worker; this module defines the audited workflow,
|
||||
findings, impact analysis, and V/R/C recommendations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResearchTarget:
|
||||
id: str
|
||||
topic: str
|
||||
source_type: str
|
||||
query: str
|
||||
cadence: str = "weekly"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResearchFinding:
|
||||
id: str
|
||||
target_id: str
|
||||
title: str
|
||||
summary: str
|
||||
source: str
|
||||
relevance: str
|
||||
impact: str
|
||||
recommendation: str
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResearchDecision:
|
||||
status: str
|
||||
version_bump: str
|
||||
reason: str
|
||||
findings: list[ResearchFinding]
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"status": self.status,
|
||||
"version_bump": self.version_bump,
|
||||
"reason": self.reason,
|
||||
"findings": [f.to_dict() for f in self.findings],
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
|
||||
class ArchitectWeeklyResearchAgent:
|
||||
DEFAULT_TARGETS = (
|
||||
ResearchTarget("agent-frameworks", "Agent framework versions", "web", "latest multi-agent orchestration framework releases"),
|
||||
ResearchTarget("model-ecosystem", "Model ecosystem changes", "web", "latest LLM model releases tool calling agent features"),
|
||||
ResearchTarget("github-top-agentops", "GitHub top AgentOps implementations", "github", "top repositories agent observability multi agent orchestration"),
|
||||
ResearchTarget("security-agent-risks", "Agent security changes", "web", "latest AI agent security sandbox prompt injection tool use risks"),
|
||||
)
|
||||
|
||||
def plan(self) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "planned",
|
||||
"cadence": "weekly",
|
||||
"targets": [target.to_dict() for target in self.DEFAULT_TARGETS],
|
||||
"created_at": utc_now(),
|
||||
}
|
||||
|
||||
def normalize_finding(
|
||||
self,
|
||||
target_id: str,
|
||||
title: str,
|
||||
summary: str,
|
||||
source: str,
|
||||
relevance: str = "medium",
|
||||
impact: str = "none",
|
||||
recommendation: str = "monitor",
|
||||
) -> ResearchFinding:
|
||||
return ResearchFinding(
|
||||
id=f"finding_{uuid4().hex[:12]}",
|
||||
target_id=target_id,
|
||||
title=title,
|
||||
summary=summary,
|
||||
source=source,
|
||||
relevance=relevance,
|
||||
impact=impact,
|
||||
recommendation=recommendation,
|
||||
)
|
||||
|
||||
def decide(self, findings: list[ResearchFinding]) -> ResearchDecision:
|
||||
if any(f.impact == "breaking" for f in findings):
|
||||
return ResearchDecision("action_required", "V", "breaking external ecosystem change", findings)
|
||||
if any(f.impact in {"architecture", "new_capability"} for f in findings):
|
||||
return ResearchDecision("optimization_recommended", "R", "external capability may improve architecture", findings)
|
||||
if any(f.impact in {"bugfix", "minor"} for f in findings):
|
||||
return ResearchDecision("minor_change_recommended", "C", "minor compatible improvement", findings)
|
||||
return ResearchDecision("no_action", "none", "no relevant external change", findings)
|
||||
|
||||
|
||||
__all__ = ["ResearchTarget", "ResearchFinding", "ResearchDecision", "ArchitectWeeklyResearchAgent"]
|
||||
@@ -1,32 +0,0 @@
|
||||
"""API for architecture change governance dashboard."""
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
|
||||
from .architecture_change_dashboard import ArchitectureChangeDashboard
|
||||
|
||||
|
||||
dashboard = ArchitectureChangeDashboard()
|
||||
|
||||
|
||||
def get_architecture_change_overview():
|
||||
return dashboard.overview()
|
||||
|
||||
|
||||
def build_router():
|
||||
if APIRouter is None:
|
||||
return None
|
||||
router = APIRouter(prefix="/admin/ai-evolution/architecture-changes", tags=["architecture-changes"])
|
||||
|
||||
@router.get("/overview", operation_id="getArchitectureChangeOverview")
|
||||
def overview():
|
||||
return get_architecture_change_overview()
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = ["router", "get_architecture_change_overview"]
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Architecture change classification.
|
||||
|
||||
Small compatible architecture changes may be handled automatically by the
|
||||
Architect Agent with audit evidence. Large, risky, production-impacting or
|
||||
breaking architecture changes require explicit Owner approval before
|
||||
implementation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArchitectureChangeClassification:
|
||||
level: str
|
||||
owner_required: bool
|
||||
auto_implement_allowed: bool
|
||||
version_bump: str
|
||||
reasons: list[str] = field(default_factory=list)
|
||||
required_gates: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class ArchitectureChangeClassifier:
|
||||
"""Classifies architecture changes into small/medium/large governance paths."""
|
||||
|
||||
LARGE_FLAGS = {
|
||||
"breaking_change",
|
||||
"breaks_architecture",
|
||||
"production_architecture_update",
|
||||
"security_boundary_change",
|
||||
"cross_tenant_effect",
|
||||
"cross_user_data_effect",
|
||||
"data_migration",
|
||||
"permission_model_change",
|
||||
"runtime_infrastructure_change",
|
||||
}
|
||||
|
||||
def classify(self, change: dict[str, Any]) -> ArchitectureChangeClassification:
|
||||
risk = str(change.get("risk", "medium")).lower()
|
||||
version_bump = str(change.get("version_bump", change.get("recommended_version_bump", "C"))).upper()
|
||||
scope = str(change.get("scope", "component")).lower()
|
||||
reasons: list[str] = []
|
||||
|
||||
for flag in sorted(self.LARGE_FLAGS):
|
||||
if change.get(flag):
|
||||
reasons.append(flag)
|
||||
|
||||
if risk in {"high", "critical"}:
|
||||
reasons.append(f"risk={risk}")
|
||||
if version_bump == "V":
|
||||
reasons.append("version_bump=V")
|
||||
if version_bump == "R" and scope in {"system", "platform", "production", "cross_agent", "cross_tenant"}:
|
||||
reasons.append(f"version_bump=R scope={scope}")
|
||||
|
||||
if reasons:
|
||||
return ArchitectureChangeClassification(
|
||||
level="large",
|
||||
owner_required=True,
|
||||
auto_implement_allowed=False,
|
||||
version_bump=version_bump,
|
||||
reasons=reasons,
|
||||
required_gates=["architect_review", "owner_architecture_approval", "test_gate", "promotion_gate"],
|
||||
)
|
||||
|
||||
if risk == "low" and version_bump in {"C", "NONE"} and scope in {"component", "local", "documentation", "test"}:
|
||||
return ArchitectureChangeClassification(
|
||||
level="small",
|
||||
owner_required=False,
|
||||
auto_implement_allowed=True,
|
||||
version_bump=version_bump,
|
||||
reasons=["low_risk_compatible_change"],
|
||||
required_gates=["architect_review", "audit", "test_gate"],
|
||||
)
|
||||
|
||||
return ArchitectureChangeClassification(
|
||||
level="medium",
|
||||
owner_required=False,
|
||||
auto_implement_allowed=False,
|
||||
version_bump=version_bump,
|
||||
reasons=["requires_architect_review_before_implementation"],
|
||||
required_gates=["architect_review", "test_gate", "audit"],
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["ArchitectureChangeClassification", "ArchitectureChangeClassifier"]
|
||||
@@ -1,93 +0,0 @@
|
||||
"""Architecture change governance dashboard.
|
||||
|
||||
Displays architecture change classification, owner approval requirements,
|
||||
implementation readiness, version recommendation and audit evidence for Web UI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from .architecture_change_classifier import ArchitectureChangeClassifier
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArchitectureChangeView:
|
||||
change_id: str
|
||||
title: str
|
||||
level: str
|
||||
owner_required: bool
|
||||
auto_implement_allowed: bool
|
||||
version_bump: str
|
||||
status: str
|
||||
reasons: list[str] = field(default_factory=list)
|
||||
required_gates: list[str] = field(default_factory=list)
|
||||
owner_decision: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class ArchitectureChangeDashboard:
|
||||
def __init__(self, classifier: ArchitectureChangeClassifier | None = None):
|
||||
self.classifier = classifier or ArchitectureChangeClassifier()
|
||||
|
||||
def overview(self, changes: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
changes = changes or []
|
||||
views = [self._view(change) for change in changes]
|
||||
return {
|
||||
"module": "architecture-change-governance",
|
||||
"route": "/admin/ai-evolution/architecture-changes",
|
||||
"title": "架构变更治理中心",
|
||||
"status": self._status(views),
|
||||
"generated_at": utc_now(),
|
||||
"summary": self._summary(views),
|
||||
"changes": [view.to_dict() for view in views],
|
||||
}
|
||||
|
||||
def _view(self, change: dict[str, Any]) -> ArchitectureChangeView:
|
||||
classification = self.classifier.classify(change)
|
||||
owner_decision = change.get("owner_decision")
|
||||
if classification.owner_required and owner_decision != "approved":
|
||||
status = "waiting_owner_approval"
|
||||
elif classification.auto_implement_allowed:
|
||||
status = "auto_implementation_allowed"
|
||||
else:
|
||||
status = "ready_after_required_gates"
|
||||
return ArchitectureChangeView(
|
||||
change_id=change.get("change_id", change.get("id", "unknown")),
|
||||
title=change.get("title", "architecture change"),
|
||||
level=classification.level,
|
||||
owner_required=classification.owner_required,
|
||||
auto_implement_allowed=classification.auto_implement_allowed,
|
||||
version_bump=classification.version_bump,
|
||||
status=status,
|
||||
reasons=classification.reasons,
|
||||
required_gates=classification.required_gates,
|
||||
owner_decision=owner_decision,
|
||||
)
|
||||
|
||||
def _status(self, views: list[ArchitectureChangeView]) -> str:
|
||||
if any(view.status == "waiting_owner_approval" for view in views):
|
||||
return "attention_required"
|
||||
return "healthy"
|
||||
|
||||
def _summary(self, views: list[ArchitectureChangeView]) -> dict[str, Any]:
|
||||
return {
|
||||
"total": len(views),
|
||||
"small": len([v for v in views if v.level == "small"]),
|
||||
"medium": len([v for v in views if v.level == "medium"]),
|
||||
"large": len([v for v in views if v.level == "large"]),
|
||||
"waiting_owner_approval": len([v for v in views if v.status == "waiting_owner_approval"]),
|
||||
"auto_allowed": len([v for v in views if v.auto_implement_allowed]),
|
||||
"version_bumps": sorted({v.version_bump for v in views if v.version_bump and v.version_bump != "NONE"}),
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["ArchitectureChangeView", "ArchitectureChangeDashboard"]
|
||||
@@ -1,89 +0,0 @@
|
||||
"""Owner approval gate for architecture changes.
|
||||
|
||||
Architecture changes may be proposed, reviewed and optimized by agents, but they
|
||||
must not be implemented until owner approval is recorded. This gate sits before
|
||||
implementation and before promotion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArchitectureOwnerApprovalRequest:
|
||||
request_id: str
|
||||
change_id: str
|
||||
title: str
|
||||
reason: str
|
||||
risk: str
|
||||
architect_decision: str
|
||||
status: str = "pending_owner_approval"
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArchitectureOwnerDecision:
|
||||
request_id: str
|
||||
change_id: str
|
||||
decision: str
|
||||
decided_by: str
|
||||
reason: str = ""
|
||||
decided_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class ArchitectureOwnerGate:
|
||||
"""Blocks architecture implementation until owner explicitly approves."""
|
||||
|
||||
def create_request(self, change: dict[str, Any], architect_decision: dict[str, Any]) -> ArchitectureOwnerApprovalRequest:
|
||||
return ArchitectureOwnerApprovalRequest(
|
||||
request_id=f"ARCH-OWNER-{change.get('change_id', change.get('id', 'unknown'))}",
|
||||
change_id=change.get("change_id", change.get("id", "unknown")),
|
||||
title=change.get("title", "architecture change"),
|
||||
reason=change.get("reason", "architecture impact requires owner approval"),
|
||||
risk=change.get("risk", "medium"),
|
||||
architect_decision=architect_decision.get("status", "reviewed"),
|
||||
)
|
||||
|
||||
def can_implement(self, request: dict[str, Any], decision: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
if not decision:
|
||||
return {
|
||||
"allowed": False,
|
||||
"status": "waiting_owner_approval",
|
||||
"reason": "architecture change requires owner approval before implementation",
|
||||
"request": request,
|
||||
}
|
||||
if decision.get("decision") == "approved":
|
||||
return {
|
||||
"allowed": True,
|
||||
"status": "owner_approved",
|
||||
"reason": decision.get("reason", "approved by owner"),
|
||||
"request": request,
|
||||
"decision": decision,
|
||||
}
|
||||
return {
|
||||
"allowed": False,
|
||||
"status": "owner_rejected",
|
||||
"reason": decision.get("reason", "rejected by owner"),
|
||||
"request": request,
|
||||
"decision": decision,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ArchitectureOwnerApprovalRequest",
|
||||
"ArchitectureOwnerDecision",
|
||||
"ArchitectureOwnerGate",
|
||||
]
|
||||
@@ -1,79 +0,0 @@
|
||||
"""HTTP handler and display data for architecture owner gate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from .architecture_owner_gate import ArchitectureOwnerGate, ArchitectureOwnerApprovalRequest
|
||||
|
||||
|
||||
gate = ArchitectureOwnerGate()
|
||||
|
||||
# In-memory store for pending approval requests
|
||||
_pending_requests: dict[str, dict[str, Any]] = {}
|
||||
_decisions: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def create_owner_approval_request(change: dict[str, Any], architect_decision: dict[str, Any]) -> dict[str, Any]:
|
||||
req = gate.create_request(change, architect_decision)
|
||||
d = asdict(req)
|
||||
_pending_requests[req.change_id] = d
|
||||
return d
|
||||
|
||||
|
||||
def approve_request(change_id: str, decided_by: str = "owner", reason: str = "") -> dict[str, Any]:
|
||||
req = _pending_requests.get(change_id)
|
||||
if not req:
|
||||
return {"error": f"no pending request for change_id={change_id}"}
|
||||
decision = asdict(
|
||||
ArchitectureOwnerApprovalRequest.__bases__[1].__new__(type("_ArchitectureOwnerDecision", (), {})) # noqa
|
||||
)
|
||||
try:
|
||||
from .architecture_owner_gate import ArchitectureOwnerDecision
|
||||
dec = ArchitectureOwnerDecision(
|
||||
request_id=req["request_id"],
|
||||
change_id=change_id,
|
||||
decision="approved",
|
||||
decided_by=decided_by,
|
||||
reason=reason,
|
||||
)
|
||||
d = asdict(dec)
|
||||
_decisions[change_id] = d
|
||||
# remove from pending
|
||||
_pending_requests.pop(change_id, None)
|
||||
return {"allowed": True, "decision": d}
|
||||
except Exception as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
def reject_request(change_id: str, decided_by: str = "owner", reason: str = "") -> dict[str, Any]:
|
||||
from .architecture_owner_gate import ArchitectureOwnerDecision
|
||||
req = _pending_requests.get(change_id)
|
||||
if not req:
|
||||
return {"error": f"no pending request for change_id={change_id}"}
|
||||
try:
|
||||
dec = ArchitectureOwnerDecision(
|
||||
request_id=req["request_id"],
|
||||
change_id=change_id,
|
||||
decision="rejected",
|
||||
decided_by=decided_by,
|
||||
reason=reason,
|
||||
)
|
||||
d = asdict(dec)
|
||||
_decisions[change_id] = d
|
||||
_pending_requests.pop(change_id, None)
|
||||
return {"allowed": False, "decision": d}
|
||||
except Exception as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
def get_owner_gate_overview() -> dict[str, Any]:
|
||||
return {
|
||||
"pending_requests": list(_pending_requests.values()),
|
||||
"decisions": list(_decisions.values()),
|
||||
"pending_count": len(_pending_requests),
|
||||
"approved_count": sum(1 for d in _decisions.values() if d.get("decision") == "approved"),
|
||||
"rejected_count": sum(1 for d in _decisions.values() if d.get("decision") == "rejected"),
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Independent architecture optimization agent."""
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def utc_now():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArchitectureReview:
|
||||
agent: str
|
||||
decision: str
|
||||
findings: list[str]
|
||||
created_at: str = utc_now()
|
||||
|
||||
def to_dict(self):
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class ArchitectureReviewAgent:
|
||||
"""Reviews architecture changes from system-wide optimal perspective."""
|
||||
|
||||
def review(self, proposal: dict) -> ArchitectureReview:
|
||||
findings = [
|
||||
"dependency_impact_checked",
|
||||
"security_boundary_checked",
|
||||
"scalability_checked",
|
||||
"operability_checked",
|
||||
"rollback_strategy_checked",
|
||||
]
|
||||
return ArchitectureReview(
|
||||
"architecture-optimization-agent",
|
||||
"pass" if proposal.get("architecture_change") else "not_required",
|
||||
findings,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["ArchitectureReviewAgent", "ArchitectureReview"]
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Autonomous self-loop orchestration primitives.
|
||||
|
||||
Goal: detect gaps, classify risk, plan remediation, run checks, and emit an
|
||||
approval-aware report without storing secrets or bypassing owner governance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SelfLoopSignal:
|
||||
name: str
|
||||
status: str
|
||||
severity: str = "info"
|
||||
detail: str = ""
|
||||
|
||||
def ok(self) -> bool:
|
||||
return self.status in {"ok", "done", "pass"}
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RemediationStep:
|
||||
id: str
|
||||
title: str
|
||||
action: str
|
||||
risk: str = "low"
|
||||
requires_owner_approval: bool = False
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SelfLoopReport:
|
||||
id: str
|
||||
status: str
|
||||
signals: list[SelfLoopSignal] = field(default_factory=list)
|
||||
remediation: list[RemediationStep] = field(default_factory=list)
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"status": self.status,
|
||||
"signals": [s.to_dict() for s in self.signals],
|
||||
"remediation": [r.to_dict() for r in self.remediation],
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
|
||||
class AutonomousSelfLoop:
|
||||
def __init__(self, owner_approval_required: bool = True):
|
||||
self.owner_approval_required = owner_approval_required
|
||||
|
||||
def probe(self, runtime: dict[str, Any], baseline: dict[str, Any]) -> list[SelfLoopSignal]:
|
||||
return [
|
||||
SelfLoopSignal("runtime_active", "ok" if runtime.get("status") == "active" else "fail", "critical"),
|
||||
SelfLoopSignal("git_patch_only", "ok" if baseline.get("update_mode") == "git_patch_only" else "fail", "high"),
|
||||
SelfLoopSignal("no_direct_main_push", "ok" if not baseline.get("codex_gitops", {}).get("direct_main_push", True) else "fail", "high"),
|
||||
SelfLoopSignal("autonomous_recovery_loop", "ok" if baseline.get("features", {}).get("autonomous_recovery_loop") else "fail", "high"),
|
||||
SelfLoopSignal("portal_gitops_dashboard", "ok" if baseline.get("features", {}).get("portal_gitops_dashboard") else "fail", "medium"),
|
||||
]
|
||||
|
||||
def plan(self, signals: list[SelfLoopSignal]) -> list[RemediationStep]:
|
||||
steps: list[RemediationStep] = []
|
||||
failed = {s.name for s in signals if not s.ok()}
|
||||
if "runtime_active" in failed:
|
||||
steps.append(RemediationStep("restart-runtime", "恢复 GitOps Runtime", "request-runtime-recovery", "high", True))
|
||||
if "git_patch_only" in failed:
|
||||
steps.append(RemediationStep("enforce-git-patch", "恢复 git_patch_only 更新模式", "commit-policy-patch", "high", True))
|
||||
if "no_direct_main_push" in failed:
|
||||
steps.append(RemediationStep("disable-direct-main", "禁止直接推送 main", "commit-security-policy", "high", True))
|
||||
if "autonomous_recovery_loop" in failed:
|
||||
steps.append(RemediationStep("enable-recovery-loop", "启用自动恢复闭环", "commit-runtime-config", "medium", True))
|
||||
if "portal_gitops_dashboard" in failed:
|
||||
steps.append(RemediationStep("enable-portal-dashboard", "启用 Portal GitOps 驾驶舱", "commit-portal-config", "medium", True))
|
||||
if not steps:
|
||||
steps.append(RemediationStep("continue-implementation", "继续补齐 Router/UI/OpenAPI/验收", "create-low-risk-patch", "low", False))
|
||||
return steps
|
||||
|
||||
def evaluate(self, runtime: dict[str, Any], baseline: dict[str, Any]) -> SelfLoopReport:
|
||||
signals = self.probe(runtime, baseline)
|
||||
remediation = self.plan(signals)
|
||||
status = "done" if all(s.ok() for s in signals) and not any(r.requires_owner_approval for r in remediation) else "partial"
|
||||
return SelfLoopReport("self-loop-report", status, signals, remediation)
|
||||
|
||||
def acceptance(self, report: SelfLoopReport) -> dict[str, Any]:
|
||||
approval_blocked = any(step.requires_owner_approval for step in report.remediation)
|
||||
return {
|
||||
"status": report.status,
|
||||
"can_unattended_continue": report.status == "done" and not approval_blocked,
|
||||
"owner_approval_required": approval_blocked or self.owner_approval_required,
|
||||
"report": report.to_dict(),
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["SelfLoopSignal", "RemediationStep", "SelfLoopReport", "AutonomousSelfLoop"]
|
||||
@@ -1,103 +0,0 @@
|
||||
"""Change acceptance governance for AIOS.
|
||||
|
||||
Every feature change must provide matching tests. Every architecture change must
|
||||
be reviewed by an independent architecture agent. User-facing changes must be
|
||||
validated by an independent user acceptance agent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChangeDescriptor:
|
||||
change_id: str
|
||||
change_type: str
|
||||
title: str
|
||||
files_changed: list[str]
|
||||
tests: list[str] = field(default_factory=list)
|
||||
user_facing: bool = False
|
||||
architecture_impact: bool = False
|
||||
risk: str = "low"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AcceptanceFinding:
|
||||
check: str
|
||||
status: str
|
||||
detail: str
|
||||
required: bool = True
|
||||
|
||||
def passed(self) -> bool:
|
||||
return self.status == "pass" or not self.required
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AcceptanceDecision:
|
||||
status: str
|
||||
findings: list[AcceptanceFinding]
|
||||
decided_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"status": self.status,
|
||||
"findings": [f.to_dict() for f in self.findings],
|
||||
"decided_at": self.decided_at,
|
||||
}
|
||||
|
||||
|
||||
class ChangeAcceptanceGate:
|
||||
def evaluate(self, change: ChangeDescriptor) -> AcceptanceDecision:
|
||||
findings: list[AcceptanceFinding] = []
|
||||
findings.append(self._tests_required(change))
|
||||
findings.append(self._user_acceptance_required(change))
|
||||
findings.append(self._architecture_review_required(change))
|
||||
findings.append(self._risk_gate(change))
|
||||
status = "pass" if all(f.passed() for f in findings) else "blocked"
|
||||
return AcceptanceDecision(status, findings)
|
||||
|
||||
def _tests_required(self, change: ChangeDescriptor) -> AcceptanceFinding:
|
||||
if change.tests:
|
||||
return AcceptanceFinding("tests_present", "pass", "matching tests declared")
|
||||
return AcceptanceFinding("tests_present", "fail", "every feature change requires tests")
|
||||
|
||||
def _user_acceptance_required(self, change: ChangeDescriptor) -> AcceptanceFinding:
|
||||
if not change.user_facing:
|
||||
return AcceptanceFinding("user_acceptance", "pass", "not user-facing")
|
||||
has_user_test = any("user" in t.lower() or "acceptance" in t.lower() for t in change.tests)
|
||||
return AcceptanceFinding(
|
||||
"user_acceptance",
|
||||
"pass" if has_user_test else "fail",
|
||||
"user-facing change requires user acceptance test",
|
||||
)
|
||||
|
||||
def _architecture_review_required(self, change: ChangeDescriptor) -> AcceptanceFinding:
|
||||
if not change.architecture_impact:
|
||||
return AcceptanceFinding("architecture_review", "pass", "no architecture impact")
|
||||
has_arch_test = any("arch" in t.lower() or "architecture" in t.lower() for t in change.tests)
|
||||
return AcceptanceFinding(
|
||||
"architecture_review",
|
||||
"pass" if has_arch_test else "fail",
|
||||
"architecture change requires independent architect review artifact/test",
|
||||
)
|
||||
|
||||
def _risk_gate(self, change: ChangeDescriptor) -> AcceptanceFinding:
|
||||
if change.risk in {"low", "medium", "high"}:
|
||||
return AcceptanceFinding("risk_classified", "pass", f"risk={change.risk}")
|
||||
return AcceptanceFinding("risk_classified", "fail", "risk must be low/medium/high")
|
||||
|
||||
|
||||
__all__ = ["ChangeDescriptor", "AcceptanceFinding", "AcceptanceDecision", "ChangeAcceptanceGate"]
|
||||
@@ -1,34 +0,0 @@
|
||||
"""API for AIOS change governance dashboard."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
|
||||
from .change_governance_dashboard import ChangeGovernanceDashboard
|
||||
|
||||
|
||||
dashboard = ChangeGovernanceDashboard()
|
||||
|
||||
|
||||
def get_change_governance_overview(changes: list[dict[str, Any]] | None = None):
|
||||
return dashboard.overview(changes)
|
||||
|
||||
|
||||
def build_router():
|
||||
if APIRouter is None:
|
||||
return None
|
||||
router = APIRouter(prefix="/admin/ai-evolution/governance", tags=["change-governance"])
|
||||
|
||||
@router.get("/overview", operation_id="getChangeGovernanceOverview")
|
||||
def overview():
|
||||
return get_change_governance_overview()
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = ["router", "get_change_governance_overview"]
|
||||
@@ -1,93 +0,0 @@
|
||||
"""Web UI dashboard model for AIOS change governance.
|
||||
|
||||
Shows test gates, user acceptance, architecture review, risk gates and release
|
||||
blocking status for human operators and owner review.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernanceMetric:
|
||||
key: str
|
||||
title: str
|
||||
value: Any
|
||||
status: str = "ok"
|
||||
detail: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernancePanel:
|
||||
key: str
|
||||
title: str
|
||||
metrics: list[GovernanceMetric] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"key": self.key,
|
||||
"title": self.title,
|
||||
"metrics": [m.to_dict() for m in self.metrics],
|
||||
}
|
||||
|
||||
|
||||
class ChangeGovernanceDashboard:
|
||||
def overview(self, changes: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
changes = changes or []
|
||||
blocked = [c for c in changes if c.get("status") == "blocked"]
|
||||
waiting_arch = [c for c in changes if c.get("architecture_review") == "required"]
|
||||
waiting_user = [c for c in changes if c.get("user_acceptance") == "required"]
|
||||
missing_tests = [c for c in changes if not c.get("tests")]
|
||||
panels = [
|
||||
GovernancePanel(
|
||||
"test_gate",
|
||||
"测试 Gate",
|
||||
[
|
||||
GovernanceMetric("total_changes", "变更总数", len(changes)),
|
||||
GovernanceMetric("missing_tests", "缺少测试的变更", len(missing_tests), "warn" if missing_tests else "ok"),
|
||||
],
|
||||
),
|
||||
GovernancePanel(
|
||||
"user_acceptance",
|
||||
"用户验收 Agent",
|
||||
[
|
||||
GovernanceMetric("waiting_user_acceptance", "等待用户验收", len(waiting_user), "warn" if waiting_user else "ok"),
|
||||
],
|
||||
),
|
||||
GovernancePanel(
|
||||
"architecture_review",
|
||||
"架构师 Agent",
|
||||
[
|
||||
GovernanceMetric("waiting_architecture_review", "等待架构审查", len(waiting_arch), "warn" if waiting_arch else "ok"),
|
||||
],
|
||||
),
|
||||
GovernancePanel(
|
||||
"release_gate",
|
||||
"发布 Gate",
|
||||
[
|
||||
GovernanceMetric("blocked_changes", "阻塞发布的变更", len(blocked), "warn" if blocked else "ok"),
|
||||
],
|
||||
),
|
||||
]
|
||||
return {
|
||||
"module": "change-governance",
|
||||
"route": "/admin/ai-evolution/governance",
|
||||
"title": "AIOS 变更治理中心",
|
||||
"status": "attention_required" if blocked or missing_tests or waiting_arch or waiting_user else "healthy",
|
||||
"generated_at": utc_now(),
|
||||
"panels": [p.to_dict() for p in panels],
|
||||
"changes": changes,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["GovernanceMetric", "GovernancePanel", "ChangeGovernanceDashboard"]
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Failure memory for autonomous self-loop learning.
|
||||
|
||||
The memory keeps sanitized failure fingerprints, recurrence counters, and
|
||||
remediation hints. It is intentionally in-memory and serializable; production
|
||||
adapters can persist the exported dict to SQL/object storage without changing the
|
||||
learning contract. Secrets and raw tokens must never be stored here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from hashlib import sha256
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _clean(value: str | None) -> str:
|
||||
if not value:
|
||||
return "unknown"
|
||||
cleaned = value.replace("\n", " ").replace("\r", " ").strip().lower()
|
||||
secret_markers = ("token", "password", "secret", "api_key", "private key")
|
||||
for marker in secret_markers:
|
||||
cleaned = cleaned.replace(marker, "redacted")
|
||||
return cleaned[:500]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FailureSignature:
|
||||
component: str
|
||||
reason: str
|
||||
fingerprint: str
|
||||
severity: str = "medium"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FailureRecord:
|
||||
signature: FailureSignature
|
||||
count: int = 0
|
||||
first_seen: str = field(default_factory=utc_now)
|
||||
last_seen: str = field(default_factory=utc_now)
|
||||
last_job_id: str | None = None
|
||||
examples: list[str] = field(default_factory=list)
|
||||
remediation_hints: list[str] = field(default_factory=list)
|
||||
resolved_count: int = 0
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"signature": self.signature.to_dict(),
|
||||
"count": self.count,
|
||||
"first_seen": self.first_seen,
|
||||
"last_seen": self.last_seen,
|
||||
"last_job_id": self.last_job_id,
|
||||
"examples": list(self.examples),
|
||||
"remediation_hints": list(self.remediation_hints),
|
||||
"resolved_count": self.resolved_count,
|
||||
"recurrence_score": self.recurrence_score(),
|
||||
}
|
||||
|
||||
def recurrence_score(self) -> float:
|
||||
if self.count <= 1:
|
||||
return 0.0
|
||||
unresolved = max(self.count - self.resolved_count, 0)
|
||||
return min(1.0, unresolved / max(self.count, 1))
|
||||
|
||||
|
||||
class FailureMemory:
|
||||
def __init__(self) -> None:
|
||||
self.records: dict[str, FailureRecord] = {}
|
||||
|
||||
def fingerprint(self, component: str, reason: str, detail: str | None = None) -> str:
|
||||
base = "::".join([_clean(component), _clean(reason), _clean(detail)])
|
||||
return sha256(base.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
def signature(self, component: str, reason: str, detail: str | None = None, severity: str = "medium") -> FailureSignature:
|
||||
return FailureSignature(
|
||||
component=_clean(component),
|
||||
reason=_clean(reason),
|
||||
fingerprint=self.fingerprint(component, reason, detail),
|
||||
severity=severity,
|
||||
)
|
||||
|
||||
def record_failure(
|
||||
self,
|
||||
component: str,
|
||||
reason: str,
|
||||
detail: str | None = None,
|
||||
severity: str = "medium",
|
||||
job_id: str | None = None,
|
||||
remediation_hint: str | None = None,
|
||||
) -> FailureRecord:
|
||||
sig = self.signature(component, reason, detail, severity)
|
||||
record = self.records.get(sig.fingerprint)
|
||||
if record is None:
|
||||
record = FailureRecord(signature=sig)
|
||||
self.records[sig.fingerprint] = record
|
||||
record.count += 1
|
||||
record.last_seen = utc_now()
|
||||
record.last_job_id = job_id
|
||||
if detail:
|
||||
example = _clean(detail)
|
||||
if example not in record.examples:
|
||||
record.examples.append(example)
|
||||
record.examples = record.examples[-5:]
|
||||
if remediation_hint and remediation_hint not in record.remediation_hints:
|
||||
record.remediation_hints.append(remediation_hint)
|
||||
record.remediation_hints = record.remediation_hints[-5:]
|
||||
return record
|
||||
|
||||
def record_from_job(self, job: dict[str, Any]) -> FailureRecord | None:
|
||||
status = job.get("status")
|
||||
if status in {"completed", "waiting_owner"}:
|
||||
return None
|
||||
reason = job.get("last_error") or status or "unknown_failure"
|
||||
return self.record_failure(
|
||||
component="self_loop_job",
|
||||
reason=reason,
|
||||
detail=str(job.get("result") or job.get("last_error") or ""),
|
||||
severity="high" if status == "failed" else "medium",
|
||||
job_id=job.get("id"),
|
||||
remediation_hint="inspect acceptance report and regenerate patch plan",
|
||||
)
|
||||
|
||||
def mark_resolved(self, fingerprint: str) -> bool:
|
||||
record = self.records.get(fingerprint)
|
||||
if not record:
|
||||
return False
|
||||
record.resolved_count += 1
|
||||
record.last_seen = utc_now()
|
||||
return True
|
||||
|
||||
def recurrence_score(self, fingerprint: str) -> float:
|
||||
record = self.records.get(fingerprint)
|
||||
return record.recurrence_score() if record else 0.0
|
||||
|
||||
def top_failures(self, limit: int = 5) -> list[dict[str, Any]]:
|
||||
ranked = sorted(
|
||||
self.records.values(),
|
||||
key=lambda record: (record.recurrence_score(), record.count, record.last_seen),
|
||||
reverse=True,
|
||||
)
|
||||
return [record.to_dict() for record in ranked[:limit]]
|
||||
|
||||
def suggest_remediation(self, fingerprint: str) -> list[str]:
|
||||
record = self.records.get(fingerprint)
|
||||
if not record:
|
||||
return []
|
||||
if record.remediation_hints:
|
||||
return list(record.remediation_hints)
|
||||
reason = record.signature.reason
|
||||
if "runtime" in reason:
|
||||
return ["run runtime health probe", "request owner-gated runtime recovery if probe fails"]
|
||||
if "acceptance" in reason or "test" in reason:
|
||||
return ["rerun acceptance pipeline", "generate targeted low-risk patch"]
|
||||
if "permission" in reason or "forbidden" in reason:
|
||||
return ["verify GitOps branch policy", "request owner review for permission drift"]
|
||||
return ["classify failure", "generate remediation plan", "keep owner gate for non-low-risk changes"]
|
||||
|
||||
def export(self) -> dict[str, Any]:
|
||||
return {
|
||||
"records": {fingerprint: record.to_dict() for fingerprint, record in self.records.items()},
|
||||
"top_failures": self.top_failures(),
|
||||
}
|
||||
|
||||
def load(self, data: dict[str, Any]) -> None:
|
||||
self.records.clear()
|
||||
for fingerprint, raw in data.get("records", {}).items():
|
||||
sig_raw = raw.get("signature", {})
|
||||
signature = FailureSignature(
|
||||
component=sig_raw.get("component", "unknown"),
|
||||
reason=sig_raw.get("reason", "unknown"),
|
||||
fingerprint=sig_raw.get("fingerprint", fingerprint),
|
||||
severity=sig_raw.get("severity", "medium"),
|
||||
)
|
||||
record = FailureRecord(signature=signature)
|
||||
record.count = int(raw.get("count", 0))
|
||||
record.first_seen = raw.get("first_seen", utc_now())
|
||||
record.last_seen = raw.get("last_seen", utc_now())
|
||||
record.last_job_id = raw.get("last_job_id")
|
||||
record.examples = list(raw.get("examples", []))[-5:]
|
||||
record.remediation_hints = list(raw.get("remediation_hints", []))[-5:]
|
||||
record.resolved_count = int(raw.get("resolved_count", 0))
|
||||
self.records[fingerprint] = record
|
||||
|
||||
|
||||
__all__ = ["FailureSignature", "FailureRecord", "FailureMemory"]
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Persistence adapters for FailureMemory.
|
||||
|
||||
Stores sanitized failure memory snapshots. The adapter only persists exported
|
||||
FailureMemory dictionaries; it does not store secrets or raw runtime credentials.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .failure_memory import FailureMemory
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MemoryStoreStatus:
|
||||
status: str
|
||||
path: str
|
||||
records: int
|
||||
updated_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class JsonFailureMemoryStore:
|
||||
def __init__(self, path: str | Path):
|
||||
self.path = Path(path)
|
||||
|
||||
def save(self, memory: FailureMemory) -> MemoryStoreStatus:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = memory.export()
|
||||
self.path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return MemoryStoreStatus("saved", str(self.path), len(data.get("records", {})))
|
||||
|
||||
def load(self) -> FailureMemory:
|
||||
memory = FailureMemory()
|
||||
if not self.path.exists():
|
||||
return memory
|
||||
memory.load(json.loads(self.path.read_text(encoding="utf-8")))
|
||||
return memory
|
||||
|
||||
|
||||
class SQLiteFailureMemoryStore:
|
||||
def __init__(self, path: str | Path):
|
||||
self.path = Path(path)
|
||||
|
||||
def _connect(self):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(self.path))
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS self_loop_failure_memory (
|
||||
fingerprint TEXT PRIMARY KEY,
|
||||
payload_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
return conn
|
||||
|
||||
def save(self, memory: FailureMemory) -> MemoryStoreStatus:
|
||||
data = memory.export().get("records", {})
|
||||
with self._connect() as conn:
|
||||
for fingerprint, payload in data.items():
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO self_loop_failure_memory (fingerprint, payload_json, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(fingerprint) DO UPDATE SET
|
||||
payload_json = excluded.payload_json,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(fingerprint, json.dumps(payload, ensure_ascii=False), utc_now()),
|
||||
)
|
||||
return MemoryStoreStatus("saved", str(self.path), len(data))
|
||||
|
||||
def load(self) -> FailureMemory:
|
||||
memory = FailureMemory()
|
||||
if not self.path.exists():
|
||||
return memory
|
||||
records: dict[str, Any] = {}
|
||||
with self._connect() as conn:
|
||||
for fingerprint, payload_json in conn.execute("SELECT fingerprint, payload_json FROM self_loop_failure_memory"):
|
||||
records[fingerprint] = json.loads(payload_json)
|
||||
memory.load({"records": records})
|
||||
return memory
|
||||
|
||||
|
||||
__all__ = ["MemoryStoreStatus", "JsonFailureMemoryStore", "SQLiteFailureMemoryStore"]
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Git repository location resolver with validation.
|
||||
|
||||
Prevents hard-coded repository names from silently pointing to stale paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class GitRepoLocator:
|
||||
def __init__(self, root: str | None = None):
|
||||
self.root = Path(root or os.getenv("AIOS_GITOPS_ROOT", "/data/aios-gitops/runtime/gitea-bin/data/repositories"))
|
||||
|
||||
def candidates(self, owner: str = "aios", names: tuple[str, ...] = ("aios.git", "aios-v23.git")):
|
||||
return [self.root / owner / name for name in names]
|
||||
|
||||
def locate(self, owner: str = "aios") -> dict:
|
||||
found = [str(path) for path in self.candidates(owner) if path.exists()]
|
||||
return {
|
||||
"root": str(self.root),
|
||||
"owner": owner,
|
||||
"found": found,
|
||||
"healthy": len(found) == 1,
|
||||
"warning": "multiple repositories found" if len(found) > 1 else None,
|
||||
}
|
||||
|
||||
def require(self, owner: str = "aios") -> Path:
|
||||
result = self.locate(owner)
|
||||
if not result["healthy"]:
|
||||
raise RuntimeError(f"Git repo resolution failed: {result}")
|
||||
return Path(result["found"][0])
|
||||
|
||||
|
||||
__all__ = ["GitRepoLocator"]
|
||||
@@ -0,0 +1,59 @@
|
||||
"""GitOps commit planning adapter for self-loop patches.
|
||||
|
||||
Creates commit intents and validates governance. Actual repository writes remain
|
||||
through the controlled GitOps Actions layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from .self_loop_patch_executor import PatchBundle
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommitIntent:
|
||||
branch: str
|
||||
message: str
|
||||
files: tuple[str, ...]
|
||||
direct_main_push: bool
|
||||
owner_approval_required: bool
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class GitOpsCommitExecutor:
|
||||
"""Produces safe commit intents instead of bypassing GitOps controls."""
|
||||
|
||||
def prepare(self, bundle: PatchBundle) -> CommitIntent:
|
||||
if bundle.direct_main_push:
|
||||
raise ValueError("direct main push is forbidden")
|
||||
if not bundle.branch.startswith("patch/"):
|
||||
raise ValueError("only patch branches are allowed")
|
||||
return CommitIntent(
|
||||
branch=bundle.branch,
|
||||
message=f"Apply self-loop patch: {bundle.goal}",
|
||||
files=tuple(file.path for file in bundle.files),
|
||||
direct_main_push=False,
|
||||
owner_approval_required=bundle.owner_approval_required,
|
||||
)
|
||||
|
||||
def commit_result(self, intent: CommitIntent, revision: str | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "ready" if not intent.owner_approval_required else "waiting_owner",
|
||||
"branch": intent.branch,
|
||||
"revision": revision,
|
||||
"commit": intent.to_dict(),
|
||||
"sync_required": True,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["CommitIntent", "GitOpsCommitExecutor"]
|
||||
@@ -1,148 +0,0 @@
|
||||
"""Bridge governance agents into the unified Agent Event Bus.
|
||||
|
||||
This module emits normalized governance events for requirement changes,
|
||||
architecture reviews, user acceptance, test gates and version decisions. It uses
|
||||
duck typing so it can work with the unified AgentEventBus when available, while
|
||||
remaining testable without a running bus.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class EventPublisher(Protocol):
|
||||
def publish(
|
||||
self,
|
||||
kind: str,
|
||||
source: str,
|
||||
target: str,
|
||||
summary: str,
|
||||
payload: dict[str, Any],
|
||||
correlation_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernanceEventEnvelope:
|
||||
id: str
|
||||
kind: str
|
||||
source: str
|
||||
target: str
|
||||
summary: str
|
||||
payload: dict[str, Any]
|
||||
correlation_id: str | None = None
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class InMemoryGovernanceEventBus:
|
||||
"""Small fallback bus for tests and offline governance runs."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.events: list[dict[str, Any]] = []
|
||||
|
||||
def publish(
|
||||
self,
|
||||
kind: str,
|
||||
source: str,
|
||||
target: str,
|
||||
summary: str,
|
||||
payload: dict[str, Any],
|
||||
correlation_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
event = GovernanceEventEnvelope(
|
||||
id=f"evt_{uuid4().hex[:12]}",
|
||||
kind=kind,
|
||||
source=source,
|
||||
target=target,
|
||||
summary=summary,
|
||||
payload=payload,
|
||||
correlation_id=correlation_id,
|
||||
).to_dict()
|
||||
self.events.append(event)
|
||||
return event
|
||||
|
||||
|
||||
class GovernanceEventBridge:
|
||||
"""Publishes governance lifecycle events to the Agent Event Bus."""
|
||||
|
||||
def __init__(self, bus: EventPublisher | None = None):
|
||||
self.bus = bus or InMemoryGovernanceEventBus()
|
||||
|
||||
def requirement_changed(self, requirement: dict[str, Any]) -> dict[str, Any]:
|
||||
return self.bus.publish(
|
||||
kind="requirement_changed",
|
||||
source=requirement.get("source", "unknown"),
|
||||
target="architect-agent",
|
||||
summary=requirement.get("requirement", "requirement changed"),
|
||||
payload=requirement,
|
||||
correlation_id=requirement.get("id"),
|
||||
)
|
||||
|
||||
def architecture_review_started(self, proposal: dict[str, Any]) -> dict[str, Any]:
|
||||
return self.bus.publish(
|
||||
kind="architecture_review_started",
|
||||
source="architect-agent",
|
||||
target="architecture-review-agent",
|
||||
summary=proposal.get("title", "architecture review started"),
|
||||
payload=proposal,
|
||||
correlation_id=proposal.get("change_id"),
|
||||
)
|
||||
|
||||
def architecture_review_completed(self, decision: dict[str, Any]) -> dict[str, Any]:
|
||||
return self.bus.publish(
|
||||
kind="architecture_review_completed",
|
||||
source="architecture-review-agent",
|
||||
target="change-governance",
|
||||
summary=f"architecture review {decision.get('status', 'completed')}",
|
||||
payload=decision,
|
||||
correlation_id=decision.get("change_id"),
|
||||
)
|
||||
|
||||
def user_acceptance_completed(self, report: dict[str, Any]) -> dict[str, Any]:
|
||||
return self.bus.publish(
|
||||
kind="user_acceptance_completed",
|
||||
source="user-acceptance-agent",
|
||||
target="change-governance",
|
||||
summary=f"user acceptance {report.get('decision', 'completed')}",
|
||||
payload=report,
|
||||
correlation_id=report.get("change_id"),
|
||||
)
|
||||
|
||||
def test_gate_completed(self, result: dict[str, Any]) -> dict[str, Any]:
|
||||
return self.bus.publish(
|
||||
kind="test_gate_completed",
|
||||
source="test-gate",
|
||||
target="change-governance",
|
||||
summary=f"test gate {result.get('status', 'completed')}",
|
||||
payload=result,
|
||||
correlation_id=result.get("change_id"),
|
||||
)
|
||||
|
||||
def version_decision_created(self, decision: dict[str, Any]) -> dict[str, Any]:
|
||||
return self.bus.publish(
|
||||
kind="version_decision_created",
|
||||
source="architect-agent",
|
||||
target="owner-gate",
|
||||
summary=f"version bump recommendation: {decision.get('version_bump', 'unknown')}",
|
||||
payload=decision,
|
||||
correlation_id=decision.get("change_id"),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"GovernanceEventEnvelope",
|
||||
"InMemoryGovernanceEventBus",
|
||||
"GovernanceEventBridge",
|
||||
]
|
||||
@@ -1,32 +0,0 @@
|
||||
"""API for governance timeline visualization."""
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
|
||||
from .governance_timeline_dashboard import GovernanceTimelineDashboard
|
||||
|
||||
|
||||
dashboard = GovernanceTimelineDashboard()
|
||||
|
||||
|
||||
def get_governance_timeline(events=None):
|
||||
return dashboard.build(events)
|
||||
|
||||
|
||||
def build_router():
|
||||
if APIRouter is None:
|
||||
return None
|
||||
router = APIRouter(prefix="/admin/ai-evolution/governance", tags=["governance-timeline"])
|
||||
|
||||
@router.get("/timeline", operation_id="getGovernanceTimeline")
|
||||
def timeline():
|
||||
return get_governance_timeline()
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = ["router", "get_governance_timeline"]
|
||||
@@ -1,106 +0,0 @@
|
||||
"""Governance timeline dashboard.
|
||||
|
||||
Turns governance events into a human-readable flow for Web UI so operators can
|
||||
see why a change is blocked or ready for promotion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
EVENT_ORDER = {
|
||||
"requirement_changed": 10,
|
||||
"architecture_review_started": 20,
|
||||
"architecture_review_completed": 30,
|
||||
"test_gate_completed": 40,
|
||||
"user_acceptance_completed": 50,
|
||||
"version_decision_created": 60,
|
||||
"owner_gate_completed": 70,
|
||||
"promotion_completed": 80,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernanceTimelineStep:
|
||||
id: str
|
||||
kind: str
|
||||
title: str
|
||||
actor: str
|
||||
status: str
|
||||
timestamp: str
|
||||
correlation_id: str | None = None
|
||||
detail: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class GovernanceTimelineDashboard:
|
||||
def build(self, events: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
events = events or []
|
||||
steps = [self._event_to_step(event) for event in events]
|
||||
steps.sort(key=lambda step: (EVENT_ORDER.get(step.kind, 999), step.timestamp))
|
||||
blockers = [step for step in steps if step.status in {"blocked", "fail", "attention_required"}]
|
||||
return {
|
||||
"module": "governance-timeline",
|
||||
"route": "/admin/ai-evolution/governance/timeline",
|
||||
"title": "治理事件时间线",
|
||||
"status": "attention_required" if blockers else "healthy",
|
||||
"generated_at": utc_now(),
|
||||
"steps": [step.to_dict() for step in steps],
|
||||
"blockers": [step.to_dict() for step in blockers],
|
||||
"flow": self._flow_summary(steps),
|
||||
}
|
||||
|
||||
def _event_to_step(self, event: dict[str, Any]) -> GovernanceTimelineStep:
|
||||
payload = event.get("payload", {})
|
||||
kind = event.get("kind", "unknown")
|
||||
return GovernanceTimelineStep(
|
||||
id=event.get("id", f"event-{len(str(event))}"),
|
||||
kind=kind,
|
||||
title=self._title(kind, payload),
|
||||
actor=event.get("source", payload.get("actor", "unknown")),
|
||||
status=payload.get("status") or payload.get("decision") or event.get("status", "ok"),
|
||||
timestamp=event.get("created_at") or payload.get("created_at") or utc_now(),
|
||||
correlation_id=event.get("correlation_id") or payload.get("change_id") or payload.get("id"),
|
||||
detail=payload,
|
||||
)
|
||||
|
||||
def _title(self, kind: str, payload: dict[str, Any]) -> str:
|
||||
titles = {
|
||||
"requirement_changed": "需求变化",
|
||||
"architecture_review_started": "架构审查开始",
|
||||
"architecture_review_completed": "架构审查完成",
|
||||
"test_gate_completed": "测试 Gate 完成",
|
||||
"user_acceptance_completed": "用户验收完成",
|
||||
"version_decision_created": "版本建议产生",
|
||||
"owner_gate_completed": "Owner Gate 完成",
|
||||
"promotion_completed": "Promotion 完成",
|
||||
}
|
||||
return payload.get("title") or payload.get("summary") or titles.get(kind, kind)
|
||||
|
||||
def _flow_summary(self, steps: list[GovernanceTimelineStep]) -> dict[str, Any]:
|
||||
present = {step.kind for step in steps}
|
||||
required = [
|
||||
"requirement_changed",
|
||||
"architecture_review_completed",
|
||||
"test_gate_completed",
|
||||
"user_acceptance_completed",
|
||||
"version_decision_created",
|
||||
]
|
||||
missing = [kind for kind in required if kind not in present]
|
||||
return {
|
||||
"required_steps": required,
|
||||
"missing_steps": missing,
|
||||
"ready_for_owner_gate": not missing,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["GovernanceTimelineStep", "GovernanceTimelineDashboard"]
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Independent acceptance agents.
|
||||
|
||||
Simulates independent roles: user representative and architecture reviewer.
|
||||
They provide acceptance evidence, not implementation decisions.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AcceptanceReport:
|
||||
agent: str
|
||||
role: str
|
||||
decision: str
|
||||
evidence: list[str]
|
||||
created_at: str = utc_now()
|
||||
|
||||
def to_dict(self):
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class UserAcceptanceAgent:
|
||||
def review(self, change: dict[str, Any]) -> AcceptanceReport:
|
||||
checks = [
|
||||
"user_requirement_match",
|
||||
"user_workflow_validation",
|
||||
"permission_visibility_check",
|
||||
]
|
||||
return AcceptanceReport(
|
||||
"user-acceptance-agent",
|
||||
"user_representative",
|
||||
"pass" if change.get("user_facing") else "not_required",
|
||||
checks,
|
||||
)
|
||||
|
||||
|
||||
class ArchitectureAcceptanceAgent:
|
||||
def review(self, change: dict[str, Any]) -> AcceptanceReport:
|
||||
checks = [
|
||||
"architecture_consistency",
|
||||
"scalability_review",
|
||||
"security_boundary_review",
|
||||
"operability_review",
|
||||
]
|
||||
return AcceptanceReport(
|
||||
"architecture-review-agent",
|
||||
"system_architect",
|
||||
"pass" if change.get("architecture_impact") else "not_required",
|
||||
checks,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["AcceptanceReport", "UserAcceptanceAgent", "ArchitectureAcceptanceAgent"]
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Portal operations API domain layer.
|
||||
No secrets. Designed for user/group_admin/owner portal integration.
|
||||
"""
|
||||
|
||||
ROLES = ("user", "group_admin", "owner")
|
||||
|
||||
|
||||
class PortalOpsAPI:
|
||||
def dashboard(self, role):
|
||||
if role not in ROLES:
|
||||
raise ValueError("invalid role")
|
||||
return {
|
||||
"role": role,
|
||||
"modules": self.modules(role),
|
||||
"status": "ok"
|
||||
}
|
||||
|
||||
def modules(self, role):
|
||||
common = ["profile", "alerts", "approvals", "audit"]
|
||||
if role == "user":
|
||||
return common + ["tasks", "usage"]
|
||||
if role == "group_admin":
|
||||
return common + ["group_users", "group_quota"]
|
||||
return common + ["system", "gitops", "runtime", "security", "models"]
|
||||
|
||||
def approval(self, request_id, action):
|
||||
return {"request_id": request_id, "action": action, "state": "recorded"}
|
||||
|
||||
def audit_event(self, actor, action):
|
||||
return {"actor": actor, "action": action, "recorded": True}
|
||||
|
||||
def alert(self, alert_id, level):
|
||||
return {"id": alert_id, "level": level, "state": "open"}
|
||||
@@ -0,0 +1,493 @@
|
||||
"""Three-role portal RBAC, workflow, alert, audit, and owner cockpit services.
|
||||
|
||||
This module is intentionally framework-neutral so it can be used by FastAPI routes,
|
||||
unit tests, CLI checks, and future Hermes/OpenClaw agents without importing runtime
|
||||
secrets or web framework globals.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterable
|
||||
from uuid import uuid4
|
||||
|
||||
ROLE_USER = "user"
|
||||
ROLE_GROUP_ADMIN = "group_admin"
|
||||
ROLE_OWNER = "owner"
|
||||
ROLES = (ROLE_USER, ROLE_GROUP_ADMIN, ROLE_OWNER)
|
||||
|
||||
SCOPE_SELF = "self"
|
||||
SCOPE_GROUP = "group"
|
||||
SCOPE_GLOBAL = "global"
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PortalContext:
|
||||
user_id: str
|
||||
role: str = ROLE_USER
|
||||
group_id: str | None = None
|
||||
display_name: str | None = None
|
||||
|
||||
def natural_scope(self) -> str:
|
||||
if self.role == ROLE_OWNER:
|
||||
return SCOPE_GLOBAL
|
||||
if self.role == ROLE_GROUP_ADMIN:
|
||||
return SCOPE_GROUP
|
||||
return SCOPE_SELF
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PortalResource:
|
||||
scope: str
|
||||
owner_user_id: str | None = None
|
||||
group_id: str | None = None
|
||||
resource_type: str = "portal"
|
||||
resource_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RBACDecision:
|
||||
allowed: bool
|
||||
reason: str
|
||||
role: str
|
||||
scope: str
|
||||
action: str
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApprovalRequest:
|
||||
id: str
|
||||
request_type: str
|
||||
requester_id: str
|
||||
scope: str
|
||||
group_id: str | None = None
|
||||
target: str | None = None
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
state: str = "pending"
|
||||
decided_by: str | None = None
|
||||
decision_reason: str | None = None
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
updated_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AlertRecord:
|
||||
id: str
|
||||
level: str
|
||||
message: str
|
||||
scope: str
|
||||
group_id: str | None = None
|
||||
owner_user_id: str | None = None
|
||||
source: str = "portal"
|
||||
state: str = "open"
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
updated_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuditEvent:
|
||||
id: str
|
||||
actor_id: str
|
||||
action: str
|
||||
scope: str
|
||||
group_id: str | None = None
|
||||
resource_type: str = "portal"
|
||||
resource_id: str | None = None
|
||||
outcome: str = "success"
|
||||
detail: dict[str, Any] = field(default_factory=dict)
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PortalModule:
|
||||
key: str
|
||||
title: str
|
||||
route: str
|
||||
permission: str
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
ROLE_PERMISSIONS: dict[str, set[str]] = {
|
||||
ROLE_USER: {
|
||||
"portal.dashboard.read:self",
|
||||
"portal.profile.read:self",
|
||||
"portal.tasks.read:self",
|
||||
"portal.usage.read:self",
|
||||
"portal.alerts.read:self",
|
||||
"portal.approvals.create:self",
|
||||
"portal.approvals.read:self",
|
||||
"portal.audit.read:self",
|
||||
},
|
||||
ROLE_GROUP_ADMIN: {
|
||||
"portal.dashboard.read:self",
|
||||
"portal.dashboard.read:group",
|
||||
"portal.profile.read:self",
|
||||
"portal.tasks.read:self",
|
||||
"portal.usage.read:self",
|
||||
"portal.alerts.read:self",
|
||||
"portal.alerts.read:group",
|
||||
"portal.approvals.create:self",
|
||||
"portal.approvals.read:self",
|
||||
"portal.approvals.read:group",
|
||||
"portal.approvals.decide:group",
|
||||
"portal.audit.read:self",
|
||||
"portal.audit.read:group",
|
||||
"portal.group.users.read:group",
|
||||
"portal.group.quota.read:group",
|
||||
"portal.group.quota.write:group",
|
||||
},
|
||||
ROLE_OWNER: {
|
||||
"portal.*:*",
|
||||
},
|
||||
}
|
||||
|
||||
ROLE_MODULES: dict[str, tuple[PortalModule, ...]] = {
|
||||
ROLE_USER: (
|
||||
PortalModule("profile", "个人中心", "/portal/user/profile", "portal.profile.read:self"),
|
||||
PortalModule("tasks", "任务", "/portal/user/tasks", "portal.tasks.read:self"),
|
||||
PortalModule("usage", "用量", "/portal/user/usage", "portal.usage.read:self"),
|
||||
PortalModule("alerts", "告警", "/portal/user/alerts", "portal.alerts.read:self"),
|
||||
PortalModule("approvals", "审批", "/portal/user/approvals", "portal.approvals.read:self"),
|
||||
PortalModule("audit", "审计", "/portal/user/audit", "portal.audit.read:self"),
|
||||
),
|
||||
ROLE_GROUP_ADMIN: (
|
||||
PortalModule("group_users", "组用户", "/portal/group_admin/users", "portal.group.users.read:group"),
|
||||
PortalModule("group_quota", "组配额", "/portal/group_admin/quota", "portal.group.quota.read:group"),
|
||||
PortalModule("alerts", "组告警", "/portal/group_admin/alerts", "portal.alerts.read:group"),
|
||||
PortalModule("approvals", "组审批", "/portal/group_admin/approvals", "portal.approvals.read:group"),
|
||||
PortalModule("audit", "组审计", "/portal/group_admin/audit", "portal.audit.read:group"),
|
||||
),
|
||||
ROLE_OWNER: (
|
||||
PortalModule("system", "系统驾驶舱", "/portal/owner/system", "portal.system.read:global"),
|
||||
PortalModule("gitops", "GitOps", "/portal/owner/gitops", "portal.gitops.read:global"),
|
||||
PortalModule("runtime", "运行时", "/portal/owner/runtime", "portal.runtime.read:global"),
|
||||
PortalModule("security", "安全", "/portal/owner/security", "portal.security.read:global"),
|
||||
PortalModule("models", "模型", "/portal/owner/models", "portal.models.read:global"),
|
||||
PortalModule("approvals", "全局审批", "/portal/owner/approvals", "portal.approvals.read:global"),
|
||||
PortalModule("alerts", "全局告警", "/portal/owner/alerts", "portal.alerts.read:global"),
|
||||
PortalModule("audit", "全局审计", "/portal/owner/audit", "portal.audit.read:global"),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class PortalRBACService:
|
||||
"""Enforces user=self, group_admin=group, owner=global access."""
|
||||
|
||||
def __init__(self, permissions: dict[str, set[str]] | None = None):
|
||||
self.permissions = permissions or ROLE_PERMISSIONS
|
||||
|
||||
def authorize(self, ctx: PortalContext, action: str, resource: PortalResource | None = None) -> RBACDecision:
|
||||
if ctx.role not in ROLES:
|
||||
return RBACDecision(False, "unknown_role", ctx.role, SCOPE_SELF, action)
|
||||
|
||||
target = resource or PortalResource(scope=ctx.natural_scope(), owner_user_id=ctx.user_id, group_id=ctx.group_id)
|
||||
if ctx.role == ROLE_OWNER:
|
||||
return RBACDecision(True, "owner_global_allow", ctx.role, target.scope, action)
|
||||
|
||||
if target.scope == SCOPE_GLOBAL:
|
||||
return RBACDecision(False, "global_scope_requires_owner", ctx.role, target.scope, action)
|
||||
|
||||
if ctx.role == ROLE_USER:
|
||||
if target.scope != SCOPE_SELF or target.owner_user_id not in (None, ctx.user_id):
|
||||
return RBACDecision(False, "user_limited_to_self", ctx.role, target.scope, action)
|
||||
return self._permission_decision(ctx.role, action, SCOPE_SELF)
|
||||
|
||||
if ctx.role == ROLE_GROUP_ADMIN:
|
||||
if target.scope == SCOPE_SELF and target.owner_user_id in (None, ctx.user_id):
|
||||
return self._permission_decision(ctx.role, action, SCOPE_SELF)
|
||||
if target.scope == SCOPE_GROUP and ctx.group_id and target.group_id == ctx.group_id:
|
||||
return self._permission_decision(ctx.role, action, SCOPE_GROUP)
|
||||
return RBACDecision(False, "group_admin_limited_to_own_group", ctx.role, target.scope, action)
|
||||
|
||||
return RBACDecision(False, "deny_by_default", ctx.role, target.scope, action)
|
||||
|
||||
def visible_modules(self, ctx: PortalContext) -> list[dict[str, Any]]:
|
||||
modules = ROLE_MODULES.get(ctx.role, ())
|
||||
visible: list[dict[str, Any]] = []
|
||||
for module in modules:
|
||||
action, scope = module.permission.split(":", 1)
|
||||
resource = PortalResource(scope=scope, owner_user_id=ctx.user_id, group_id=ctx.group_id)
|
||||
if self.authorize(ctx, action, resource).allowed:
|
||||
visible.append(module.to_dict())
|
||||
return visible
|
||||
|
||||
def _permission_decision(self, role: str, action: str, scope: str) -> RBACDecision:
|
||||
permissions = self.permissions.get(role, set())
|
||||
if "portal.*:*" in permissions or f"{action}:{scope}" in permissions:
|
||||
return RBACDecision(True, "permission_match", role, scope, action)
|
||||
return RBACDecision(False, "permission_missing", role, scope, action)
|
||||
|
||||
|
||||
class PortalRepository:
|
||||
"""Small deterministic repository used for domain tests and route integration.
|
||||
|
||||
Production deployments can replace this repository with a SQL-backed adapter while
|
||||
preserving the service contract.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.approvals: dict[str, ApprovalRequest] = {}
|
||||
self.alerts: dict[str, AlertRecord] = {}
|
||||
self.audit_events: list[AuditEvent] = []
|
||||
|
||||
def create_approval(self, ctx: PortalContext, request_type: str, payload: dict[str, Any] | None = None, target: str | None = None) -> ApprovalRequest:
|
||||
approval = ApprovalRequest(
|
||||
id=f"apr_{uuid4().hex[:12]}",
|
||||
request_type=request_type,
|
||||
requester_id=ctx.user_id,
|
||||
scope=ctx.natural_scope(),
|
||||
group_id=ctx.group_id,
|
||||
target=target,
|
||||
payload=payload or {},
|
||||
)
|
||||
self.approvals[approval.id] = approval
|
||||
self.append_audit(ctx, "portal.approvals.create", approval.scope, approval.group_id, "approval", approval.id)
|
||||
return approval
|
||||
|
||||
def decide_approval(self, ctx: PortalContext, approval_id: str, decision: str, reason: str | None = None) -> ApprovalRequest:
|
||||
if decision not in {"approved", "rejected"}:
|
||||
raise ValueError("decision must be approved or rejected")
|
||||
approval = self.approvals[approval_id]
|
||||
approval.state = decision
|
||||
approval.decided_by = ctx.user_id
|
||||
approval.decision_reason = reason
|
||||
approval.updated_at = utc_now()
|
||||
self.append_audit(ctx, f"portal.approvals.{decision}", approval.scope, approval.group_id, "approval", approval.id)
|
||||
return approval
|
||||
|
||||
def create_alert(self, ctx: PortalContext, level: str, message: str, scope: str | None = None, source: str = "portal") -> AlertRecord:
|
||||
if level not in {"info", "warning", "high", "critical"}:
|
||||
raise ValueError("invalid alert level")
|
||||
alert_scope = scope or ctx.natural_scope()
|
||||
alert = AlertRecord(
|
||||
id=f"alt_{uuid4().hex[:12]}",
|
||||
level=level,
|
||||
message=message,
|
||||
scope=alert_scope,
|
||||
group_id=ctx.group_id if alert_scope == SCOPE_GROUP else None,
|
||||
owner_user_id=ctx.user_id if alert_scope == SCOPE_SELF else None,
|
||||
source=source,
|
||||
)
|
||||
self.alerts[alert.id] = alert
|
||||
self.append_audit(ctx, "portal.alerts.create", alert.scope, alert.group_id, "alert", alert.id)
|
||||
return alert
|
||||
|
||||
def resolve_alert(self, ctx: PortalContext, alert_id: str) -> AlertRecord:
|
||||
alert = self.alerts[alert_id]
|
||||
alert.state = "resolved"
|
||||
alert.updated_at = utc_now()
|
||||
self.append_audit(ctx, "portal.alerts.resolve", alert.scope, alert.group_id, "alert", alert.id)
|
||||
return alert
|
||||
|
||||
def append_audit(
|
||||
self,
|
||||
ctx: PortalContext,
|
||||
action: str,
|
||||
scope: str | None = None,
|
||||
group_id: str | None = None,
|
||||
resource_type: str = "portal",
|
||||
resource_id: str | None = None,
|
||||
outcome: str = "success",
|
||||
detail: dict[str, Any] | None = None,
|
||||
) -> AuditEvent:
|
||||
event = AuditEvent(
|
||||
id=f"aud_{uuid4().hex[:12]}",
|
||||
actor_id=ctx.user_id,
|
||||
action=action,
|
||||
scope=scope or ctx.natural_scope(),
|
||||
group_id=group_id or ctx.group_id,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
outcome=outcome,
|
||||
detail=detail or {},
|
||||
)
|
||||
self.audit_events.append(event)
|
||||
return event
|
||||
|
||||
def list_approvals(self, ctx: PortalContext) -> list[ApprovalRequest]:
|
||||
return [a for a in self.approvals.values() if self._visible(ctx, a.scope, a.requester_id, a.group_id)]
|
||||
|
||||
def list_alerts(self, ctx: PortalContext) -> list[AlertRecord]:
|
||||
return [a for a in self.alerts.values() if self._visible(ctx, a.scope, a.owner_user_id, a.group_id)]
|
||||
|
||||
def list_audit(self, ctx: PortalContext) -> list[AuditEvent]:
|
||||
return [a for a in self.audit_events if self._visible(ctx, a.scope, a.actor_id, a.group_id)]
|
||||
|
||||
def _visible(self, ctx: PortalContext, scope: str, owner_user_id: str | None, group_id: str | None) -> bool:
|
||||
if ctx.role == ROLE_OWNER:
|
||||
return True
|
||||
if scope == SCOPE_SELF:
|
||||
return owner_user_id in (None, ctx.user_id)
|
||||
if ctx.role == ROLE_GROUP_ADMIN and scope == SCOPE_GROUP:
|
||||
return bool(ctx.group_id and group_id == ctx.group_id)
|
||||
return False
|
||||
|
||||
|
||||
class PortalDashboardService:
|
||||
def __init__(self, repo: PortalRepository | None = None, rbac: PortalRBACService | None = None):
|
||||
self.repo = repo or PortalRepository()
|
||||
self.rbac = rbac or PortalRBACService()
|
||||
|
||||
def dashboard(self, ctx: PortalContext) -> dict[str, Any]:
|
||||
resource = PortalResource(scope=ctx.natural_scope(), owner_user_id=ctx.user_id, group_id=ctx.group_id)
|
||||
decision = self.rbac.authorize(ctx, "portal.dashboard.read", resource)
|
||||
if not decision.allowed:
|
||||
return {"status": "forbidden", "decision": decision.to_dict(), "modules": []}
|
||||
return {
|
||||
"status": "ok",
|
||||
"role": ctx.role,
|
||||
"scope": ctx.natural_scope(),
|
||||
"user_id": ctx.user_id,
|
||||
"group_id": ctx.group_id,
|
||||
"modules": self.rbac.visible_modules(ctx),
|
||||
"counters": {
|
||||
"approvals": len(self.repo.list_approvals(ctx)),
|
||||
"alerts": len([a for a in self.repo.list_alerts(ctx) if a.state == "open"]),
|
||||
"audit_events": len(self.repo.list_audit(ctx)),
|
||||
},
|
||||
}
|
||||
|
||||
def owner_cockpit(self, ctx: PortalContext, runtime: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
decision = self.rbac.authorize(ctx, "portal.system.read", PortalResource(scope=SCOPE_GLOBAL))
|
||||
if not decision.allowed:
|
||||
return {"status": "forbidden", "decision": decision.to_dict()}
|
||||
runtime = runtime or {}
|
||||
return {
|
||||
"status": "ok",
|
||||
"scope": SCOPE_GLOBAL,
|
||||
"runtime": {
|
||||
"service": runtime.get("service", "unknown"),
|
||||
"status": runtime.get("status", "unknown"),
|
||||
"workspace_branch": runtime.get("workspace_branch"),
|
||||
},
|
||||
"gitops": {
|
||||
"mode": runtime.get("mode", "git_patch_only"),
|
||||
"sync_status": runtime.get("sync_status", "unknown"),
|
||||
},
|
||||
"security": {
|
||||
"human_shortlinks": "login_required",
|
||||
"secrets_in_git": False,
|
||||
"direct_main_push": False,
|
||||
},
|
||||
"models": {
|
||||
"custom_llm": bool(runtime.get("custom_llm", True)),
|
||||
"user_priority": bool(runtime.get("user_model_priority", True)),
|
||||
},
|
||||
"ops": {
|
||||
"open_alerts": len([a for a in self.repo.alerts.values() if a.state == "open"]),
|
||||
"pending_approvals": len([a for a in self.repo.approvals.values() if a.state == "pending"]),
|
||||
"audit_events": len(self.repo.audit_events),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class PortalOpsService:
|
||||
def __init__(self, repo: PortalRepository | None = None, rbac: PortalRBACService | None = None):
|
||||
self.repo = repo or PortalRepository()
|
||||
self.rbac = rbac or PortalRBACService()
|
||||
self.dashboard_service = PortalDashboardService(self.repo, self.rbac)
|
||||
|
||||
def check_rbac(self, ctx: PortalContext, action: str, resource: PortalResource | None = None) -> dict[str, Any]:
|
||||
return self.rbac.authorize(ctx, action, resource).to_dict()
|
||||
|
||||
def dashboard(self, ctx: PortalContext) -> dict[str, Any]:
|
||||
return self.dashboard_service.dashboard(ctx)
|
||||
|
||||
def owner_cockpit(self, ctx: PortalContext, runtime: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
return self.dashboard_service.owner_cockpit(ctx, runtime)
|
||||
|
||||
def create_approval(self, ctx: PortalContext, request_type: str, payload: dict[str, Any] | None = None, target: str | None = None) -> dict[str, Any]:
|
||||
decision = self.rbac.authorize(ctx, "portal.approvals.create", PortalResource(scope=ctx.natural_scope(), owner_user_id=ctx.user_id, group_id=ctx.group_id))
|
||||
if not decision.allowed:
|
||||
return {"status": "forbidden", "decision": decision.to_dict()}
|
||||
approval = self.repo.create_approval(ctx, request_type, payload, target)
|
||||
return {"status": "ok", "approval": approval.to_dict()}
|
||||
|
||||
def decide_approval(self, ctx: PortalContext, approval_id: str, decision_value: str, reason: str | None = None) -> dict[str, Any]:
|
||||
approval = self.repo.approvals[approval_id]
|
||||
decision = self.rbac.authorize(ctx, "portal.approvals.decide", PortalResource(scope=approval.scope, group_id=approval.group_id, owner_user_id=approval.requester_id))
|
||||
if not decision.allowed:
|
||||
return {"status": "forbidden", "decision": decision.to_dict()}
|
||||
updated = self.repo.decide_approval(ctx, approval_id, decision_value, reason)
|
||||
return {"status": "ok", "approval": updated.to_dict()}
|
||||
|
||||
def create_alert(self, ctx: PortalContext, level: str, message: str, scope: str | None = None, source: str = "portal") -> dict[str, Any]:
|
||||
requested_scope = scope or ctx.natural_scope()
|
||||
decision = self.rbac.authorize(ctx, "portal.alerts.read", PortalResource(scope=requested_scope, group_id=ctx.group_id, owner_user_id=ctx.user_id))
|
||||
if not decision.allowed:
|
||||
return {"status": "forbidden", "decision": decision.to_dict()}
|
||||
alert = self.repo.create_alert(ctx, level, message, requested_scope, source)
|
||||
return {"status": "ok", "alert": alert.to_dict()}
|
||||
|
||||
def list_alerts(self, ctx: PortalContext) -> dict[str, Any]:
|
||||
return {"status": "ok", "alerts": [a.to_dict() for a in self.repo.list_alerts(ctx)]}
|
||||
|
||||
def list_approvals(self, ctx: PortalContext) -> dict[str, Any]:
|
||||
return {"status": "ok", "approvals": [a.to_dict() for a in self.repo.list_approvals(ctx)]}
|
||||
|
||||
def list_audit(self, ctx: PortalContext) -> dict[str, Any]:
|
||||
return {"status": "ok", "audit": [a.to_dict() for a in self.repo.list_audit(ctx)]}
|
||||
|
||||
def run_self_loop(self) -> dict[str, Any]:
|
||||
owner = PortalContext("owner", ROLE_OWNER)
|
||||
group_admin = PortalContext("admin", ROLE_GROUP_ADMIN, "g1")
|
||||
user = PortalContext("user", ROLE_USER, "g1")
|
||||
self.create_alert(owner, "info", "self-loop-started", SCOPE_GLOBAL, "self_loop")
|
||||
approval = self.create_approval(user, "model_priority_change", {"model": "default"}, "model_priority")
|
||||
approval_id = approval["approval"]["id"]
|
||||
decision = self.decide_approval(group_admin, approval_id, "approved", "self-loop group approval")
|
||||
return {
|
||||
"status": "ok",
|
||||
"checks": {
|
||||
"user_dashboard": self.dashboard(user)["status"] == "ok",
|
||||
"group_admin_dashboard": self.dashboard(group_admin)["status"] == "ok",
|
||||
"owner_cockpit": self.owner_cockpit(owner)["status"] == "ok",
|
||||
"approval_decision": decision["status"] == "ok",
|
||||
"audit_written": len(self.repo.audit_events) >= 3,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_context(user_id: str, role: str = ROLE_USER, group_id: str | None = None, display_name: str | None = None) -> PortalContext:
|
||||
return PortalContext(user_id=user_id, role=role, group_id=group_id, display_name=display_name)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ROLE_USER",
|
||||
"ROLE_GROUP_ADMIN",
|
||||
"ROLE_OWNER",
|
||||
"ROLES",
|
||||
"SCOPE_SELF",
|
||||
"SCOPE_GROUP",
|
||||
"SCOPE_GLOBAL",
|
||||
"PortalContext",
|
||||
"PortalResource",
|
||||
"RBACDecision",
|
||||
"ApprovalRequest",
|
||||
"AlertRecord",
|
||||
"AuditEvent",
|
||||
"PortalRBACService",
|
||||
"PortalRepository",
|
||||
"PortalDashboardService",
|
||||
"PortalOpsService",
|
||||
"build_context",
|
||||
]
|
||||
@@ -0,0 +1,239 @@
|
||||
"""HTTP routes for the three-role user portal.
|
||||
|
||||
The router is FastAPI-compatible but keeps handlers as plain functions so tests and
|
||||
agent self-checks can exercise the contract without booting an ASGI server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
try: # pragma: no cover - optional runtime dependency
|
||||
from fastapi import APIRouter, Header, HTTPException
|
||||
except Exception: # pragma: no cover - test/runtime fallback
|
||||
APIRouter = None
|
||||
Header = None
|
||||
HTTPException = RuntimeError
|
||||
|
||||
from .portal_rbac_ops import (
|
||||
ROLE_GROUP_ADMIN,
|
||||
ROLE_OWNER,
|
||||
ROLE_USER,
|
||||
SCOPE_GLOBAL,
|
||||
PortalOpsService,
|
||||
PortalResource,
|
||||
build_context,
|
||||
)
|
||||
|
||||
portal_ops_service = PortalOpsService()
|
||||
|
||||
|
||||
def context_from_headers(
|
||||
user_id: str | None = None,
|
||||
role: str | None = None,
|
||||
group_id: str | None = None,
|
||||
display_name: str | None = None,
|
||||
):
|
||||
return build_context(
|
||||
user_id=user_id or "anonymous",
|
||||
role=role or ROLE_USER,
|
||||
group_id=group_id,
|
||||
display_name=display_name,
|
||||
)
|
||||
|
||||
|
||||
def get_portal_dashboard(role: str, user_id: str = "anonymous", group_id: str | None = None) -> dict[str, Any]:
|
||||
ctx = context_from_headers(user_id=user_id, role=role, group_id=group_id)
|
||||
return portal_ops_service.dashboard(ctx)
|
||||
|
||||
|
||||
def get_owner_system_cockpit(user_id: str = "owner", runtime: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
ctx = context_from_headers(user_id=user_id, role=ROLE_OWNER)
|
||||
return portal_ops_service.owner_cockpit(ctx, runtime)
|
||||
|
||||
|
||||
def check_portal_rbac(
|
||||
role: str,
|
||||
action: str,
|
||||
user_id: str = "anonymous",
|
||||
group_id: str | None = None,
|
||||
scope: str | None = None,
|
||||
owner_user_id: str | None = None,
|
||||
resource_group_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
ctx = context_from_headers(user_id=user_id, role=role, group_id=group_id)
|
||||
resource = PortalResource(
|
||||
scope=scope or ctx.natural_scope(),
|
||||
owner_user_id=owner_user_id or user_id,
|
||||
group_id=resource_group_id or group_id,
|
||||
)
|
||||
return portal_ops_service.check_rbac(ctx, action, resource)
|
||||
|
||||
|
||||
def create_portal_approval(
|
||||
role: str,
|
||||
request_type: str,
|
||||
user_id: str = "anonymous",
|
||||
group_id: str | None = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
target: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
ctx = context_from_headers(user_id=user_id, role=role, group_id=group_id)
|
||||
return portal_ops_service.create_approval(ctx, request_type, payload, target)
|
||||
|
||||
|
||||
def decide_portal_approval(
|
||||
approval_id: str,
|
||||
decision: str,
|
||||
role: str,
|
||||
user_id: str,
|
||||
group_id: str | None = None,
|
||||
reason: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
ctx = context_from_headers(user_id=user_id, role=role, group_id=group_id)
|
||||
return portal_ops_service.decide_approval(ctx, approval_id, decision, reason)
|
||||
|
||||
|
||||
def list_portal_alerts(role: str, user_id: str = "anonymous", group_id: str | None = None) -> dict[str, Any]:
|
||||
ctx = context_from_headers(user_id=user_id, role=role, group_id=group_id)
|
||||
return portal_ops_service.list_alerts(ctx)
|
||||
|
||||
|
||||
def create_portal_alert(
|
||||
role: str,
|
||||
level: str,
|
||||
message: str,
|
||||
user_id: str = "anonymous",
|
||||
group_id: str | None = None,
|
||||
scope: str | None = None,
|
||||
source: str = "portal",
|
||||
) -> dict[str, Any]:
|
||||
ctx = context_from_headers(user_id=user_id, role=role, group_id=group_id)
|
||||
return portal_ops_service.create_alert(ctx, level, message, scope, source)
|
||||
|
||||
|
||||
def list_portal_approvals(role: str, user_id: str = "anonymous", group_id: str | None = None) -> dict[str, Any]:
|
||||
ctx = context_from_headers(user_id=user_id, role=role, group_id=group_id)
|
||||
return portal_ops_service.list_approvals(ctx)
|
||||
|
||||
|
||||
def list_portal_audit_events(role: str, user_id: str = "anonymous", group_id: str | None = None) -> dict[str, Any]:
|
||||
ctx = context_from_headers(user_id=user_id, role=role, group_id=group_id)
|
||||
return portal_ops_service.list_audit(ctx)
|
||||
|
||||
|
||||
def run_portal_self_loop() -> dict[str, Any]:
|
||||
return portal_ops_service.run_self_loop()
|
||||
|
||||
|
||||
def build_router():
|
||||
if APIRouter is None:
|
||||
return None
|
||||
|
||||
router = APIRouter(prefix="/portal", tags=["portal"])
|
||||
|
||||
@router.get("/dashboard", operation_id="getPortalDashboard")
|
||||
def dashboard(
|
||||
x_user_id: str | None = Header(default=None),
|
||||
x_portal_role: str | None = Header(default=None),
|
||||
x_group_id: str | None = Header(default=None),
|
||||
):
|
||||
return get_portal_dashboard(x_portal_role or ROLE_USER, x_user_id or "anonymous", x_group_id)
|
||||
|
||||
@router.post("/rbac/check", operation_id="checkPortalRBAC")
|
||||
def rbac_check(payload: dict[str, Any]):
|
||||
return check_portal_rbac(
|
||||
role=payload.get("role", ROLE_USER),
|
||||
action=payload["action"],
|
||||
user_id=payload.get("user_id", "anonymous"),
|
||||
group_id=payload.get("group_id"),
|
||||
scope=payload.get("scope"),
|
||||
owner_user_id=payload.get("owner_user_id"),
|
||||
resource_group_id=payload.get("resource_group_id"),
|
||||
)
|
||||
|
||||
@router.post("/approvals", operation_id="createPortalApproval")
|
||||
def create_approval(payload: dict[str, Any]):
|
||||
return create_portal_approval(
|
||||
role=payload.get("role", ROLE_USER),
|
||||
request_type=payload["request_type"],
|
||||
user_id=payload.get("user_id", "anonymous"),
|
||||
group_id=payload.get("group_id"),
|
||||
payload=payload.get("payload"),
|
||||
target=payload.get("target"),
|
||||
)
|
||||
|
||||
@router.post("/approvals/{approval_id}/decision", operation_id="decidePortalApproval")
|
||||
def decide_approval(approval_id: str, payload: dict[str, Any]):
|
||||
return decide_portal_approval(
|
||||
approval_id=approval_id,
|
||||
decision=payload["decision"],
|
||||
role=payload.get("role", ROLE_GROUP_ADMIN),
|
||||
user_id=payload.get("user_id", "anonymous"),
|
||||
group_id=payload.get("group_id"),
|
||||
reason=payload.get("reason"),
|
||||
)
|
||||
|
||||
@router.get("/approvals", operation_id="listPortalApprovals")
|
||||
def approvals(
|
||||
x_user_id: str | None = Header(default=None),
|
||||
x_portal_role: str | None = Header(default=None),
|
||||
x_group_id: str | None = Header(default=None),
|
||||
):
|
||||
return list_portal_approvals(x_portal_role or ROLE_USER, x_user_id or "anonymous", x_group_id)
|
||||
|
||||
@router.get("/alerts", operation_id="listPortalAlerts")
|
||||
def alerts(
|
||||
x_user_id: str | None = Header(default=None),
|
||||
x_portal_role: str | None = Header(default=None),
|
||||
x_group_id: str | None = Header(default=None),
|
||||
):
|
||||
return list_portal_alerts(x_portal_role or ROLE_USER, x_user_id or "anonymous", x_group_id)
|
||||
|
||||
@router.post("/alerts", operation_id="createPortalAlert")
|
||||
def create_alert(payload: dict[str, Any]):
|
||||
return create_portal_alert(
|
||||
role=payload.get("role", ROLE_USER),
|
||||
level=payload["level"],
|
||||
message=payload["message"],
|
||||
user_id=payload.get("user_id", "anonymous"),
|
||||
group_id=payload.get("group_id"),
|
||||
scope=payload.get("scope"),
|
||||
source=payload.get("source", "portal"),
|
||||
)
|
||||
|
||||
@router.get("/audit", operation_id="listPortalAuditEvents")
|
||||
def audit(
|
||||
x_user_id: str | None = Header(default=None),
|
||||
x_portal_role: str | None = Header(default=None),
|
||||
x_group_id: str | None = Header(default=None),
|
||||
):
|
||||
return list_portal_audit_events(x_portal_role or ROLE_USER, x_user_id or "anonymous", x_group_id)
|
||||
|
||||
@router.get("/owner/system", operation_id="getOwnerSystemCockpit")
|
||||
def owner_system(x_user_id: str | None = Header(default=None)):
|
||||
return get_owner_system_cockpit(x_user_id or "owner")
|
||||
|
||||
@router.post("/self-loop", operation_id="runPortalSelfLoop")
|
||||
def self_loop():
|
||||
return run_portal_self_loop()
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"build_router",
|
||||
"context_from_headers",
|
||||
"get_portal_dashboard",
|
||||
"get_owner_system_cockpit",
|
||||
"check_portal_rbac",
|
||||
"create_portal_approval",
|
||||
"decide_portal_approval",
|
||||
"list_portal_alerts",
|
||||
"list_portal_approvals",
|
||||
"list_portal_audit_events",
|
||||
"run_portal_self_loop",
|
||||
]
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Production acceptance gate for self-loop promotion.
|
||||
|
||||
The gate verifies runtime, GitOps policy, portal readiness, and security rules
|
||||
before any promotion request can proceed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GateCheck:
|
||||
name: str
|
||||
passed: bool
|
||||
detail: str
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProductionGateResult:
|
||||
status: str
|
||||
checks: tuple[GateCheck, ...]
|
||||
owner_approval_required: bool
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"status": self.status,
|
||||
"checks": [c.to_dict() for c in self.checks],
|
||||
"owner_approval_required": self.owner_approval_required,
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
|
||||
class ProductionAcceptanceGate:
|
||||
def evaluate(
|
||||
self,
|
||||
runtime: dict[str, Any],
|
||||
baseline: dict[str, Any],
|
||||
acceptance: dict[str, Any],
|
||||
owner_approval_required: bool = True,
|
||||
) -> ProductionGateResult:
|
||||
checks = (
|
||||
GateCheck("runtime_active", runtime.get("status") == "active", "runtime status"),
|
||||
GateCheck("git_patch_only", baseline.get("update_mode") == "git_patch_only", "patch-only policy"),
|
||||
GateCheck("no_direct_main_push", baseline.get("codex_gitops", {}).get("direct_main_push") is False, "main push guard"),
|
||||
GateCheck("acceptance_passed", acceptance.get("status") in {"pass", "waiting_owner_gate"}, "acceptance pipeline"),
|
||||
GateCheck("no_secrets", baseline.get("codex_gitops", {}).get("api_keys_in_git") is False, "secret policy"),
|
||||
)
|
||||
passed = all(check.passed for check in checks)
|
||||
if not passed:
|
||||
status = "blocked"
|
||||
elif owner_approval_required:
|
||||
status = "waiting_owner_approval"
|
||||
else:
|
||||
status = "ready_for_promotion"
|
||||
return ProductionGateResult(status, checks, owner_approval_required)
|
||||
|
||||
|
||||
__all__ = ["GateCheck", "ProductionGateResult", "ProductionAcceptanceGate"]
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Agent adapters for AIOS Search Gateway.
|
||||
|
||||
All runtime agents should call SearchGatewayAgentAdapter instead of performing
|
||||
ad-hoc network search. This gives consistent provider fallback, profile
|
||||
resolution, evidence capture, audit and event visibility.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from .search_gateway_orchestrator import SearchGatewayOrchestrator
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentSearchContext:
|
||||
agent: str
|
||||
user_id: str | None = None
|
||||
group_id: str | None = None
|
||||
role: str = "user"
|
||||
group_profile: dict[str, Any] | None = None
|
||||
user_profile: dict[str, Any] | None = None
|
||||
session_profile: dict[str, Any] | None = None
|
||||
authorized_sources: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class SearchGatewayAgentAdapter:
|
||||
def __init__(self, orchestrator: SearchGatewayOrchestrator | None = None):
|
||||
self.orchestrator = orchestrator or SearchGatewayOrchestrator()
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
context: AgentSearchContext,
|
||||
intent: str = "general",
|
||||
region: str | None = None,
|
||||
time_range: str | None = None,
|
||||
data_type: str | None = None,
|
||||
domain_profiles: list[str] | None = None,
|
||||
user_scope: str = "public",
|
||||
correlation_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload = {
|
||||
"query": query,
|
||||
"requester": context.agent,
|
||||
"intent": intent,
|
||||
"region": region,
|
||||
"time_range": time_range,
|
||||
"data_type": data_type,
|
||||
"domain_profiles": domain_profiles or [],
|
||||
"user_scope": user_scope,
|
||||
"authorized_sources": context.authorized_sources,
|
||||
"group_profile": context.group_profile,
|
||||
"user_profile": context.user_profile,
|
||||
"session_profile": context.session_profile,
|
||||
"correlation_id": correlation_id,
|
||||
}
|
||||
return self.orchestrator.execute(payload)
|
||||
|
||||
|
||||
class OpenClawSearchClient(SearchGatewayAgentAdapter):
|
||||
def search_public(self, query: str, context: AgentSearchContext, **kwargs) -> dict[str, Any]:
|
||||
return self.search(query, context, user_scope="public", **kwargs)
|
||||
|
||||
|
||||
class HermesSearchClient(SearchGatewayAgentAdapter):
|
||||
def delegate_search(self, query: str, context: AgentSearchContext, **kwargs) -> dict[str, Any]:
|
||||
return self.search(query, context, **kwargs)
|
||||
|
||||
|
||||
class CodexSearchClient(SearchGatewayAgentAdapter):
|
||||
def technical_search(self, query: str, context: AgentSearchContext, **kwargs) -> dict[str, Any]:
|
||||
return self.search(query, context, intent=kwargs.pop("intent", "software_library"), **kwargs)
|
||||
|
||||
|
||||
class CustomGPTSearchClient(SearchGatewayAgentAdapter):
|
||||
def user_search(self, query: str, context: AgentSearchContext, **kwargs) -> dict[str, Any]:
|
||||
return self.search(query, context, **kwargs)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentSearchContext",
|
||||
"SearchGatewayAgentAdapter",
|
||||
"OpenClawSearchClient",
|
||||
"HermesSearchClient",
|
||||
"CodexSearchClient",
|
||||
"CustomGPTSearchClient",
|
||||
]
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Architect governance integration for Search Gateway.
|
||||
|
||||
Search Gateway is a shared platform capability. The Architect Agent periodically
|
||||
reviews provider health, external search/browser capabilities, cost, security and
|
||||
privacy impact, then emits C/R/V recommendations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchArchitectureFinding:
|
||||
finding_id: str
|
||||
title: str
|
||||
impact: str
|
||||
recommendation: str
|
||||
evidence: list[str] = field(default_factory=list)
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchArchitectureDecision:
|
||||
status: str
|
||||
version_bump: str
|
||||
reason: str
|
||||
findings: list[SearchArchitectureFinding]
|
||||
owner_gate_required: bool
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"status": self.status,
|
||||
"version_bump": self.version_bump,
|
||||
"reason": self.reason,
|
||||
"findings": [f.to_dict() for f in self.findings],
|
||||
"owner_gate_required": self.owner_gate_required,
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
|
||||
class SearchArchitectIntegration:
|
||||
def review(self, signals: dict[str, Any]) -> SearchArchitectureDecision:
|
||||
findings: list[SearchArchitectureFinding] = []
|
||||
if signals.get("provider_breaking_change"):
|
||||
findings.append(SearchArchitectureFinding("search-provider-breaking", "Provider breaking change", "breaking", "owner_review"))
|
||||
if signals.get("new_search_capability"):
|
||||
findings.append(SearchArchitectureFinding("search-new-capability", "New search capability available", "architecture", "evaluate_adoption"))
|
||||
if signals.get("cost_increase"):
|
||||
findings.append(SearchArchitectureFinding("search-cost", "Search provider cost increase", "minor", "optimize_provider_order"))
|
||||
if signals.get("security_advisory"):
|
||||
findings.append(SearchArchitectureFinding("search-security", "Search security advisory", "breaking", "owner_review"))
|
||||
|
||||
if any(f.impact == "breaking" for f in findings):
|
||||
return SearchArchitectureDecision("action_required", "V", "breaking search ecosystem/security change", findings, True)
|
||||
if any(f.impact == "architecture" for f in findings):
|
||||
return SearchArchitectureDecision("optimization_recommended", "R", "new search capability may improve architecture", findings, True)
|
||||
if findings:
|
||||
return SearchArchitectureDecision("minor_change_recommended", "C", "compatible provider optimization", findings, False)
|
||||
return SearchArchitectureDecision("no_action", "none", "no relevant search architecture change", findings, False)
|
||||
|
||||
|
||||
__all__ = ["SearchArchitectureFinding", "SearchArchitectureDecision", "SearchArchitectIntegration"]
|
||||
@@ -1,27 +0,0 @@
|
||||
"""Audit storage for Search Gateway executions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class SearchAuditStore:
|
||||
def __init__(self, root: str = "runtime/search-gateway/audit"):
|
||||
self.root = Path(root)
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def append(self, record: dict[str, Any]) -> str:
|
||||
path = self.root / f"{record.get('request_id', 'search')}.json"
|
||||
path.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
def list_recent(self) -> list[dict[str, Any]]:
|
||||
result = []
|
||||
for path in sorted(self.root.glob("*.json")):
|
||||
result.append(json.loads(path.read_text(encoding="utf-8")))
|
||||
return result[-50:]
|
||||
|
||||
|
||||
__all__ = ["SearchAuditStore"]
|
||||
@@ -1,118 +0,0 @@
|
||||
"""Search Gateway event bridge.
|
||||
|
||||
Publishes normalized search lifecycle events into the AIOS Agent Event Bus when a
|
||||
bus is available, with an in-memory fallback for tests and offline runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class SearchEventPublisher(Protocol):
|
||||
def publish(
|
||||
self,
|
||||
kind: str,
|
||||
source: str,
|
||||
target: str,
|
||||
summary: str,
|
||||
payload: dict[str, Any],
|
||||
correlation_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchEvent:
|
||||
id: str
|
||||
kind: str
|
||||
source: str
|
||||
target: str
|
||||
summary: str
|
||||
payload: dict[str, Any]
|
||||
correlation_id: str | None = None
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class InMemorySearchEventBus:
|
||||
def __init__(self):
|
||||
self.events: list[dict[str, Any]] = []
|
||||
|
||||
def publish(
|
||||
self,
|
||||
kind: str,
|
||||
source: str,
|
||||
target: str,
|
||||
summary: str,
|
||||
payload: dict[str, Any],
|
||||
correlation_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
event = SearchEvent(
|
||||
id=f"search_evt_{uuid4().hex[:12]}",
|
||||
kind=kind,
|
||||
source=source,
|
||||
target=target,
|
||||
summary=summary,
|
||||
payload=payload,
|
||||
correlation_id=correlation_id,
|
||||
).to_dict()
|
||||
self.events.append(event)
|
||||
return event
|
||||
|
||||
|
||||
class SearchEventBridge:
|
||||
def __init__(self, bus: SearchEventPublisher | None = None):
|
||||
self.bus = bus or InMemorySearchEventBus()
|
||||
|
||||
def search_started(self, request: dict[str, Any]) -> dict[str, Any]:
|
||||
return self.bus.publish(
|
||||
kind="search_started",
|
||||
source=request.get("requester", "unknown"),
|
||||
target="search-gateway",
|
||||
summary=request.get("query", "search started"),
|
||||
payload=request,
|
||||
correlation_id=request.get("correlation_id"),
|
||||
)
|
||||
|
||||
def provider_attempted(self, request_id: str, attempt: dict[str, Any]) -> dict[str, Any]:
|
||||
return self.bus.publish(
|
||||
kind="search_provider_attempted",
|
||||
source="search-gateway",
|
||||
target=attempt.get("provider", "unknown"),
|
||||
summary=f"provider {attempt.get('provider', 'unknown')} {attempt.get('status', 'attempted')}",
|
||||
payload=attempt,
|
||||
correlation_id=request_id,
|
||||
)
|
||||
|
||||
def fallback_triggered(self, request_id: str, reason: str) -> dict[str, Any]:
|
||||
return self.bus.publish(
|
||||
kind="search_fallback_triggered",
|
||||
source="search-gateway",
|
||||
target="search-provider-router",
|
||||
summary=reason,
|
||||
payload={"reason": reason},
|
||||
correlation_id=request_id,
|
||||
)
|
||||
|
||||
def search_completed(self, response: dict[str, Any]) -> dict[str, Any]:
|
||||
return self.bus.publish(
|
||||
kind="search_completed",
|
||||
source="search-gateway",
|
||||
target=response.get("requester", "requester"),
|
||||
summary=f"search {response.get('status', 'completed')}",
|
||||
payload=response,
|
||||
correlation_id=response.get("request_id"),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["SearchEvent", "InMemorySearchEventBus", "SearchEventBridge"]
|
||||
@@ -1,252 +0,0 @@
|
||||
"""Governed general-purpose Search Gateway for AIOS.
|
||||
|
||||
The gateway coordinates query planning, provider routing, fallback, evidence
|
||||
normalization and compliance checks. It is provider-agnostic: runtime workers plug
|
||||
in public or explicitly authorized search providers.
|
||||
|
||||
Search is a shared capability. Groups and users may customize provider order,
|
||||
trusted domains, query expansion hints and domain profiles, but customization may
|
||||
not override hard authorization, privacy, safety or audit rules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from .search_profile_resolver import DomainQueryProfileRegistry, SearchProfile, SearchProfileResolver
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchRequest:
|
||||
query: str
|
||||
requester: str
|
||||
intent: str = "general"
|
||||
region: str | None = None
|
||||
time_range: str | None = None
|
||||
data_type: str | None = None
|
||||
user_scope: str = "public"
|
||||
authorized_sources: list[str] = field(default_factory=list)
|
||||
domain_profiles: list[str] = field(default_factory=list)
|
||||
group_profile: dict[str, Any] | None = None
|
||||
user_profile: dict[str, Any] | None = None
|
||||
session_profile: dict[str, Any] | None = None
|
||||
correlation_id: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchEvidence:
|
||||
title: str
|
||||
url: str | None
|
||||
source: str
|
||||
snippet: str
|
||||
evidence_type: str = "web"
|
||||
confidence: str = "medium"
|
||||
retrieved_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchProviderAttempt:
|
||||
provider: str
|
||||
status: str
|
||||
result_count: int = 0
|
||||
reason: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchResponse:
|
||||
request_id: str
|
||||
status: str
|
||||
answer: str
|
||||
evidence: list[SearchEvidence]
|
||||
attempts: list[SearchProviderAttempt]
|
||||
confidence: str
|
||||
expanded_queries: list[str] = field(default_factory=list)
|
||||
resolved_profile: dict[str, Any] | None = None
|
||||
need_clarification: list[str] = field(default_factory=list)
|
||||
blocked_reasons: list[str] = field(default_factory=list)
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"request_id": self.request_id,
|
||||
"status": self.status,
|
||||
"answer": self.answer,
|
||||
"evidence": [e.to_dict() for e in self.evidence],
|
||||
"attempts": [a.to_dict() for a in self.attempts],
|
||||
"confidence": self.confidence,
|
||||
"expanded_queries": self.expanded_queries,
|
||||
"resolved_profile": self.resolved_profile,
|
||||
"need_clarification": self.need_clarification,
|
||||
"blocked_reasons": self.blocked_reasons,
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
|
||||
class SearchProvider(Protocol):
|
||||
name: str
|
||||
|
||||
def search(self, request: SearchRequest, queries: list[str]) -> tuple[list[SearchEvidence], SearchProviderAttempt]:
|
||||
...
|
||||
|
||||
|
||||
class QueryPlanner:
|
||||
def __init__(self, domain_registry: DomainQueryProfileRegistry | None = None):
|
||||
self.domain_registry = domain_registry or DomainQueryProfileRegistry()
|
||||
|
||||
def expand(self, request: SearchRequest, profile: SearchProfile) -> list[str]:
|
||||
base = request.query.strip()
|
||||
queries = [base]
|
||||
if request.region:
|
||||
queries.append(f"{request.region} {base}")
|
||||
if request.time_range:
|
||||
queries.append(f"{base} {request.time_range}")
|
||||
domain_profiles = list(dict.fromkeys([request.intent] + request.domain_profiles + profile.domain_profiles))
|
||||
hints = list(profile.query_expansion_hints) + self.domain_registry.hints_for(domain_profiles)
|
||||
for hint in hints:
|
||||
queries.append(f"{base} {hint}")
|
||||
for domain in profile.trusted_domains:
|
||||
queries.append(f"site:{domain} {base}")
|
||||
return list(dict.fromkeys([q for q in queries if q]))
|
||||
|
||||
|
||||
class SearchPolicyGuard:
|
||||
"""Blocks non-public or unauthorized collection patterns."""
|
||||
|
||||
FORBIDDEN_TERMS = (
|
||||
"绕过登录",
|
||||
"绕过验证码",
|
||||
"验证码绕过",
|
||||
"破解登录",
|
||||
"个人成绩",
|
||||
"学生隐私",
|
||||
"受保护平台批量爬取",
|
||||
)
|
||||
|
||||
def evaluate(self, request: SearchRequest, profile: SearchProfile) -> list[str]:
|
||||
q = request.query.lower()
|
||||
reasons: list[str] = []
|
||||
for term in self.FORBIDDEN_TERMS:
|
||||
if term.lower() in q:
|
||||
reasons.append(f"blocked_term:{term}")
|
||||
if request.user_scope not in {"public", "authorized_user_file", "authorized_link", "internal_archive"}:
|
||||
reasons.append(f"unsupported_scope:{request.user_scope}")
|
||||
if request.data_type in {"personal_grade", "student_private_data", "private_person_data"} and request.user_scope != "authorized_user_file":
|
||||
reasons.append("private_data_requires_user_authorized_file")
|
||||
if not profile.audit_required:
|
||||
reasons.append("audit_cannot_be_disabled")
|
||||
if not profile.fallback_required:
|
||||
reasons.append("fallback_cannot_be_disabled")
|
||||
return reasons
|
||||
|
||||
|
||||
class SearchGateway:
|
||||
def __init__(self, providers: list[SearchProvider] | None = None):
|
||||
self.providers = providers or []
|
||||
self.profile_resolver = SearchProfileResolver()
|
||||
self.planner = QueryPlanner()
|
||||
self.guard = SearchPolicyGuard()
|
||||
|
||||
def search(self, request: SearchRequest) -> SearchResponse:
|
||||
request_id = request.correlation_id or f"search_{uuid4().hex[:12]}"
|
||||
profile = self.profile_resolver.resolve(request.group_profile, request.user_profile, request.session_profile)
|
||||
expanded_queries = self.planner.expand(request, profile)
|
||||
blocked = self.guard.evaluate(request, profile)
|
||||
if blocked:
|
||||
return SearchResponse(
|
||||
request_id=request_id,
|
||||
status="blocked",
|
||||
answer="该搜索请求涉及未授权访问、验证码/登录绕过、受保护平台批量抓取或个人隐私数据,已阻断。可改为搜索公开网页、官方公告、公开文档,或处理用户授权上传的文件/链接。",
|
||||
evidence=[],
|
||||
attempts=[],
|
||||
confidence="high",
|
||||
expanded_queries=expanded_queries,
|
||||
resolved_profile=profile.to_dict(),
|
||||
blocked_reasons=blocked,
|
||||
)
|
||||
|
||||
evidence: list[SearchEvidence] = []
|
||||
attempts: list[SearchProviderAttempt] = []
|
||||
providers = self._ordered_providers(profile)
|
||||
for provider in providers:
|
||||
try:
|
||||
provider_evidence, attempt = provider.search(request, expanded_queries)
|
||||
evidence.extend(provider_evidence)
|
||||
attempts.append(attempt)
|
||||
if provider_evidence:
|
||||
break
|
||||
except Exception as exc:
|
||||
attempts.append(SearchProviderAttempt(provider=getattr(provider, "name", "unknown"), status="failed", reason=str(exc)))
|
||||
|
||||
if evidence:
|
||||
return SearchResponse(
|
||||
request_id=request_id,
|
||||
status="found",
|
||||
answer="已找到公开或授权来源结果。",
|
||||
evidence=evidence,
|
||||
attempts=attempts,
|
||||
confidence=self._confidence(evidence),
|
||||
expanded_queries=expanded_queries,
|
||||
resolved_profile=profile.to_dict(),
|
||||
)
|
||||
|
||||
return SearchResponse(
|
||||
request_id=request_id,
|
||||
status="not_found",
|
||||
answer="未找到足够可靠的公开或授权结果;已记录 provider 尝试和失败原因。",
|
||||
evidence=[],
|
||||
attempts=attempts,
|
||||
confidence="low",
|
||||
expanded_queries=expanded_queries,
|
||||
resolved_profile=profile.to_dict(),
|
||||
need_clarification=self._clarification(request),
|
||||
)
|
||||
|
||||
def _ordered_providers(self, profile: SearchProfile) -> list[SearchProvider]:
|
||||
if not self.providers:
|
||||
return []
|
||||
by_name = {provider.name: provider for provider in self.providers}
|
||||
ordered = [by_name[name] for name in profile.provider_order if name in by_name]
|
||||
remaining = [provider for provider in self.providers if provider.name not in {p.name for p in ordered}]
|
||||
return ordered + remaining
|
||||
|
||||
def _confidence(self, evidence: list[SearchEvidence]) -> str:
|
||||
if any(e.confidence == "high" for e in evidence):
|
||||
return "high"
|
||||
if evidence:
|
||||
return "medium"
|
||||
return "low"
|
||||
|
||||
def _clarification(self, request: SearchRequest) -> list[str]:
|
||||
if request.intent == "education_exam_data":
|
||||
return ["请补充学校名称、联考名称、年级、科目,以及需要试卷/答案还是成绩统计。"]
|
||||
if request.intent in {"company_research", "policy_regulation", "software_library", "product_research"}:
|
||||
return ["请补充主体名称、地区/版本、时间范围、需要的数据类型或可信来源范围。"]
|
||||
return ["请补充更具体的主体、平台、地区、时间、数据类型或授权文件/链接。"]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SearchRequest",
|
||||
"SearchEvidence",
|
||||
"SearchProviderAttempt",
|
||||
"SearchResponse",
|
||||
"SearchProvider",
|
||||
"QueryPlanner",
|
||||
"SearchPolicyGuard",
|
||||
"SearchGateway",
|
||||
]
|
||||
@@ -1,65 +0,0 @@
|
||||
"""API endpoints for AIOS Search Gateway."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
|
||||
from .search_gateway import SearchGateway, SearchRequest
|
||||
from .search_gateway_dashboard import SearchGatewayDashboard
|
||||
|
||||
|
||||
gateway = SearchGateway()
|
||||
dashboard = SearchGatewayDashboard()
|
||||
|
||||
|
||||
def search_gateway_request(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
request = SearchRequest(**payload)
|
||||
return gateway.search(request).to_dict()
|
||||
|
||||
|
||||
def get_search_gateway_status() -> dict[str, Any]:
|
||||
return {
|
||||
"module": "search-gateway",
|
||||
"status": "ready",
|
||||
"providers_configured": len(gateway.providers),
|
||||
"policy_guard": "enabled",
|
||||
"profile_customization": "enabled",
|
||||
"fallback_required_when_provider_unavailable": True,
|
||||
}
|
||||
|
||||
|
||||
def get_search_gateway_overview() -> dict[str, Any]:
|
||||
return dashboard.overview()
|
||||
|
||||
|
||||
def build_router():
|
||||
if APIRouter is None:
|
||||
return None
|
||||
router = APIRouter(prefix="/admin/ai-evolution/search", tags=["search-gateway"])
|
||||
|
||||
@router.get("/status", operation_id="getSearchGatewayStatus")
|
||||
def status():
|
||||
return get_search_gateway_status()
|
||||
|
||||
@router.get("/overview", operation_id="getSearchGatewayOverview")
|
||||
def overview():
|
||||
return get_search_gateway_overview()
|
||||
|
||||
@router.post("/query", operation_id="runSearchGatewayQuery")
|
||||
def query(payload: dict[str, Any]):
|
||||
return search_gateway_request(payload)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"search_gateway_request",
|
||||
"get_search_gateway_status",
|
||||
"get_search_gateway_overview",
|
||||
]
|
||||
@@ -1,85 +0,0 @@
|
||||
"""Search Gateway Web UI dashboard model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchDashboardMetric:
|
||||
key: str
|
||||
title: str
|
||||
value: Any
|
||||
status: str = "ok"
|
||||
detail: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchDashboardPanel:
|
||||
key: str
|
||||
title: str
|
||||
metrics: list[SearchDashboardMetric] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"key": self.key,
|
||||
"title": self.title,
|
||||
"metrics": [m.to_dict() for m in self.metrics],
|
||||
}
|
||||
|
||||
|
||||
class SearchGatewayDashboard:
|
||||
def overview(self, search_runs: list[dict[str, Any]] | None = None, providers: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
search_runs = search_runs or []
|
||||
providers = providers or []
|
||||
blocked = [r for r in search_runs if r.get("status") == "blocked"]
|
||||
not_found = [r for r in search_runs if r.get("status") == "not_found"]
|
||||
provider_failures = [p for p in providers if p.get("status") == "failed"]
|
||||
panels = [
|
||||
SearchDashboardPanel(
|
||||
"search_health",
|
||||
"搜索健康度",
|
||||
[
|
||||
SearchDashboardMetric("total_runs", "搜索请求数", len(search_runs)),
|
||||
SearchDashboardMetric("not_found", "未找到结果", len(not_found), "warn" if not_found else "ok"),
|
||||
SearchDashboardMetric("blocked", "合规阻断", len(blocked), "warn" if blocked else "ok"),
|
||||
],
|
||||
),
|
||||
SearchDashboardPanel(
|
||||
"provider_health",
|
||||
"Provider 可用性",
|
||||
[
|
||||
SearchDashboardMetric("providers", "Provider 数量", len(providers)),
|
||||
SearchDashboardMetric("provider_failures", "Provider 失败", len(provider_failures), "warn" if provider_failures else "ok"),
|
||||
],
|
||||
),
|
||||
SearchDashboardPanel(
|
||||
"evidence_quality",
|
||||
"证据质量",
|
||||
[
|
||||
SearchDashboardMetric("with_evidence", "有证据的回答", len([r for r in search_runs if r.get("evidence")])) ,
|
||||
SearchDashboardMetric("fallback_required", "要求多源降级", True),
|
||||
],
|
||||
),
|
||||
]
|
||||
return {
|
||||
"module": "search-gateway",
|
||||
"route": "/admin/ai-evolution/search",
|
||||
"title": "AIOS 联网搜索治理中心",
|
||||
"status": "attention_required" if provider_failures else "healthy",
|
||||
"generated_at": utc_now(),
|
||||
"panels": [p.to_dict() for p in panels],
|
||||
"recent_runs": search_runs[-20:],
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["SearchDashboardMetric", "SearchDashboardPanel", "SearchGatewayDashboard"]
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Runtime orchestrator joining Search Gateway, registry, audit and events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .search_audit_store import SearchAuditStore
|
||||
from .search_event_bridge import SearchEventBridge
|
||||
from .search_gateway import SearchGateway, SearchRequest
|
||||
from .search_provider_registry import SearchProviderRegistry
|
||||
|
||||
|
||||
class SearchGatewayOrchestrator:
|
||||
def __init__(self):
|
||||
self.registry = SearchProviderRegistry()
|
||||
self.audit = SearchAuditStore()
|
||||
self.events = SearchEventBridge()
|
||||
self.gateway = SearchGateway(self.registry.ordered())
|
||||
|
||||
def execute(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
request = SearchRequest(**payload)
|
||||
self.events.search_started(request.to_dict())
|
||||
response = self.gateway.search(request)
|
||||
result = response.to_dict()
|
||||
for attempt in result.get("attempts", []):
|
||||
self.events.provider_attempted(result["request_id"], attempt)
|
||||
if len(result.get("attempts", [])) > 1:
|
||||
self.events.fallback_triggered(result["request_id"], "multiple providers attempted")
|
||||
self.audit.append(result)
|
||||
self.events.search_completed(result)
|
||||
return result
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
return {
|
||||
"registry": self.registry.status(),
|
||||
"audit": "enabled",
|
||||
"events": "enabled",
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["SearchGatewayOrchestrator"]
|
||||
@@ -1,124 +0,0 @@
|
||||
"""Search profile resolver for general-purpose AIOS Search Gateway.
|
||||
|
||||
The Search Gateway is a shared platform capability. Groups and users may customize
|
||||
provider preference, query expansion hints, trusted sources and domain profiles on
|
||||
top of the shared base profile. Customization is intentionally layered and cannot
|
||||
override hard safety, authorization or audit rules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
HARD_BLOCKED_BEHAVIORS = {
|
||||
"bypass_login",
|
||||
"bypass_captcha",
|
||||
"scrape_private_student_grades",
|
||||
"scrape_student_private_data",
|
||||
"bulk_scrape_protected_platforms",
|
||||
"collect_data_without_authorization",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchProfile:
|
||||
profile_id: str
|
||||
scope: str
|
||||
provider_order: list[str] = field(default_factory=lambda: [
|
||||
"cache",
|
||||
"official_site",
|
||||
"general_web",
|
||||
"vertical_source",
|
||||
"browser_fetch",
|
||||
"user_authorized_file",
|
||||
"internal_archive",
|
||||
])
|
||||
trusted_domains: list[str] = field(default_factory=list)
|
||||
query_expansion_hints: list[str] = field(default_factory=list)
|
||||
domain_profiles: list[str] = field(default_factory=list)
|
||||
blocked_behaviors: set[str] = field(default_factory=lambda: set(HARD_BLOCKED_BEHAVIORS))
|
||||
audit_required: bool = True
|
||||
fallback_required: bool = True
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = asdict(self)
|
||||
data["blocked_behaviors"] = sorted(self.blocked_behaviors)
|
||||
return data
|
||||
|
||||
|
||||
class SearchProfileResolver:
|
||||
"""Resolves base -> group -> user -> session search preferences."""
|
||||
|
||||
def base_profile(self) -> SearchProfile:
|
||||
return SearchProfile(profile_id="base", scope="global")
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
group_profile: dict[str, Any] | None = None,
|
||||
user_profile: dict[str, Any] | None = None,
|
||||
session_profile: dict[str, Any] | None = None,
|
||||
) -> SearchProfile:
|
||||
profile = self.base_profile().to_dict()
|
||||
for overlay in (group_profile, user_profile, session_profile):
|
||||
if overlay:
|
||||
profile = self._merge(profile, overlay)
|
||||
profile["blocked_behaviors"] = set(HARD_BLOCKED_BEHAVIORS)
|
||||
profile["audit_required"] = True
|
||||
profile["fallback_required"] = True
|
||||
return SearchProfile(**profile)
|
||||
|
||||
def _merge(self, base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
|
||||
merged = dict(base)
|
||||
if overlay.get("provider_order"):
|
||||
merged["provider_order"] = list(overlay["provider_order"])
|
||||
for list_key in ("trusted_domains", "query_expansion_hints", "domain_profiles"):
|
||||
merged[list_key] = self._dedupe(list(merged.get(list_key, [])) + list(overlay.get(list_key, [])))
|
||||
merged["profile_id"] = overlay.get("profile_id", merged.get("profile_id", "custom"))
|
||||
merged["scope"] = overlay.get("scope", merged.get("scope", "custom"))
|
||||
return merged
|
||||
|
||||
def _dedupe(self, items: list[str]) -> list[str]:
|
||||
return list(dict.fromkeys([item for item in items if item]))
|
||||
|
||||
|
||||
class DomainQueryProfileRegistry:
|
||||
"""Optional domain-specific expansions for the general Search Gateway."""
|
||||
|
||||
DEFAULT_PROFILES = {
|
||||
"education_exam_data": [
|
||||
"学校 年级 科目 试卷 答案 成绩统计",
|
||||
"联考 月考 期中 期末 质量检测",
|
||||
],
|
||||
"policy_regulation": [
|
||||
"官方 通知 政策 解读 实施细则",
|
||||
"site:gov.cn OR site:edu.cn",
|
||||
],
|
||||
"software_library": [
|
||||
"release notes changelog migration guide",
|
||||
"official documentation GitHub issues",
|
||||
],
|
||||
"product_research": [
|
||||
"specs review comparison price availability",
|
||||
"official page manual support",
|
||||
],
|
||||
"company_research": [
|
||||
"official announcement press release filings",
|
||||
"leadership product financial report",
|
||||
],
|
||||
}
|
||||
|
||||
def hints_for(self, domain_profiles: list[str]) -> list[str]:
|
||||
hints: list[str] = []
|
||||
for profile in domain_profiles:
|
||||
hints.extend(self.DEFAULT_PROFILES.get(profile, []))
|
||||
return list(dict.fromkeys(hints))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"HARD_BLOCKED_BEHAVIORS",
|
||||
"SearchProfile",
|
||||
"SearchProfileResolver",
|
||||
"DomainQueryProfileRegistry",
|
||||
]
|
||||
@@ -1,121 +0,0 @@
|
||||
"""Provider registry for the general AIOS Search Gateway.
|
||||
|
||||
The registry makes provider availability explicit and auditable. Provider
|
||||
implementations are pluggable: production can register real public search,
|
||||
official-site, vertical-source, browser-fetch, authorized-file or archive
|
||||
providers without changing the Search Gateway core.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from .search_gateway import SearchEvidence, SearchProviderAttempt, SearchRequest
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchProviderConfig:
|
||||
name: str
|
||||
provider_type: str
|
||||
enabled: bool = True
|
||||
requires_secret: bool = False
|
||||
supports_public_web: bool = True
|
||||
supports_authorized_sources: bool = False
|
||||
health: str = "unknown"
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class NullSearchProvider:
|
||||
"""Safe placeholder provider used until runtime wires a real adapter."""
|
||||
|
||||
def __init__(self, config: SearchProviderConfig):
|
||||
self.config = config
|
||||
self.name = config.name
|
||||
|
||||
def search(self, request: SearchRequest, queries: list[str]):
|
||||
return [], SearchProviderAttempt(
|
||||
provider=self.name,
|
||||
status="not_configured" if self.config.enabled else "disabled",
|
||||
result_count=0,
|
||||
reason="provider adapter not wired in runtime",
|
||||
)
|
||||
|
||||
|
||||
class StaticArchiveProvider:
|
||||
"""Small deterministic provider for tests and internal archived evidence."""
|
||||
|
||||
name = "internal_archive"
|
||||
|
||||
def __init__(self, records: list[dict[str, Any]] | None = None):
|
||||
self.records = records or []
|
||||
|
||||
def search(self, request: SearchRequest, queries: list[str]):
|
||||
evidence: list[SearchEvidence] = []
|
||||
for record in self.records:
|
||||
haystack = " ".join([record.get("title", ""), record.get("snippet", ""), record.get("tags", "")]).lower()
|
||||
if any(q.lower() in haystack for q in queries):
|
||||
evidence.append(
|
||||
SearchEvidence(
|
||||
title=record.get("title", "archive result"),
|
||||
url=record.get("url"),
|
||||
source=self.name,
|
||||
snippet=record.get("snippet", ""),
|
||||
evidence_type="internal_archive",
|
||||
confidence=record.get("confidence", "medium"),
|
||||
)
|
||||
)
|
||||
return evidence, SearchProviderAttempt(provider=self.name, status="ok", result_count=len(evidence))
|
||||
|
||||
|
||||
class SearchProviderRegistry:
|
||||
DEFAULT_PROVIDER_CONFIGS = [
|
||||
SearchProviderConfig("cache", "cache", supports_public_web=False),
|
||||
SearchProviderConfig("official_site", "official_site"),
|
||||
SearchProviderConfig("general_web", "general_web"),
|
||||
SearchProviderConfig("vertical_source", "vertical_source"),
|
||||
SearchProviderConfig("browser_fetch", "browser_fetch"),
|
||||
SearchProviderConfig("user_authorized_file", "authorized_file", supports_public_web=False, supports_authorized_sources=True),
|
||||
SearchProviderConfig("internal_archive", "archive", supports_public_web=False),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
self.configs: dict[str, SearchProviderConfig] = {}
|
||||
self.providers: dict[str, Any] = {}
|
||||
for config in self.DEFAULT_PROVIDER_CONFIGS:
|
||||
self.register_config(config)
|
||||
|
||||
def register_config(self, config: SearchProviderConfig) -> None:
|
||||
self.configs[config.name] = config
|
||||
self.providers.setdefault(config.name, NullSearchProvider(config))
|
||||
|
||||
def register_provider(self, name: str, provider: Any, config: SearchProviderConfig | None = None) -> None:
|
||||
if config:
|
||||
self.configs[name] = config
|
||||
elif name not in self.configs:
|
||||
self.configs[name] = SearchProviderConfig(name=name, provider_type="custom")
|
||||
self.providers[name] = provider
|
||||
|
||||
def ordered(self, order: list[str] | None = None) -> list[Any]:
|
||||
order = order or list(self.configs)
|
||||
result = [self.providers[name] for name in order if name in self.providers and self.configs[name].enabled]
|
||||
result.extend([provider for name, provider in self.providers.items() if name not in order and self.configs[name].enabled])
|
||||
return result
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
return {
|
||||
"providers": [self.configs[name].to_dict() for name in sorted(self.configs)],
|
||||
"enabled": len([c for c in self.configs.values() if c.enabled]),
|
||||
"configured": len([name for name, provider in self.providers.items() if not isinstance(provider, NullSearchProvider)]),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SearchProviderConfig",
|
||||
"NullSearchProvider",
|
||||
"StaticArchiveProvider",
|
||||
"SearchProviderRegistry",
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Self-evolution reporting for release and owner review."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvolutionReport:
|
||||
status: str
|
||||
improvements: list[str]
|
||||
risks: list[str]
|
||||
metrics: dict[str, Any]
|
||||
recommendations: list[str]
|
||||
created_at: str = utc_now()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class SelfEvolutionReporter:
|
||||
def generate(
|
||||
self,
|
||||
supervisor: dict[str, Any],
|
||||
failure_memory: dict[str, Any],
|
||||
adaptive_policy: dict[str, Any],
|
||||
) -> EvolutionReport:
|
||||
failures = failure_memory.get("top_failures", [])
|
||||
risks = []
|
||||
if failures:
|
||||
risks.append("recurring failures require owner-visible review")
|
||||
if adaptive_policy.get("automation_level") == "analysis_only":
|
||||
risks.append("automation reduced due to recurrence")
|
||||
return EvolutionReport(
|
||||
status="ready_for_review",
|
||||
improvements=[
|
||||
"self-loop supervisor enabled",
|
||||
"failure memory accumulated",
|
||||
"adaptive policy active",
|
||||
],
|
||||
risks=risks,
|
||||
metrics={
|
||||
"failure_records": len(failure_memory.get("records", {})),
|
||||
"supervisor_cycles": supervisor.get("state", {}).get("cycles", 0),
|
||||
},
|
||||
recommendations=[
|
||||
"continue low-risk autonomous patches",
|
||||
"review owner-gated promotion requests",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["EvolutionReport", "SelfEvolutionReporter"]
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Event hooks that trigger autonomous self-loop jobs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from .adaptive_policy import AdaptivePolicy
|
||||
from .failure_memory import FailureMemory
|
||||
from .self_loop_scheduler import SelfLoopScheduler
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SelfLoopEvent:
|
||||
source: str
|
||||
event_type: str
|
||||
goal: str
|
||||
runtime: dict[str, Any] = field(default_factory=dict)
|
||||
baseline: dict[str, Any] = field(default_factory=dict)
|
||||
detail: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class SelfLoopEventHook:
|
||||
SUPPORTED_EVENTS = {
|
||||
"runtime_unhealthy",
|
||||
"gitops_drift",
|
||||
"test_failed",
|
||||
"portal_gap_detected",
|
||||
"acceptance_failed",
|
||||
}
|
||||
|
||||
def __init__(self, scheduler: SelfLoopScheduler | None = None, memory: FailureMemory | None = None, policy: AdaptivePolicy | None = None):
|
||||
self.scheduler = scheduler or SelfLoopScheduler()
|
||||
self.memory = memory or FailureMemory()
|
||||
self.policy = policy or AdaptivePolicy()
|
||||
|
||||
def parse(self, payload: dict[str, Any]) -> SelfLoopEvent:
|
||||
return SelfLoopEvent(
|
||||
source=str(payload.get("source", "unknown")),
|
||||
event_type=str(payload.get("event_type", "unknown")),
|
||||
goal=str(payload.get("goal", "investigate self-loop event")),
|
||||
runtime=dict(payload.get("runtime", {})),
|
||||
baseline=dict(payload.get("baseline", {})),
|
||||
detail=str(payload.get("detail", "")),
|
||||
)
|
||||
|
||||
def handle(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
event = self.parse(payload)
|
||||
if event.event_type not in self.SUPPORTED_EVENTS:
|
||||
return {
|
||||
"status": "ignored",
|
||||
"reason": "unsupported_event",
|
||||
"event": event.to_dict(),
|
||||
}
|
||||
|
||||
if event.event_type in {"test_failed", "acceptance_failed", "runtime_unhealthy"}:
|
||||
self.memory.record_failure(
|
||||
component=event.source,
|
||||
reason=event.event_type,
|
||||
detail=event.detail,
|
||||
severity="high",
|
||||
remediation_hint="run self-loop diagnosis and generate patch plan",
|
||||
)
|
||||
|
||||
decision = self.policy.evaluate(self.memory, event.runtime, event.baseline, event.goal)
|
||||
options = self.policy.apply_to_scheduler(decision)
|
||||
job = self.scheduler.enqueue(
|
||||
goal=event.goal,
|
||||
runtime=event.runtime,
|
||||
baseline=event.baseline,
|
||||
max_attempts=options["max_attempts"],
|
||||
)
|
||||
return {
|
||||
"status": "queued",
|
||||
"event": event.to_dict(),
|
||||
"policy": decision.to_dict(),
|
||||
"job": job.to_dict(),
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["SelfLoopEvent", "SelfLoopEventHook"]
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Autonomous self-loop executor.
|
||||
|
||||
This layer turns probe/plan reports into safe execution outcomes. It does not
|
||||
bypass owner governance: high-risk or restricted actions are blocked at the owner
|
||||
approval gate and emitted as explicit pending work.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
from .autonomous_self_loop import AutonomousSelfLoop, RemediationStep, SelfLoopReport
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionResult:
|
||||
step_id: str
|
||||
action: str
|
||||
status: str
|
||||
reason: str
|
||||
risk: str
|
||||
requires_owner_approval: bool
|
||||
output: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestResult:
|
||||
name: str
|
||||
status: str
|
||||
detail: str = ""
|
||||
|
||||
def passed(self) -> bool:
|
||||
return self.status == "pass"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionReport:
|
||||
status: str
|
||||
report_id: str
|
||||
actions: list[ExecutionResult]
|
||||
tests: list[TestResult]
|
||||
owner_approval_required: bool
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"status": self.status,
|
||||
"report_id": self.report_id,
|
||||
"actions": [a.to_dict() for a in self.actions],
|
||||
"tests": [t.to_dict() for t in self.tests],
|
||||
"owner_approval_required": self.owner_approval_required,
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
|
||||
class SelfLoopTestRunner:
|
||||
"""Deterministic self-loop checks that avoid shell execution and secrets."""
|
||||
|
||||
def run(self, runtime: dict[str, Any], baseline: dict[str, Any]) -> list[TestResult]:
|
||||
return [
|
||||
self._runtime_active(runtime),
|
||||
self._patch_only_policy(baseline),
|
||||
self._owner_gate_policy(baseline),
|
||||
self._portal_domain_smoke(),
|
||||
]
|
||||
|
||||
def _runtime_active(self, runtime: dict[str, Any]) -> TestResult:
|
||||
if runtime.get("status") == "active":
|
||||
return TestResult("runtime_active", "pass", "runtime reports active")
|
||||
return TestResult("runtime_active", "fail", "runtime is not active")
|
||||
|
||||
def _patch_only_policy(self, baseline: dict[str, Any]) -> TestResult:
|
||||
if baseline.get("update_mode") == "git_patch_only":
|
||||
return TestResult("git_patch_only", "pass", "patch-only updates enforced")
|
||||
return TestResult("git_patch_only", "fail", "update mode is not git_patch_only")
|
||||
|
||||
def _owner_gate_policy(self, baseline: dict[str, Any]) -> TestResult:
|
||||
codex_gitops = baseline.get("codex_gitops", {})
|
||||
if codex_gitops.get("direct_main_push") is False:
|
||||
return TestResult("owner_gate", "pass", "direct main push disabled")
|
||||
return TestResult("owner_gate", "fail", "direct main push must remain disabled")
|
||||
|
||||
def _portal_domain_smoke(self) -> TestResult:
|
||||
try:
|
||||
from .portal_rbac_ops import ROLE_OWNER, PortalOpsService, build_context
|
||||
|
||||
service = PortalOpsService()
|
||||
result = service.owner_cockpit(build_context("owner", ROLE_OWNER))
|
||||
if result.get("status") == "ok":
|
||||
return TestResult("portal_domain_smoke", "pass", "owner cockpit domain service responds")
|
||||
return TestResult("portal_domain_smoke", "fail", "owner cockpit returned non-ok")
|
||||
except Exception as exc: # pragma: no cover - defensive smoke output
|
||||
return TestResult("portal_domain_smoke", "fail", f"portal smoke failed: {type(exc).__name__}")
|
||||
|
||||
|
||||
class SelfLoopAgentExecutor:
|
||||
"""Executes safe self-loop steps and gates restricted ones."""
|
||||
|
||||
SAFE_ACTIONS = {
|
||||
"health_probe",
|
||||
"test_execution",
|
||||
"failure_classification",
|
||||
"patch_plan_generation",
|
||||
"report_generation",
|
||||
"create-low-risk-patch",
|
||||
}
|
||||
|
||||
RESTRICTED_ACTIONS = {
|
||||
"request-runtime-recovery",
|
||||
"commit-policy-patch",
|
||||
"commit-security-policy",
|
||||
"commit-runtime-config",
|
||||
"commit-portal-config",
|
||||
"production-promotion",
|
||||
"permission-escalation",
|
||||
"secret-rotation",
|
||||
}
|
||||
|
||||
def __init__(self, loop: AutonomousSelfLoop | None = None, test_runner: SelfLoopTestRunner | None = None):
|
||||
self.loop = loop or AutonomousSelfLoop(owner_approval_required=True)
|
||||
self.test_runner = test_runner or SelfLoopTestRunner()
|
||||
self.handlers: dict[str, Callable[[RemediationStep], dict[str, Any]]] = {
|
||||
"create-low-risk-patch": self._create_low_risk_patch_manifest,
|
||||
"patch_plan_generation": self._patch_plan_manifest,
|
||||
"report_generation": self._report_manifest,
|
||||
}
|
||||
|
||||
def execute_step(self, step: RemediationStep) -> ExecutionResult:
|
||||
if step.requires_owner_approval or step.action in self.RESTRICTED_ACTIONS:
|
||||
return ExecutionResult(
|
||||
step.id,
|
||||
step.action,
|
||||
"blocked_owner_gate",
|
||||
"restricted action requires owner approval",
|
||||
step.risk,
|
||||
True,
|
||||
{"title": step.title},
|
||||
)
|
||||
if step.risk not in {"low"}:
|
||||
return ExecutionResult(
|
||||
step.id,
|
||||
step.action,
|
||||
"blocked_risk_gate",
|
||||
"only low-risk autonomous actions are allowed",
|
||||
step.risk,
|
||||
True,
|
||||
{"title": step.title},
|
||||
)
|
||||
handler = self.handlers.get(step.action, self._safe_noop_manifest)
|
||||
return ExecutionResult(
|
||||
step.id,
|
||||
step.action,
|
||||
"executed",
|
||||
"safe autonomous action executed",
|
||||
step.risk,
|
||||
False,
|
||||
handler(step),
|
||||
)
|
||||
|
||||
def execute_report(self, report: SelfLoopReport, runtime: dict[str, Any], baseline: dict[str, Any]) -> ExecutionReport:
|
||||
actions = [self.execute_step(step) for step in report.remediation]
|
||||
tests = self.test_runner.run(runtime, baseline)
|
||||
owner_approval_required = self.loop.owner_approval_required or any(a.requires_owner_approval for a in actions)
|
||||
failed_tests = [t for t in tests if not t.passed()]
|
||||
if failed_tests:
|
||||
status = "failed_tests"
|
||||
elif owner_approval_required:
|
||||
status = "waiting_owner_gate"
|
||||
else:
|
||||
status = "completed"
|
||||
return ExecutionReport(status, report.id, actions, tests, owner_approval_required)
|
||||
|
||||
def run_once(self, runtime: dict[str, Any], baseline: dict[str, Any]) -> dict[str, Any]:
|
||||
report = self.loop.evaluate(runtime, baseline)
|
||||
execution = self.execute_report(report, runtime, baseline)
|
||||
acceptance = self.loop.acceptance(report)
|
||||
owner_approval_required = execution.owner_approval_required or acceptance["owner_approval_required"]
|
||||
return {
|
||||
"status": execution.status,
|
||||
"can_unattended_continue": execution.status == "completed" and not owner_approval_required,
|
||||
"owner_approval_required": owner_approval_required,
|
||||
"plan": report.to_dict(),
|
||||
"execution": execution.to_dict(),
|
||||
}
|
||||
|
||||
def _create_low_risk_patch_manifest(self, step: RemediationStep) -> dict[str, Any]:
|
||||
return {
|
||||
"patch_intent": step.title,
|
||||
"branch_policy": "patch-only",
|
||||
"direct_main_push": False,
|
||||
"secrets_in_git": False,
|
||||
}
|
||||
|
||||
def _patch_plan_manifest(self, step: RemediationStep) -> dict[str, Any]:
|
||||
return {"plan": step.title, "action": step.action}
|
||||
|
||||
def _report_manifest(self, step: RemediationStep) -> dict[str, Any]:
|
||||
return {"report": step.title, "generated": True}
|
||||
|
||||
def _safe_noop_manifest(self, step: RemediationStep) -> dict[str, Any]:
|
||||
return {"noop": True, "title": step.title}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ExecutionResult",
|
||||
"TestResult",
|
||||
"ExecutionReport",
|
||||
"SelfLoopTestRunner",
|
||||
"SelfLoopAgentExecutor",
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Adapter manifest for controlled GitOps Actions execution.
|
||||
|
||||
The adapter produces an action plan consumed by the GitOps bridge. It does not
|
||||
embed credentials and does not perform direct repository writes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from .self_loop_patch_executor import PatchBundle
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitOpsActionPlan:
|
||||
action: str
|
||||
branch: str
|
||||
operations: tuple[dict[str, Any], ...]
|
||||
owner_approval_required: bool
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class SelfLoopGitOpsAdapter:
|
||||
def build_action_plan(self, bundle: PatchBundle) -> GitOpsActionPlan:
|
||||
operations = tuple(
|
||||
{
|
||||
"operation": "commitFile",
|
||||
"path": patch_file.path,
|
||||
"checksum": patch_file.checksum(),
|
||||
}
|
||||
for patch_file in bundle.files
|
||||
)
|
||||
return GitOpsActionPlan(
|
||||
action="gitops_patch_apply",
|
||||
branch=bundle.branch,
|
||||
operations=operations,
|
||||
owner_approval_required=bundle.owner_approval_required,
|
||||
)
|
||||
|
||||
def validate_action_plan(self, plan: GitOpsActionPlan) -> dict[str, Any]:
|
||||
return {
|
||||
"valid": plan.branch.startswith("patch/") and plan.action == "gitops_patch_apply",
|
||||
"branch": plan.branch,
|
||||
"owner_approval_required": plan.owner_approval_required,
|
||||
"operations": len(plan.operations),
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["GitOpsActionPlan", "SelfLoopGitOpsAdapter"]
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Multi-agent self-loop orchestration.
|
||||
|
||||
Coordinates planning, implementation proposals, and validation feedback from
|
||||
Hermes/OpenClaw/CodexBridge-style agents without storing credentials or bypassing
|
||||
GitOps governance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from .self_loop_executor import SelfLoopAgentExecutor
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentProfile:
|
||||
name: str
|
||||
role: str
|
||||
weight: int
|
||||
capabilities: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentTask:
|
||||
task_id: str
|
||||
goal: str
|
||||
task_type: str
|
||||
risk: str = "low"
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentResponse:
|
||||
agent: str
|
||||
status: str
|
||||
proposal: dict[str, Any]
|
||||
confidence: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentVote:
|
||||
agent: str
|
||||
approve: bool
|
||||
weight: int
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PatchManifest:
|
||||
branch_policy: str
|
||||
files: list[str]
|
||||
tests: list[str]
|
||||
approvals_required: bool
|
||||
generated_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class SelfLoopAgentOrchestrator:
|
||||
AGENTS = (
|
||||
AgentProfile("Hermes", "planner", 2, ("analysis", "planning", "risk")),
|
||||
AgentProfile("OpenClaw", "implementation", 2, ("code", "patch", "test")),
|
||||
AgentProfile("CodexBridge", "validation", 2, ("gitops", "validation", "acceptance")),
|
||||
)
|
||||
|
||||
def __init__(self, executor: SelfLoopAgentExecutor | None = None):
|
||||
self.executor = executor or SelfLoopAgentExecutor()
|
||||
|
||||
def dispatch(self, task: AgentTask) -> list[AgentResponse]:
|
||||
responses = []
|
||||
for agent in self.AGENTS:
|
||||
if agent.role == "planner":
|
||||
proposal = {"risk": task.risk, "plan": "classify issue and produce remediation steps"}
|
||||
elif agent.role == "implementation":
|
||||
proposal = {"patch": True, "branch_policy": "patch-only", "goal": task.goal}
|
||||
else:
|
||||
proposal = {"validation": ["tests", "gitops_policy", "acceptance_report"]}
|
||||
responses.append(AgentResponse(agent.name, "ready", proposal, 0.9))
|
||||
return responses
|
||||
|
||||
def vote(self, responses: list[AgentResponse]) -> dict[str, Any]:
|
||||
votes = []
|
||||
total = 0
|
||||
approved = 0
|
||||
for response in responses:
|
||||
profile = next(a for a in self.AGENTS if a.name == response.agent)
|
||||
approve = response.status == "ready"
|
||||
votes.append(AgentVote(response.agent, approve, profile.weight, "ready" if approve else "rejected"))
|
||||
total += profile.weight
|
||||
approved += profile.weight if approve else 0
|
||||
return {
|
||||
"approved": approved >= total / 2,
|
||||
"approval_weight": approved,
|
||||
"total_weight": total,
|
||||
"votes": [asdict(v) for v in votes],
|
||||
}
|
||||
|
||||
def create_patch_manifest(self, task: AgentTask, requires_approval: bool) -> PatchManifest:
|
||||
return PatchManifest(
|
||||
branch_policy="patch-only",
|
||||
files=[
|
||||
"active-runtime/app",
|
||||
"openapi",
|
||||
"tests",
|
||||
],
|
||||
tests=[
|
||||
"self_loop_executor",
|
||||
"portal_self_loop",
|
||||
"acceptance_pipeline",
|
||||
],
|
||||
approvals_required=requires_approval or task.risk != "low",
|
||||
)
|
||||
|
||||
def run_cycle(self, runtime: dict[str, Any], baseline: dict[str, Any], goal: str) -> dict[str, Any]:
|
||||
task = AgentTask("self-loop-cycle", goal, "remediation")
|
||||
executor_result = self.executor.run_once(runtime, baseline)
|
||||
responses = self.dispatch(task)
|
||||
decision = self.vote(responses)
|
||||
manifest = self.create_patch_manifest(task, executor_result["owner_approval_required"])
|
||||
return {
|
||||
"status": "ready_for_patch" if decision["approved"] else "blocked_agent_vote",
|
||||
"executor": executor_result,
|
||||
"agents": [asdict(r) for r in responses],
|
||||
"vote": decision,
|
||||
"patch_manifest": manifest.to_dict(),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentProfile",
|
||||
"AgentTask",
|
||||
"AgentResponse",
|
||||
"SelfLoopAgentOrchestrator",
|
||||
"PatchManifest",
|
||||
]
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Self-loop patch executor and acceptance pipeline.
|
||||
|
||||
This module converts multi-agent self-loop decisions into GitOps-safe patch
|
||||
bundles, validates policy, runs deterministic acceptance checks, and prepares a
|
||||
promotion request. It intentionally does not push main, rotate secrets, or bypass
|
||||
owner approval.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from hashlib import sha256
|
||||
from typing import Any
|
||||
|
||||
from .self_loop_orchestrator import SelfLoopAgentOrchestrator
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def stable_id(*parts: str) -> str:
|
||||
digest = sha256("::".join(parts).encode("utf-8")).hexdigest()[:12]
|
||||
return f"slp_{digest}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PatchFile:
|
||||
path: str
|
||||
content: str
|
||||
purpose: str
|
||||
|
||||
def checksum(self) -> str:
|
||||
return sha256(self.content.encode("utf-8")).hexdigest()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = asdict(self)
|
||||
data["sha256"] = self.checksum()
|
||||
return data
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PatchBundle:
|
||||
id: str
|
||||
branch: str
|
||||
risk: str
|
||||
goal: str
|
||||
files: tuple[PatchFile, ...]
|
||||
owner_approval_required: bool
|
||||
direct_main_push: bool = False
|
||||
secrets_in_git: bool = False
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"branch": self.branch,
|
||||
"risk": self.risk,
|
||||
"goal": self.goal,
|
||||
"files": [f.to_dict() for f in self.files],
|
||||
"owner_approval_required": self.owner_approval_required,
|
||||
"direct_main_push": self.direct_main_push,
|
||||
"secrets_in_git": self.secrets_in_git,
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AcceptanceCheck:
|
||||
name: str
|
||||
status: str
|
||||
detail: str
|
||||
required: bool = True
|
||||
|
||||
def passed(self) -> bool:
|
||||
return self.status == "pass" or not self.required
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AcceptanceReport:
|
||||
status: str
|
||||
patch_id: str
|
||||
checks: tuple[AcceptanceCheck, ...]
|
||||
owner_approval_required: bool
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"status": self.status,
|
||||
"patch_id": self.patch_id,
|
||||
"checks": [c.to_dict() for c in self.checks],
|
||||
"owner_approval_required": self.owner_approval_required,
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromotionRequest:
|
||||
status: str
|
||||
patch_id: str
|
||||
branch: str
|
||||
reason: str
|
||||
owner_approval_required: bool
|
||||
acceptance: AcceptanceReport
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"status": self.status,
|
||||
"patch_id": self.patch_id,
|
||||
"branch": self.branch,
|
||||
"reason": self.reason,
|
||||
"owner_approval_required": self.owner_approval_required,
|
||||
"acceptance": self.acceptance.to_dict(),
|
||||
}
|
||||
|
||||
|
||||
class PatchPolicyValidator:
|
||||
"""Validates GitOps safety constraints before any patch is promoted."""
|
||||
|
||||
FORBIDDEN_PATH_PREFIXES = (
|
||||
".git/",
|
||||
"secrets/",
|
||||
"private/",
|
||||
)
|
||||
SECRET_MARKERS = (
|
||||
"BEGIN PRIVATE KEY",
|
||||
"api_key=",
|
||||
"password=",
|
||||
"token=",
|
||||
"secret=",
|
||||
)
|
||||
|
||||
def validate(self, bundle: PatchBundle) -> tuple[AcceptanceCheck, ...]:
|
||||
checks: list[AcceptanceCheck] = []
|
||||
checks.append(self._patch_only(bundle))
|
||||
checks.append(self._no_direct_main_push(bundle))
|
||||
checks.append(self._no_secret_files(bundle))
|
||||
checks.append(self._file_paths_safe(bundle))
|
||||
checks.append(self._content_safe(bundle))
|
||||
return tuple(checks)
|
||||
|
||||
def _patch_only(self, bundle: PatchBundle) -> AcceptanceCheck:
|
||||
if bundle.branch.startswith("patch/"):
|
||||
return AcceptanceCheck("patch_branch_policy", "pass", "branch is patch-scoped")
|
||||
return AcceptanceCheck("patch_branch_policy", "fail", "branch must start with patch/")
|
||||
|
||||
def _no_direct_main_push(self, bundle: PatchBundle) -> AcceptanceCheck:
|
||||
if bundle.direct_main_push is False:
|
||||
return AcceptanceCheck("no_direct_main_push", "pass", "direct main push disabled")
|
||||
return AcceptanceCheck("no_direct_main_push", "fail", "direct main push is not allowed")
|
||||
|
||||
def _no_secret_files(self, bundle: PatchBundle) -> AcceptanceCheck:
|
||||
if bundle.secrets_in_git is False:
|
||||
return AcceptanceCheck("no_secrets_in_git", "pass", "bundle declares no secrets")
|
||||
return AcceptanceCheck("no_secrets_in_git", "fail", "secrets must not be stored in Git")
|
||||
|
||||
def _file_paths_safe(self, bundle: PatchBundle) -> AcceptanceCheck:
|
||||
unsafe = [f.path for f in bundle.files if f.path.startswith(self.FORBIDDEN_PATH_PREFIXES)]
|
||||
if not unsafe:
|
||||
return AcceptanceCheck("safe_file_paths", "pass", "all file paths are allowed")
|
||||
return AcceptanceCheck("safe_file_paths", "fail", f"unsafe paths: {unsafe}")
|
||||
|
||||
def _content_safe(self, bundle: PatchBundle) -> AcceptanceCheck:
|
||||
flagged: list[str] = []
|
||||
for patch_file in bundle.files:
|
||||
content_lower = patch_file.content.lower()
|
||||
for marker in self.SECRET_MARKERS:
|
||||
if marker.lower() in content_lower:
|
||||
flagged.append(patch_file.path)
|
||||
break
|
||||
if not flagged:
|
||||
return AcceptanceCheck("safe_file_content", "pass", "no secret markers detected")
|
||||
return AcceptanceCheck("safe_file_content", "fail", f"secret-like markers detected: {flagged}")
|
||||
|
||||
|
||||
class AcceptancePipeline:
|
||||
def __init__(self, validator: PatchPolicyValidator | None = None):
|
||||
self.validator = validator or PatchPolicyValidator()
|
||||
|
||||
def run(self, bundle: PatchBundle, executor_result: dict[str, Any]) -> AcceptanceReport:
|
||||
checks = list(self.validator.validate(bundle))
|
||||
checks.extend(self._executor_checks(executor_result))
|
||||
status = "pass" if all(c.passed() for c in checks) else "fail"
|
||||
if status == "pass" and bundle.owner_approval_required:
|
||||
status = "waiting_owner_gate"
|
||||
return AcceptanceReport(status, bundle.id, tuple(checks), bundle.owner_approval_required)
|
||||
|
||||
def _executor_checks(self, executor_result: dict[str, Any]) -> tuple[AcceptanceCheck, ...]:
|
||||
execution = executor_result.get("execution", {})
|
||||
tests = execution.get("tests", [])
|
||||
checks: list[AcceptanceCheck] = []
|
||||
for test in tests:
|
||||
checks.append(AcceptanceCheck(
|
||||
name=f"executor_{test.get('name', 'unknown')}",
|
||||
status="pass" if test.get("status") == "pass" else "fail",
|
||||
detail=test.get("detail", ""),
|
||||
))
|
||||
if not checks:
|
||||
checks.append(AcceptanceCheck("executor_tests_present", "fail", "no executor tests were reported"))
|
||||
return tuple(checks)
|
||||
|
||||
|
||||
class SelfLoopPatchExecutor:
|
||||
def __init__(self, orchestrator: SelfLoopAgentOrchestrator | None = None, pipeline: AcceptancePipeline | None = None):
|
||||
self.orchestrator = orchestrator or SelfLoopAgentOrchestrator()
|
||||
self.pipeline = pipeline or AcceptancePipeline()
|
||||
|
||||
def build_patch_bundle(self, cycle: dict[str, Any], goal: str, branch: str = "patch/autonomous-self-loop") -> PatchBundle:
|
||||
executor = cycle.get("executor", {})
|
||||
owner_approval_required = bool(executor.get("owner_approval_required"))
|
||||
manifest = cycle.get("patch_manifest", {})
|
||||
risk = "medium" if owner_approval_required else "low"
|
||||
patch_id = stable_id(branch, goal, cycle.get("status", "unknown"))
|
||||
files = (
|
||||
PatchFile(
|
||||
path=f"patches/{patch_id}.self-loop.json",
|
||||
purpose="self-loop patch manifest",
|
||||
content=self._manifest_content(patch_id, goal, cycle, manifest),
|
||||
),
|
||||
)
|
||||
return PatchBundle(
|
||||
id=patch_id,
|
||||
branch=branch,
|
||||
risk=risk,
|
||||
goal=goal,
|
||||
files=files,
|
||||
owner_approval_required=owner_approval_required or bool(manifest.get("approvals_required")),
|
||||
)
|
||||
|
||||
def run_cycle_to_promotion(
|
||||
self,
|
||||
runtime: dict[str, Any],
|
||||
baseline: dict[str, Any],
|
||||
goal: str,
|
||||
branch: str = "patch/autonomous-self-loop",
|
||||
) -> dict[str, Any]:
|
||||
cycle = self.orchestrator.run_cycle(runtime, baseline, goal)
|
||||
bundle = self.build_patch_bundle(cycle, goal, branch)
|
||||
acceptance = self.pipeline.run(bundle, cycle.get("executor", {}))
|
||||
promotion = self.prepare_promotion(bundle, acceptance)
|
||||
return {
|
||||
"status": promotion.status,
|
||||
"cycle": cycle,
|
||||
"patch_bundle": bundle.to_dict(),
|
||||
"acceptance": acceptance.to_dict(),
|
||||
"promotion": promotion.to_dict(),
|
||||
}
|
||||
|
||||
def prepare_promotion(self, bundle: PatchBundle, acceptance: AcceptanceReport) -> PromotionRequest:
|
||||
if acceptance.status == "fail":
|
||||
return PromotionRequest("blocked_failed_acceptance", bundle.id, bundle.branch, "acceptance failed", True, acceptance)
|
||||
if acceptance.owner_approval_required:
|
||||
return PromotionRequest("waiting_owner_approval", bundle.id, bundle.branch, "owner approval required", True, acceptance)
|
||||
return PromotionRequest("ready_for_auto_merge", bundle.id, bundle.branch, "low-risk patch passed acceptance", False, acceptance)
|
||||
|
||||
def _manifest_content(self, patch_id: str, goal: str, cycle: dict[str, Any], manifest: dict[str, Any]) -> str:
|
||||
lines = [
|
||||
"{",
|
||||
f' "id": "{patch_id}",',
|
||||
f' "goal": "{goal}",',
|
||||
f' "status": "{cycle.get("status", "unknown")}",',
|
||||
f' "branch_policy": "{manifest.get("branch_policy", "patch-only")}",',
|
||||
' "direct_main_push": false,',
|
||||
' "secrets_in_git": false,',
|
||||
f' "owner_approval_required": {str(bool(manifest.get("approvals_required"))).lower()}',
|
||||
"}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PatchFile",
|
||||
"PatchBundle",
|
||||
"AcceptanceCheck",
|
||||
"AcceptanceReport",
|
||||
"PromotionRequest",
|
||||
"PatchPolicyValidator",
|
||||
"AcceptancePipeline",
|
||||
"SelfLoopPatchExecutor",
|
||||
]
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Runtime API wiring for autonomous self-loop jobs.
|
||||
|
||||
This module exposes plain functions and an optional FastAPI router for portal,
|
||||
webhook, and worker integrations. It keeps all writes behind patch-only GitOps
|
||||
plans and never stores secrets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
try: # pragma: no cover - optional runtime dependency
|
||||
from fastapi import APIRouter, Header
|
||||
except Exception: # pragma: no cover - fallback for unit tests
|
||||
APIRouter = None
|
||||
Header = None
|
||||
|
||||
from .production_acceptance_gate import ProductionAcceptanceGate
|
||||
from .self_loop_scheduler import SelfLoopScheduler, TriggerType
|
||||
|
||||
scheduler = SelfLoopScheduler()
|
||||
production_gate = ProductionAcceptanceGate()
|
||||
|
||||
|
||||
def enqueue_self_loop_job(
|
||||
goal: str,
|
||||
runtime: dict[str, Any],
|
||||
baseline: dict[str, Any],
|
||||
trigger: str = "event",
|
||||
branch: str = "patch/autonomous-self-loop",
|
||||
) -> dict[str, Any]:
|
||||
job = scheduler.enqueue(goal, runtime, baseline, TriggerType(trigger), branch)
|
||||
return {"status": "queued", "job": job.to_dict()}
|
||||
|
||||
|
||||
def run_self_loop_job(job_id: str) -> dict[str, Any]:
|
||||
return {"status": "ok", "job": scheduler.run_job(job_id)}
|
||||
|
||||
|
||||
def tick_self_loop_jobs(limit: int = 1) -> dict[str, Any]:
|
||||
return {"status": "ok", "jobs": scheduler.tick(limit), "snapshot": scheduler.snapshot().to_dict()}
|
||||
|
||||
|
||||
def list_self_loop_jobs() -> dict[str, Any]:
|
||||
return {"status": "ok", "jobs": [job.to_dict() for job in scheduler.jobs.values()]}
|
||||
|
||||
|
||||
def get_self_loop_job(job_id: str) -> dict[str, Any]:
|
||||
job = scheduler.jobs.get(job_id)
|
||||
if not job:
|
||||
return {"status": "not_found", "job_id": job_id}
|
||||
return {"status": "ok", "job": job.to_dict()}
|
||||
|
||||
|
||||
def run_self_loop_once(
|
||||
goal: str,
|
||||
runtime: dict[str, Any],
|
||||
baseline: dict[str, Any],
|
||||
branch: str = "patch/autonomous-self-loop",
|
||||
) -> dict[str, Any]:
|
||||
job = scheduler.enqueue(goal, runtime, baseline, TriggerType.MANUAL, branch, max_attempts=1)
|
||||
return run_self_loop_job(job.id)
|
||||
|
||||
|
||||
def evaluate_self_loop_production_gate(
|
||||
runtime: dict[str, Any],
|
||||
baseline: dict[str, Any],
|
||||
acceptance: dict[str, Any],
|
||||
owner_approval_required: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
result = production_gate.evaluate(runtime, baseline, acceptance, owner_approval_required)
|
||||
return {"status": "ok", "gate": result.to_dict()}
|
||||
|
||||
|
||||
def build_router():
|
||||
if APIRouter is None:
|
||||
return None
|
||||
|
||||
router = APIRouter(prefix="/self-loop", tags=["self-loop"])
|
||||
|
||||
@router.post("/jobs", operation_id="enqueueSelfLoopJob")
|
||||
def enqueue_job(payload: dict[str, Any]):
|
||||
return enqueue_self_loop_job(
|
||||
goal=payload["goal"],
|
||||
runtime=payload.get("runtime", {}),
|
||||
baseline=payload.get("baseline", {}),
|
||||
trigger=payload.get("trigger", "event"),
|
||||
branch=payload.get("branch", "patch/autonomous-self-loop"),
|
||||
)
|
||||
|
||||
@router.get("/jobs", operation_id="listSelfLoopJobs")
|
||||
def jobs():
|
||||
return list_self_loop_jobs()
|
||||
|
||||
@router.get("/jobs/{job_id}", operation_id="getSelfLoopJob")
|
||||
def get_job(job_id: str):
|
||||
return get_self_loop_job(job_id)
|
||||
|
||||
@router.post("/jobs/{job_id}/run", operation_id="runSelfLoopJob")
|
||||
def run_job(job_id: str):
|
||||
return run_self_loop_job(job_id)
|
||||
|
||||
@router.post("/tick", operation_id="tickSelfLoopJobs")
|
||||
def tick(payload: dict[str, Any] | None = None):
|
||||
return tick_self_loop_jobs((payload or {}).get("limit", 1))
|
||||
|
||||
@router.post("/run-once", operation_id="runSelfLoopOnce")
|
||||
def run_once(payload: dict[str, Any]):
|
||||
return run_self_loop_once(
|
||||
goal=payload["goal"],
|
||||
runtime=payload.get("runtime", {}),
|
||||
baseline=payload.get("baseline", {}),
|
||||
branch=payload.get("branch", "patch/autonomous-self-loop"),
|
||||
)
|
||||
|
||||
@router.post("/production-gate", operation_id="evaluateSelfLoopProductionGate")
|
||||
def production_gate_route(payload: dict[str, Any]):
|
||||
return evaluate_self_loop_production_gate(
|
||||
runtime=payload.get("runtime", {}),
|
||||
baseline=payload.get("baseline", {}),
|
||||
acceptance=payload.get("acceptance", {}),
|
||||
owner_approval_required=payload.get("owner_approval_required", True),
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"build_router",
|
||||
"enqueue_self_loop_job",
|
||||
"run_self_loop_job",
|
||||
"tick_self_loop_jobs",
|
||||
"list_self_loop_jobs",
|
||||
"get_self_loop_job",
|
||||
"run_self_loop_once",
|
||||
"evaluate_self_loop_production_gate",
|
||||
]
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Autonomous self-loop scheduler.
|
||||
|
||||
Schedules event-triggered and interval-style self-loop cycles. The scheduler is
|
||||
framework-neutral and deterministic: production code can invoke tick() from a
|
||||
cron, worker, or webhook handler without giving the module direct access to
|
||||
secrets, shell commands, or main-branch writes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from .self_loop_patch_executor import SelfLoopPatchExecutor
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class TriggerType(str, Enum):
|
||||
INTERVAL = "interval"
|
||||
EVENT = "event"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
class JobStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
WAITING_OWNER = "waiting_owner"
|
||||
FAILED = "failed"
|
||||
RETRY_PENDING = "retry_pending"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SelfLoopJob:
|
||||
id: str
|
||||
goal: str
|
||||
trigger: TriggerType
|
||||
runtime: dict[str, Any]
|
||||
baseline: dict[str, Any]
|
||||
branch: str = "patch/autonomous-self-loop"
|
||||
status: JobStatus = JobStatus.PENDING
|
||||
attempts: int = 0
|
||||
max_attempts: int = 3
|
||||
last_error: str | None = None
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
updated_at: str = field(default_factory=utc_now)
|
||||
result: dict[str, Any] | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = asdict(self)
|
||||
data["trigger"] = self.trigger.value
|
||||
data["status"] = self.status.value
|
||||
return data
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SchedulerSnapshot:
|
||||
pending: int
|
||||
running: int
|
||||
completed: int
|
||||
waiting_owner: int
|
||||
failed: int
|
||||
retry_pending: int
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class SelfLoopScheduler:
|
||||
"""Small in-process scheduler used by runtime workers and tests."""
|
||||
|
||||
def __init__(self, executor: SelfLoopPatchExecutor | None = None):
|
||||
self.executor = executor or SelfLoopPatchExecutor()
|
||||
self.jobs: dict[str, SelfLoopJob] = {}
|
||||
|
||||
def enqueue(
|
||||
self,
|
||||
goal: str,
|
||||
runtime: dict[str, Any],
|
||||
baseline: dict[str, Any],
|
||||
trigger: TriggerType | str = TriggerType.EVENT,
|
||||
branch: str = "patch/autonomous-self-loop",
|
||||
max_attempts: int = 3,
|
||||
) -> SelfLoopJob:
|
||||
trigger_value = trigger if isinstance(trigger, TriggerType) else TriggerType(trigger)
|
||||
job = SelfLoopJob(
|
||||
id=f"job_{uuid4().hex[:12]}",
|
||||
goal=goal,
|
||||
trigger=trigger_value,
|
||||
runtime=runtime,
|
||||
baseline=baseline,
|
||||
branch=branch,
|
||||
max_attempts=max_attempts,
|
||||
)
|
||||
self.jobs[job.id] = job
|
||||
return job
|
||||
|
||||
def tick(self, limit: int = 1) -> list[dict[str, Any]]:
|
||||
runnable = [j for j in self.jobs.values() if j.status in {JobStatus.PENDING, JobStatus.RETRY_PENDING}]
|
||||
results: list[dict[str, Any]] = []
|
||||
for job in runnable[:limit]:
|
||||
results.append(self.run_job(job.id))
|
||||
return results
|
||||
|
||||
def run_job(self, job_id: str) -> dict[str, Any]:
|
||||
job = self.jobs[job_id]
|
||||
job.status = JobStatus.RUNNING
|
||||
job.attempts += 1
|
||||
job.updated_at = utc_now()
|
||||
try:
|
||||
result = self.executor.run_cycle_to_promotion(job.runtime, job.baseline, job.goal, job.branch)
|
||||
job.result = result
|
||||
promotion_status = result.get("promotion", {}).get("status")
|
||||
if promotion_status == "waiting_owner_approval":
|
||||
job.status = JobStatus.WAITING_OWNER
|
||||
elif promotion_status == "ready_for_auto_merge":
|
||||
job.status = JobStatus.COMPLETED
|
||||
elif job.attempts < job.max_attempts:
|
||||
job.status = JobStatus.RETRY_PENDING
|
||||
job.last_error = promotion_status or "promotion_not_ready"
|
||||
else:
|
||||
job.status = JobStatus.FAILED
|
||||
job.last_error = promotion_status or "promotion_failed"
|
||||
except Exception as exc: # pragma: no cover - defensive runtime guard
|
||||
job.last_error = f"{type(exc).__name__}: {exc}"
|
||||
job.status = JobStatus.RETRY_PENDING if job.attempts < job.max_attempts else JobStatus.FAILED
|
||||
job.updated_at = utc_now()
|
||||
return job.to_dict()
|
||||
|
||||
def retry_failed(self) -> list[str]:
|
||||
retry_ids: list[str] = []
|
||||
for job in self.jobs.values():
|
||||
if job.status == JobStatus.FAILED and job.attempts < job.max_attempts:
|
||||
job.status = JobStatus.RETRY_PENDING
|
||||
job.updated_at = utc_now()
|
||||
retry_ids.append(job.id)
|
||||
return retry_ids
|
||||
|
||||
def snapshot(self) -> SchedulerSnapshot:
|
||||
statuses = [job.status for job in self.jobs.values()]
|
||||
return SchedulerSnapshot(
|
||||
pending=statuses.count(JobStatus.PENDING),
|
||||
running=statuses.count(JobStatus.RUNNING),
|
||||
completed=statuses.count(JobStatus.COMPLETED),
|
||||
waiting_owner=statuses.count(JobStatus.WAITING_OWNER),
|
||||
failed=statuses.count(JobStatus.FAILED),
|
||||
retry_pending=statuses.count(JobStatus.RETRY_PENDING),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TriggerType",
|
||||
"JobStatus",
|
||||
"SelfLoopJob",
|
||||
"SchedulerSnapshot",
|
||||
"SelfLoopScheduler",
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Long-running supervisor model for autonomous self-loop runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from .self_loop_event_hooks import SelfLoopEventHook
|
||||
from .failure_memory import FailureMemory
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass
|
||||
class SupervisorState:
|
||||
running: bool = False
|
||||
cycles: int = 0
|
||||
last_cycle: str | None = None
|
||||
last_status: str = "idle"
|
||||
heartbeat: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class SelfLoopSupervisor:
|
||||
def __init__(self, hook: SelfLoopEventHook | None = None, memory: FailureMemory | None = None):
|
||||
self.memory = memory or FailureMemory()
|
||||
self.hook = hook or SelfLoopEventHook(memory=self.memory)
|
||||
self.state = SupervisorState()
|
||||
|
||||
def start(self) -> dict[str, Any]:
|
||||
self.state.running = True
|
||||
self.state.last_status = "running"
|
||||
self.state.heartbeat = utc_now()
|
||||
return self.status()
|
||||
|
||||
def stop(self) -> dict[str, Any]:
|
||||
self.state.running = False
|
||||
self.state.last_status = "stopped"
|
||||
self.state.heartbeat = utc_now()
|
||||
return self.status()
|
||||
|
||||
def heartbeat(self) -> dict[str, Any]:
|
||||
self.state.heartbeat = utc_now()
|
||||
return self.status()
|
||||
|
||||
def handle_event(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if not self.state.running:
|
||||
self.start()
|
||||
self.state.cycles += 1
|
||||
self.state.last_cycle = utc_now()
|
||||
result = self.hook.handle(payload)
|
||||
self.state.last_status = result.get("status", "unknown")
|
||||
self.state.heartbeat = utc_now()
|
||||
return result
|
||||
|
||||
def tick(self, events: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
results = [self.handle_event(event) for event in events]
|
||||
return {
|
||||
"status": "ok",
|
||||
"supervisor": self.status(),
|
||||
"events": results,
|
||||
"failure_memory": self.memory.export(),
|
||||
}
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
return {
|
||||
"state": self.state.to_dict(),
|
||||
"failure_memory_size": len(self.memory.records),
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["SupervisorState", "SelfLoopSupervisor"]
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Resident worker model for self-loop supervision.
|
||||
|
||||
Provides a daemon-friendly lifecycle without forcing an infinite loop inside the
|
||||
application. A process manager can call start/run_cycle/stop and persist state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from .self_loop_supervisor import SelfLoopSupervisor
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkerState:
|
||||
running: bool = False
|
||||
cycles: int = 0
|
||||
last_error: str | None = None
|
||||
started_at: str | None = None
|
||||
heartbeat: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class SelfLoopWorker:
|
||||
def __init__(self, supervisor: SelfLoopSupervisor | None = None):
|
||||
self.supervisor = supervisor or SelfLoopSupervisor()
|
||||
self.state = WorkerState()
|
||||
|
||||
def start(self) -> dict[str, Any]:
|
||||
self.state.running = True
|
||||
self.state.started_at = utc_now()
|
||||
self.state.heartbeat = utc_now()
|
||||
self.supervisor.start()
|
||||
return self.status()
|
||||
|
||||
def run_cycle(self, events: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
if not self.state.running:
|
||||
self.start()
|
||||
try:
|
||||
result = self.supervisor.tick(events)
|
||||
self.state.cycles += 1
|
||||
self.state.heartbeat = utc_now()
|
||||
return result
|
||||
except Exception as exc:
|
||||
self.state.last_error = f"{type(exc).__name__}: {exc}"
|
||||
self.state.heartbeat = utc_now()
|
||||
raise
|
||||
|
||||
def stop(self) -> dict[str, Any]:
|
||||
self.state.running = False
|
||||
self.supervisor.stop()
|
||||
self.state.heartbeat = utc_now()
|
||||
return self.status()
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
return {
|
||||
"worker": self.state.to_dict(),
|
||||
"supervisor": self.supervisor.status(),
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["WorkerState", "SelfLoopWorker"]
|
||||
@@ -1,265 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Evolution Dashboard</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; background: #0d1117; color: #c9d1d9; height: 100vh; overflow: hidden; }
|
||||
.app { display: flex; height: 100vh; }
|
||||
.sidebar { width: 260px; background: #161b22; border-right: 1px solid #30363d; padding: 20px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.sidebar h1 { font-size: 18px; color: #f0f6fc; border-bottom: 1px solid #30363d; padding-bottom: 12px; margin-bottom: 8px; }
|
||||
.sidebar-nav { list-style: none; display: flex; flex-direction: column; gap: 4px; }
|
||||
.sidebar-nav li { padding: 8px 12px; border-radius: 6px; cursor: pointer; font-size: 14px; color: #8b949e; }
|
||||
.sidebar-nav li:hover, .sidebar-nav li.active { background: #1f6feb22; color: #58a6ff; }
|
||||
.sidebar-nav li i { margin-right: 8px; }
|
||||
.main { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
.main-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.main-header h2 { font-size: 20px; }
|
||||
.status-badge { padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600; text-transform: uppercase; }
|
||||
.status-badge.ok { background: #1b3a2d; color: #3fb950; border: 1px solid #3fb95044; }
|
||||
.status-badge.degraded { background: #3a2e1b; color: #d29922; border: 1px solid #d2992244; }
|
||||
.status-badge.fail { background: #3a1b1b; color: #f85149; border: 1px solid #f8514944; }
|
||||
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
|
||||
.card-title { font-size: 14px; color: #8b949e; margin-bottom: 8px; }
|
||||
.card-value { font-size: 28px; font-weight: 700; color: #f0f6fc; }
|
||||
.row { display: flex; gap: 16px; }
|
||||
.col { flex: 1; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
|
||||
pre { white-space: pre-wrap; word-break: break-word; }
|
||||
.command-item { padding: 12px; margin-bottom: 8px; border-radius: 6px; background: #0d1117; border: 1px solid #21262d; }
|
||||
.command-item .meta { display: flex; gap: 8px; align-items: center; font-size: 12px; color: #8b949e; }
|
||||
.command-item .name { font-size: 14px; color: #58a6ff; font-weight: 600; }
|
||||
.command-item .state { font-size: 11px; padding: 2px 8px; border-radius: 10px; text-transform: uppercase; }
|
||||
.state-active { background: #1b3a2d; color: #3fb950; }
|
||||
.state-running { background: #3a2e1b; color: #d29922; }
|
||||
.state-completed { background: #1b3a2d; color: #3fb950; }
|
||||
.state-failed { background: #3a1b1b; color: #f85149; }
|
||||
.loading { text-align: center; padding: 40px; color: #8b949e; }
|
||||
.error { padding: 20px; background: #3a1b1b; border: 1px solid #f8514944; border-radius: 8px; color: #f85149; margin-bottom: 16px; }
|
||||
.version-item { padding: 8px 12px; background: #0d1117; border: 1px solid #21262d; border-radius: 6px; margin-bottom: 4px; display: flex; justify-content: space-between; }
|
||||
.version-item .tag { color: #58a6ff; font-weight: 600; }
|
||||
.version-item .name { color: #8b949e; font-size: 13px; }
|
||||
.agent-item { background: #0d1117; border: 1px solid #21262d; border-radius: 6px; margin-bottom: 8px; padding: 12px; }
|
||||
.agent-item .agent-title { font-size: 14px; font-weight: 600; color: #c9d1d9; margin-bottom: 4px; }
|
||||
.agent-item .agent-meta { font-size: 12px; color: #8b949e; }
|
||||
.agent-run-item { padding: 8px 12px; margin-bottom: 4px; border-radius: 4px; background: #0d1117; border: 1px solid #21262d; display: flex; justify-content: space-between; align-items: center; }
|
||||
.agent-run-item .run-id { color: #58a6ff; font-size: 13px; font-weight: 600; }
|
||||
.agent-run-item .source { color: #8b949e; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="app">
|
||||
<div class="sidebar">
|
||||
<h1>🕹️ AI Evolution Dashboard</h1>
|
||||
<ul class="sidebar-nav">
|
||||
<li class="active" onclick="showPanel('overview')"><i>📊</i> Overview</li>
|
||||
<li onclick="showPanel('versions')"><i>🏷️</i> Versions</li>
|
||||
<li onclick="showPanel('commands')"><i>⚡</i> Commands</li>
|
||||
<li onclick="showPanel('agents')"><i>🤖</i> Agent Runs</li>
|
||||
<li onclick="showPanel('arch-owner')"><i>🔐</i> Owner Gate</li>
|
||||
<li onclick="showPanel('arch-approve')"><i>✓</i> Approve Changes</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div id="panel-overview" class="panel">
|
||||
<div class="main-header">
|
||||
<h2>System Overview</h2>
|
||||
<div id="header-status" class="status-badge">Loading...</div>
|
||||
</div>
|
||||
<div id="loading" class="loading">Loading...</div>
|
||||
<div id="error" class="error" style="display:none;"></div>
|
||||
<div id="content">
|
||||
<div class="row">
|
||||
<div class="col"><div class="card"><div class="card-title">Current Baseline</div><div id="base" class="card-value">-</div></div></div>
|
||||
<div class="col"><div class="card"><div class="card-title">Git Branch</div><div id="branch" class="card-value">-</div></div></div>
|
||||
<div class="col"><div class="card"><div class="card-title">Uptime</div><div id="uptime" class="card-value">-</div></div></div>
|
||||
</div>
|
||||
<div class="card" style="margin-top:16px">
|
||||
<div class="card-title">Event Bus Status</div>
|
||||
<pre id="bus-status">-</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="panel-versions" class="panel" style="display:none;">
|
||||
<div class="main-header"><h2>Version Timeline</h2></div>
|
||||
<div id="versions-list">Loading...</div>
|
||||
</div>
|
||||
<div id="panel-commands" class="panel" style="display:none;">
|
||||
<div class="main-header"><h2>Command Lookup</h2></div>
|
||||
<div id="commands-list">Loading...</div>
|
||||
</div>
|
||||
<div id="panel-agents" class="panel" style="display:none;">
|
||||
<div class="main-header"><h2>Recent Agent Executions</h2></div>
|
||||
<div id="panel-arch-owner" class="panel" style="display:none;">
|
||||
<div class="main-header"><h2>🔐 Architecture Owner Gate</h2></div>
|
||||
<div id="arch-owner-content">Loading...</div>
|
||||
</div>
|
||||
<div id="panel-arch-approve" class="panel" style="display:none;">
|
||||
<div class="main-header"><h2>✓ Approve Architecture Changes</h2></div>
|
||||
<div id="arch-approve-content">Loading...</div>
|
||||
</div>
|
||||
<div id="agents-list">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
async function fetchData() {
|
||||
const r = await fetch('/admin/ai-evolution/api/data');
|
||||
const data = await r.json();
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
document.getElementById('error').style.display = 'none';
|
||||
document.getElementById('content').style.display = '';
|
||||
|
||||
const hs = document.getElementById('header-status');
|
||||
hs.className = 'status-badge ' + (data.healthy ? 'ok' : 'fail');
|
||||
hs.textContent = data.healthy ? '▪ operational' : '⚠ degraded';
|
||||
|
||||
document.getElementById('base').textContent = data.baseline || '-';
|
||||
document.getElementById('branch').textContent = data.main_branch || 'main';
|
||||
document.getElementById('uptime').textContent = data.uptime || '-';
|
||||
document.getElementById('bus-status').textContent = data.bus ? JSON.stringify(data.bus, null, 2) : 'no event bus data';
|
||||
}
|
||||
|
||||
async function loadVersions() {
|
||||
const el = document.getElementById('versions-list');
|
||||
el.innerHTML = '<div class="loading">Loading versions...</div>';
|
||||
try {
|
||||
const r = await fetch('/admin/ai-evolution/api/versions');
|
||||
const data = await r.json();
|
||||
if (data.error) { el.innerHTML = '<div class="error">' + data.error + '</div>'; return; }
|
||||
let html = '<div class="grid">';
|
||||
for (const v of data.versions || []) {
|
||||
html += '<div class="card"><div class="card-title">' + v.tag + '</div><div class="card-value">' + (v.version || v.tag) + '</div></div>';
|
||||
}
|
||||
html += '</div>';
|
||||
el.innerHTML = html;
|
||||
} catch(e) {
|
||||
el.innerHTML = '<div class="error">Failed to load versions: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCommands() {
|
||||
const el = document.getElementById('commands-list');
|
||||
el.innerHTML = '<div class="loading">Loading commands...</div>';
|
||||
try {
|
||||
const r = await fetch('/admin/ai-evolution/api/data');
|
||||
const data = await r.json();
|
||||
let cmds = data.commands || [];
|
||||
let html = '<div style="display:flex;gap:24px;margin-bottom:16px;">';
|
||||
html += '<div><div class="card"><div class="card-title">Total</div><div class="card-value">' + cmds.length + '</div></div></div>';
|
||||
html += '<div><div class="card"><div class="card-title">By Category</div>';
|
||||
const cats = {};
|
||||
for (const c of cmds) { const k = c.category || 'uncategorized'; cats[k] = (cats[k] || 0) + 1; }
|
||||
for (const [k, v] of Object.entries(cats)) html += '<div style="font-size:14px;color:#8b949e;margin:4px 0;">' + k + ': ' + v + '</div>';
|
||||
html += '</div></div></div>';
|
||||
for (const cmd of cmds.slice().reverse()) {
|
||||
html += '<div class="command-item"><div class="meta">' + cmd.category + ' | ' + cmd.filename + '</div><div class="name">' + cmd.title + '</div><div style="font-size:13px;color:#c9d1d9;margin-top:4px;">' + cmd.summary + '</div></div>';
|
||||
}
|
||||
el.innerHTML = html;
|
||||
} catch(e) {
|
||||
el.innerHTML = '<div class="error">Failed to load commands: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAgents() {
|
||||
const el = document.getElementById('agents-list');
|
||||
el.innerHTML = '<div class="loading">Loading agent runs...</div>';
|
||||
try {
|
||||
const r = await fetch('/admin/ai-evolution/api/agents');
|
||||
const data = await r.json();
|
||||
if (data.error) { el.innerHTML = '<div class="error">' + data.error + '</div>'; return; }
|
||||
let html = '';
|
||||
for (const agent of data.agents || []) {
|
||||
html += '<div class="agent-item"><div class="agent-title">' + agent.name + ' <span class="state state-' + (agent.status === 'running' ? 'running' : (agent.status === 'ok' ? 'completed' : 'failed')) + '">' + agent.status + '</span></div><div class="agent-meta">' + (agent.runs || 0) + ' runs | last: ' + (agent.last_run || '-') + '</div></div>';
|
||||
}
|
||||
el.innerHTML = html || '<div style="padding:24px;color:#8b949e;">No agent runs recorded yet.</div>';
|
||||
} catch(e) {
|
||||
el.innerHTML = '<div class="error">Failed to load agents: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function showPanel(name) {
|
||||
|
||||
async function loadOwnerGate() {
|
||||
const el = document.getElementById('arch-owner-content');
|
||||
el.innerHTML = '<div class="loading">Loading owner gate data...</div>';
|
||||
try {
|
||||
const r = await fetch('/admin/ai-evolution/api/owner-gate');
|
||||
const data = await r.json();
|
||||
let html = '<div class="row">';
|
||||
html += '<div class="col"><div class="card"><div class="card-title">Pending Requests</div><div class="card-value">' + data.pending_count + '</div></div></div>';
|
||||
html += '<div class="col"><div class="card"><div class="card-title">Approved</div><div class="card-value">' + data.approved_count + '</div></div></div>';
|
||||
html += '<div class="col"><div class="card"><div class="card-title">Rejected</div><div class="card-value">' + data.rejected_count + '</div></div></div>';
|
||||
html += '</div>';
|
||||
html += '<div class="card"><div class="card-title">Pending Approval Requests</div>';
|
||||
if (data.pending_requests && data.pending_requests.length) {
|
||||
for (const req of data.pending_requests) {
|
||||
html += '<div class="command-item">';
|
||||
html += '<div class="name">' + (req.title || req.change_id) + '</div>';
|
||||
html += '<div class="meta">ID: ' + req.change_id + ' | Risk: ' + req.risk + '</div>';
|
||||
html += '<div style="font-size:13px;color:#c9d1d9;margin-top:4px;">' + (req.reason || '') + '</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
} else {
|
||||
html += '<div style="color:#8b949e;padding:12px;">No pending requests.</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
el.innerHTML = html;
|
||||
} catch(e) {
|
||||
el.innerHTML = '<div class="error">Failed to load: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApprove() {
|
||||
const el = document.getElementById('arch-approve-content');
|
||||
el.innerHTML = '<div class="loading">Loading pending approvals...</div>';
|
||||
try {
|
||||
const r = await fetch('/admin/ai-evolution/api/owner-gate');
|
||||
const data = await r.json();
|
||||
let html = '';
|
||||
if (data.pending_requests && data.pending_requests.length) {
|
||||
html += '<div class="card"><div class="card-title">Pending changes requiring your approval</div>';
|
||||
for (const req of data.pending_requests) {
|
||||
html += '<div class="command-item">';
|
||||
html += '<div class="name">' + (req.title || req.change_id) + '</div>';
|
||||
html += '<div class="meta">Risk: <strong>' + req.risk + '</strong></div>';
|
||||
html += '<div style="font-size:13px;color:#c9d1d9;margin:8px 0;">' + (req.reason || '') + '</div>';
|
||||
html += '<div style="display:flex;gap:8px;">';
|
||||
html += '<button onclick="ownerAction.classList.add('\'approved\');this.disabled=true" style="padding:6px 16px;background:#238636;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px;">✓ Approve</button>';
|
||||
html += '</div></div>';
|
||||
}
|
||||
html += '</div>';
|
||||
} else {
|
||||
html += '<div class="card"><div style="padding:24px;text-align:center;color:#8b949e;">✅ All changes processed. No pending approval requests.</div></div>';
|
||||
}
|
||||
el.innerHTML = html;
|
||||
} catch(e) {
|
||||
el.innerHTML = '<div class="error">Failed to load: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.panel').forEach(p => p.style.display = 'none');
|
||||
document.getElementById('panel-' + name).style.display = '';
|
||||
document.querySelectorAll('.sidebar-nav li').forEach(li => li.classList.remove('active'));
|
||||
document.querySelector('.sidebar-nav li:nth-child(' + ({overview:1,versions:2,commands:3,agents:4}[name] || 1) + ')').classList.add('active');
|
||||
if (name === 'overview') {
|
||||
document.getElementById('panel-overview').innerHTML = '<div class="main-header"><h2>System Overview</h2><div id="header-status" class="status-badge">Loading...</div></div><div id="loading" class="loading">Loading...</div><div id="error" class="error" style="display:none;"></div><div id="content"><div class="row"><div class="col"><div class="card"><div class="card-title">Current Baseline</div><div id="base" class="card-value">-</div></div></div><div class="col"><div class="card"><div class="card-title">Git Branch</div><div id="branch" class="card-value">-</div></div></div><div class="col"><div class="card"><div class="card-title">Uptime</div><div id="uptime" class="card-value">-</div></div></div></div><div class="card" style="margin-top:16px"><div class="card-title">Event Bus Status</div><pre id="bus-status">-</pre></div></div>';
|
||||
fetchData();
|
||||
}
|
||||
const loaders = {
|
||||
'arch-owner': loadOwnerGate,
|
||||
'arch-approve': loadApprove,
|
||||
versions: loadVersions,
|
||||
commands: loadCommands,
|
||||
agents: loadAgents,
|
||||
};
|
||||
const fn = loaders[name];
|
||||
if (fn) fn();
|
||||
}
|
||||
|
||||
fetchData();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Task Center action API model for Web UI buttons."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
|
||||
from .task_lifecycle import TaskLifecycle
|
||||
|
||||
|
||||
lifecycle = TaskLifecycle()
|
||||
|
||||
|
||||
def get_task_actions(task: dict[str, Any], role: str) -> dict[str, Any]:
|
||||
return {
|
||||
"task_id": task.get("task_id"),
|
||||
"actions": lifecycle.available_actions(task, role),
|
||||
}
|
||||
|
||||
|
||||
def execute_task_action(task: dict[str, Any], action: str, role: str, reason: str | None = None):
|
||||
return lifecycle.transition(task, action, role, reason)
|
||||
|
||||
|
||||
def build_router():
|
||||
if APIRouter is None:
|
||||
return None
|
||||
router = APIRouter(prefix="/admin/ai-evolution/tasks", tags=["task-actions"])
|
||||
|
||||
@router.post("/{task_id}/actions/{action}", operation_id="executeTaskAction")
|
||||
def action(task_id: str, action: str, payload: dict[str, Any]):
|
||||
return execute_task_action(payload.get("task", {}), action, payload.get("role", "user"), payload.get("reason"))
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = ["router", "get_task_actions", "execute_task_action"]
|
||||
@@ -1,115 +0,0 @@
|
||||
"""AIOS Task Center domain model.
|
||||
|
||||
The Task Center gives owners and authorized users a single Web UI page showing:
|
||||
who assigned a task, who is executing it, progress, elapsed time, remaining
|
||||
work, and whether completion notifications were sent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def parse_time(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskNotificationStatus:
|
||||
required: bool = True
|
||||
sent: bool = False
|
||||
channel: str | None = None
|
||||
sent_at: str | None = None
|
||||
recipient_scope: str | None = None
|
||||
last_error: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskProgress:
|
||||
percent: int = 0
|
||||
current_step: str = "pending"
|
||||
completed_steps: list[str] = field(default_factory=list)
|
||||
remaining_steps: list[str] = field(default_factory=list)
|
||||
blocked_reason: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AIOSTaskRecord:
|
||||
task_id: str
|
||||
title: str
|
||||
assigned_by: str
|
||||
executor: str
|
||||
source: str
|
||||
status: str = "pending"
|
||||
priority: str = "normal"
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
started_at: str | None = None
|
||||
updated_at: str = field(default_factory=utc_now)
|
||||
completed_at: str | None = None
|
||||
progress: TaskProgress = field(default_factory=TaskProgress)
|
||||
notification: TaskNotificationStatus = field(default_factory=TaskNotificationStatus)
|
||||
owner_gate_required: bool = False
|
||||
owner_gate_status: str | None = None
|
||||
evidence: list[str] = field(default_factory=list)
|
||||
|
||||
def elapsed_seconds(self) -> int:
|
||||
start = parse_time(self.started_at) or parse_time(self.created_at)
|
||||
end = parse_time(self.completed_at) or datetime.now(timezone.utc)
|
||||
return int((end - start).total_seconds()) if start else 0
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = asdict(self)
|
||||
data["elapsed_seconds"] = self.elapsed_seconds()
|
||||
data["progress"] = self.progress.to_dict()
|
||||
data["notification"] = self.notification.to_dict()
|
||||
return data
|
||||
|
||||
|
||||
class TaskCenter:
|
||||
def summarize(self, tasks: list[AIOSTaskRecord] | list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
raw_tasks = tasks or []
|
||||
task_dicts = [t.to_dict() if hasattr(t, "to_dict") else dict(t) for t in raw_tasks]
|
||||
active = [t for t in task_dicts if t.get("status") in {"running", "pending", "blocked"}]
|
||||
blocked = [t for t in task_dicts if t.get("status") == "blocked"]
|
||||
completed = [t for t in task_dicts if t.get("status") == "completed"]
|
||||
notification_pending = [
|
||||
t for t in completed
|
||||
if t.get("notification", {}).get("required") and not t.get("notification", {}).get("sent")
|
||||
]
|
||||
return {
|
||||
"module": "task-center",
|
||||
"route": "/admin/ai-evolution/tasks",
|
||||
"title": "AIOS 任务中心",
|
||||
"status": "attention_required" if blocked or notification_pending else "healthy",
|
||||
"generated_at": utc_now(),
|
||||
"summary": {
|
||||
"total": len(task_dicts),
|
||||
"active": len(active),
|
||||
"blocked": len(blocked),
|
||||
"completed": len(completed),
|
||||
"notification_pending": len(notification_pending),
|
||||
},
|
||||
"tasks": sorted(task_dicts, key=lambda t: t.get("updated_at", ""), reverse=True),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TaskNotificationStatus",
|
||||
"TaskProgress",
|
||||
"AIOSTaskRecord",
|
||||
"TaskCenter",
|
||||
]
|
||||
@@ -1,34 +0,0 @@
|
||||
"""Task Center API."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
|
||||
from .task_center import TaskCenter
|
||||
|
||||
|
||||
task_center = TaskCenter()
|
||||
|
||||
|
||||
def get_task_overview(tasks: list[dict[str, Any]] | None = None):
|
||||
return task_center.summarize(tasks)
|
||||
|
||||
|
||||
def build_router():
|
||||
if APIRouter is None:
|
||||
return None
|
||||
router = APIRouter(prefix="/admin/ai-evolution/tasks", tags=["task-center"])
|
||||
|
||||
@router.get("/overview", operation_id="getTaskCenterOverview")
|
||||
def overview():
|
||||
return get_task_overview()
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = ["router", "get_task_overview"]
|
||||
@@ -1,81 +0,0 @@
|
||||
"""Task Center visualization model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class TaskDashboardView:
|
||||
def build(self, tasks: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
tasks = tasks or []
|
||||
return {
|
||||
"module": "task-center",
|
||||
"route": "/admin/ai-evolution/tasks",
|
||||
"title": "AIOS Task Center",
|
||||
"views": {
|
||||
"kanban": self._kanban(tasks),
|
||||
"timeline": self._timeline(tasks),
|
||||
"flow": self._flow(tasks),
|
||||
"statistics": self._statistics(tasks),
|
||||
},
|
||||
"tasks": [self._task_card(task) for task in tasks],
|
||||
}
|
||||
|
||||
def _task_card(self, task: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"task_id": task.get("task_id"),
|
||||
"title": task.get("title"),
|
||||
"assigned_by": task.get("assigned_by"),
|
||||
"executor": task.get("executor"),
|
||||
"status": task.get("status"),
|
||||
"progress": task.get("progress", {}),
|
||||
"elapsed_seconds": task.get("elapsed_seconds", 0),
|
||||
"remaining_steps": task.get("progress", {}).get("remaining_steps", []),
|
||||
"notification": task.get("notification", {}),
|
||||
"actions": [
|
||||
"start",
|
||||
"update_progress",
|
||||
"block",
|
||||
"request_approval",
|
||||
"approve",
|
||||
"reject",
|
||||
"retry",
|
||||
"complete",
|
||||
"notify",
|
||||
"close",
|
||||
],
|
||||
}
|
||||
|
||||
def _kanban(self, tasks):
|
||||
columns = {"pending": [], "running": [], "blocked": [], "waiting_approval": [], "completed": []}
|
||||
for task in tasks:
|
||||
columns.setdefault(task.get("status", "pending"), []).append(task.get("task_id"))
|
||||
return columns
|
||||
|
||||
def _timeline(self, tasks):
|
||||
return [
|
||||
{
|
||||
"task_id": task.get("task_id"),
|
||||
"updated_at": task.get("updated_at"),
|
||||
"status": task.get("status"),
|
||||
}
|
||||
for task in tasks
|
||||
]
|
||||
|
||||
def _flow(self, tasks):
|
||||
return {
|
||||
"nodes": ["created", "assigned", "running", "blocked", "approval", "completed", "closed"],
|
||||
"task_count": len(tasks),
|
||||
}
|
||||
|
||||
def _statistics(self, tasks):
|
||||
return {
|
||||
"total": len(tasks),
|
||||
"running": len([t for t in tasks if t.get("status") == "running"]),
|
||||
"blocked": len([t for t in tasks if t.get("status") == "blocked"]),
|
||||
"waiting_approval": len([t for t in tasks if t.get("status") == "waiting_approval"]),
|
||||
"completed": len([t for t in tasks if t.get("status") == "completed"]),
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["TaskDashboardView"]
|
||||
@@ -1,129 +0,0 @@
|
||||
"""Task Center event bridge.
|
||||
|
||||
Converts task lifecycle actions and agent execution events into normalized AIOS
|
||||
Task Center events. Uses duck-typed Agent Event Bus publisher when available and
|
||||
an in-memory fallback for tests/offline runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class TaskEventPublisher(Protocol):
|
||||
def publish(
|
||||
self,
|
||||
kind: str,
|
||||
source: str,
|
||||
target: str,
|
||||
summary: str,
|
||||
payload: dict[str, Any],
|
||||
correlation_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskEvent:
|
||||
id: str
|
||||
kind: str
|
||||
source: str
|
||||
target: str
|
||||
summary: str
|
||||
payload: dict[str, Any]
|
||||
correlation_id: str | None = None
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class InMemoryTaskEventBus:
|
||||
def __init__(self):
|
||||
self.events: list[dict[str, Any]] = []
|
||||
|
||||
def publish(
|
||||
self,
|
||||
kind: str,
|
||||
source: str,
|
||||
target: str,
|
||||
summary: str,
|
||||
payload: dict[str, Any],
|
||||
correlation_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
event = TaskEvent(
|
||||
id=f"task_evt_{uuid4().hex[:12]}",
|
||||
kind=kind,
|
||||
source=source,
|
||||
target=target,
|
||||
summary=summary,
|
||||
payload=payload,
|
||||
correlation_id=correlation_id,
|
||||
).to_dict()
|
||||
self.events.append(event)
|
||||
return event
|
||||
|
||||
|
||||
class TaskEventBridge:
|
||||
def __init__(self, bus: TaskEventPublisher | None = None):
|
||||
self.bus = bus or InMemoryTaskEventBus()
|
||||
|
||||
def task_created(self, task: dict[str, Any]) -> dict[str, Any]:
|
||||
return self.bus.publish(
|
||||
kind="task_created",
|
||||
source=task.get("assigned_by", "unknown"),
|
||||
target=task.get("executor", "task-center"),
|
||||
summary=task.get("title", "task created"),
|
||||
payload=task,
|
||||
correlation_id=task.get("task_id"),
|
||||
)
|
||||
|
||||
def task_updated(self, task: dict[str, Any], action: str) -> dict[str, Any]:
|
||||
return self.bus.publish(
|
||||
kind="task_updated",
|
||||
source=task.get("executor", "task-center"),
|
||||
target="task-center",
|
||||
summary=f"task {action}: {task.get('status')}",
|
||||
payload={"action": action, "task": task},
|
||||
correlation_id=task.get("task_id"),
|
||||
)
|
||||
|
||||
def task_blocked(self, task: dict[str, Any], reason: str) -> dict[str, Any]:
|
||||
return self.bus.publish(
|
||||
kind="task_blocked",
|
||||
source=task.get("executor", "unknown"),
|
||||
target="owner-gate",
|
||||
summary=reason,
|
||||
payload={"task": task, "reason": reason},
|
||||
correlation_id=task.get("task_id"),
|
||||
)
|
||||
|
||||
def approval_requested(self, task: dict[str, Any], reason: str) -> dict[str, Any]:
|
||||
return self.bus.publish(
|
||||
kind="task_approval_requested",
|
||||
source=task.get("executor", "unknown"),
|
||||
target="owner-gate",
|
||||
summary=reason,
|
||||
payload={"task": task, "reason": reason},
|
||||
correlation_id=task.get("task_id"),
|
||||
)
|
||||
|
||||
def notification_sent(self, task: dict[str, Any], channel: str) -> dict[str, Any]:
|
||||
return self.bus.publish(
|
||||
kind="task_notification_sent",
|
||||
source="notification-agent",
|
||||
target=task.get("assigned_by", "owner"),
|
||||
summary=f"notification sent via {channel}",
|
||||
payload={"task": task, "channel": channel},
|
||||
correlation_id=task.get("task_id"),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["TaskEvent", "InMemoryTaskEventBus", "TaskEventBridge"]
|
||||
@@ -1,147 +0,0 @@
|
||||
"""AIOS Task Lifecycle.
|
||||
|
||||
Defines a governed task state machine for task creation, assignment, execution,
|
||||
blocking, approval, retry, completion, notification and closure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
TERMINAL_STATES = {"completed", "cancelled", "failed", "closed"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskTransition:
|
||||
action: str
|
||||
from_states: set[str]
|
||||
to_state: str
|
||||
required_role: str = "agent"
|
||||
requires_reason: bool = False
|
||||
notify: bool = False
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = asdict(self)
|
||||
data["from_states"] = sorted(self.from_states)
|
||||
return data
|
||||
|
||||
|
||||
class TaskLifecycle:
|
||||
"""Central task state machine."""
|
||||
|
||||
TRANSITIONS = {
|
||||
"assign": TaskTransition("assign", {"new", "pending"}, "assigned", "dispatcher"),
|
||||
"start": TaskTransition("start", {"assigned", "pending"}, "running", "executor"),
|
||||
"update_progress": TaskTransition("update_progress", {"running"}, "running", "executor"),
|
||||
"block": TaskTransition("block", {"running", "assigned", "pending"}, "blocked", "executor", requires_reason=True, notify=True),
|
||||
"request_approval": TaskTransition("request_approval", {"running", "blocked"}, "waiting_approval", "executor", requires_reason=True, notify=True),
|
||||
"approve": TaskTransition("approve", {"waiting_approval"}, "approved", "owner", notify=True),
|
||||
"reject": TaskTransition("reject", {"waiting_approval"}, "rejected", "owner", requires_reason=True, notify=True),
|
||||
"resume": TaskTransition("resume", {"blocked", "approved"}, "running", "executor"),
|
||||
"retry": TaskTransition("retry", {"failed", "blocked"}, "running", "executor", notify=True),
|
||||
"complete": TaskTransition("complete", {"running", "approved"}, "completed", "executor", notify=True),
|
||||
"notify": TaskTransition("notify", {"completed"}, "completed", "notifier"),
|
||||
"close": TaskTransition("close", {"completed", "cancelled", "failed"}, "closed", "owner"),
|
||||
"cancel": TaskTransition("cancel", {"new", "pending", "assigned", "running", "blocked", "waiting_approval"}, "cancelled", "owner", requires_reason=True, notify=True),
|
||||
"fail": TaskTransition("fail", {"running", "assigned"}, "failed", "executor", requires_reason=True, notify=True),
|
||||
}
|
||||
|
||||
ROLE_ORDER = {
|
||||
"user": 1,
|
||||
"agent": 2,
|
||||
"executor": 3,
|
||||
"dispatcher": 4,
|
||||
"notifier": 4,
|
||||
"group_admin": 5,
|
||||
"owner": 9,
|
||||
}
|
||||
|
||||
def available_actions(self, task: dict[str, Any], role: str) -> list[dict[str, Any]]:
|
||||
state = task.get("status", "new")
|
||||
actions = []
|
||||
for name, transition in self.TRANSITIONS.items():
|
||||
allowed_state = state in transition.from_states
|
||||
allowed_role = self._role_allows(role, transition.required_role)
|
||||
actions.append({
|
||||
"action": name,
|
||||
"label": self._label(name),
|
||||
"enabled": allowed_state and allowed_role,
|
||||
"from_state": state,
|
||||
"to_state": transition.to_state,
|
||||
"required_role": transition.required_role,
|
||||
"requires_reason": transition.requires_reason,
|
||||
"notify": transition.notify,
|
||||
"disabled_reason": None if allowed_state and allowed_role else self._disabled_reason(allowed_state, allowed_role, transition),
|
||||
})
|
||||
return actions
|
||||
|
||||
def transition(self, task: dict[str, Any], action: str, actor_role: str, reason: str | None = None) -> dict[str, Any]:
|
||||
if action not in self.TRANSITIONS:
|
||||
raise ValueError(f"unknown task action: {action}")
|
||||
transition = self.TRANSITIONS[action]
|
||||
state = task.get("status", "new")
|
||||
if state not in transition.from_states:
|
||||
raise ValueError(f"action {action} not allowed from state {state}")
|
||||
if not self._role_allows(actor_role, transition.required_role):
|
||||
raise PermissionError(f"role {actor_role} cannot execute {action}; requires {transition.required_role}")
|
||||
if transition.requires_reason and not reason:
|
||||
raise ValueError(f"action {action} requires reason")
|
||||
updated = dict(task)
|
||||
updated["status"] = transition.to_state
|
||||
updated["updated_at"] = utc_now()
|
||||
history = list(updated.get("history", []))
|
||||
history.append({
|
||||
"action": action,
|
||||
"from": state,
|
||||
"to": transition.to_state,
|
||||
"actor_role": actor_role,
|
||||
"reason": reason,
|
||||
"ts": utc_now(),
|
||||
})
|
||||
updated["history"] = history
|
||||
if action == "complete":
|
||||
updated["completed_at"] = utc_now()
|
||||
notification = dict(updated.get("notification", {}))
|
||||
notification.setdefault("required", True)
|
||||
notification.setdefault("sent", False)
|
||||
updated["notification"] = notification
|
||||
return updated
|
||||
|
||||
def _role_allows(self, role: str, required: str) -> bool:
|
||||
return self.ROLE_ORDER.get(role, 0) >= self.ROLE_ORDER.get(required, 0)
|
||||
|
||||
def _label(self, action: str) -> str:
|
||||
labels = {
|
||||
"assign": "分配任务",
|
||||
"start": "开始执行",
|
||||
"update_progress": "更新进度",
|
||||
"block": "标记阻塞",
|
||||
"request_approval": "请求审批",
|
||||
"approve": "批准",
|
||||
"reject": "拒绝",
|
||||
"resume": "继续执行",
|
||||
"retry": "重试",
|
||||
"complete": "标记完成",
|
||||
"notify": "发送通知",
|
||||
"close": "关闭任务",
|
||||
"cancel": "取消任务",
|
||||
"fail": "标记失败",
|
||||
}
|
||||
return labels.get(action, action)
|
||||
|
||||
def _disabled_reason(self, allowed_state: bool, allowed_role: bool, transition: TaskTransition) -> str:
|
||||
if not allowed_state:
|
||||
return "当前状态不允许该操作"
|
||||
if not allowed_role:
|
||||
return f"需要角色: {transition.required_role}"
|
||||
return "不可用"
|
||||
|
||||
|
||||
__all__ = ["TaskTransition", "TaskLifecycle", "TERMINAL_STATES"]
|
||||
@@ -1,50 +0,0 @@
|
||||
"""Task Center orchestrator.
|
||||
|
||||
Coordinates task ingestion, persistence, lifecycle updates, events and statistics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .task_event_bridge import TaskEventBridge
|
||||
from .task_source_ingestor import TaskSourceIngestor
|
||||
from .task_statistics import TaskStatistics
|
||||
from .task_store import TaskStore
|
||||
|
||||
|
||||
class TaskOrchestrator:
|
||||
def __init__(self):
|
||||
self.ingestor = TaskSourceIngestor()
|
||||
self.store = TaskStore()
|
||||
self.events = TaskEventBridge()
|
||||
self.statistics = TaskStatistics()
|
||||
|
||||
def create_from_command(self, path: str, bucket: str = "active") -> dict[str, Any]:
|
||||
task = self.ingestor.command_file_to_task(path, bucket)
|
||||
self.store.append(task)
|
||||
self.events.task_created(task)
|
||||
return task
|
||||
|
||||
def create_from_agent_execution(self, record: dict[str, Any]) -> dict[str, Any]:
|
||||
task = self.ingestor.agent_execution_to_task(record)
|
||||
self.store.append(task)
|
||||
self.events.task_created(task)
|
||||
return task
|
||||
|
||||
def sync_event(self, event: dict[str, Any]) -> dict[str, Any] | None:
|
||||
task = self.ingestor.event_to_task(event)
|
||||
if task:
|
||||
self.store.append(task)
|
||||
self.events.task_updated(task, event.get("kind", "event"))
|
||||
return task
|
||||
|
||||
def dashboard(self) -> dict[str, Any]:
|
||||
tasks = self.store.latest()
|
||||
return {
|
||||
"tasks": tasks,
|
||||
"statistics": self.statistics.summarize(tasks),
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["TaskOrchestrator"]
|
||||
@@ -1,159 +0,0 @@
|
||||
"""Task source ingestion for AIOS Task Center.
|
||||
|
||||
Converts Command Watcher files, OpenClaw/Hermes/Codex execution records and
|
||||
Generic Agent Event Bus records into normalized Task Center task dictionaries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskSourceRecord:
|
||||
source_type: str
|
||||
source_id: str
|
||||
title: str
|
||||
assigned_by: str
|
||||
executor: str
|
||||
status: str
|
||||
priority: str = "normal"
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
updated_at: str = field(default_factory=utc_now)
|
||||
evidence: list[str] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_task(self) -> dict[str, Any]:
|
||||
return {
|
||||
"task_id": f"task-{self.source_type}-{self.source_id}",
|
||||
"title": self.title,
|
||||
"assigned_by": self.assigned_by,
|
||||
"executor": self.executor,
|
||||
"source": self.source_type,
|
||||
"status": self.status,
|
||||
"priority": self.priority,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
"progress": {
|
||||
"percent": self._progress_percent(),
|
||||
"current_step": self.status,
|
||||
"completed_steps": [],
|
||||
"remaining_steps": [],
|
||||
"blocked_reason": self.metadata.get("blocked_reason"),
|
||||
},
|
||||
"notification": {
|
||||
"required": True,
|
||||
"sent": bool(self.metadata.get("notification_sent")),
|
||||
"channel": self.metadata.get("notification_channel"),
|
||||
"sent_at": self.metadata.get("notification_sent_at"),
|
||||
},
|
||||
"evidence": self.evidence,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
def _progress_percent(self) -> int:
|
||||
mapping = {
|
||||
"new": 0,
|
||||
"pending": 5,
|
||||
"assigned": 15,
|
||||
"running": 50,
|
||||
"blocked": 50,
|
||||
"waiting_approval": 60,
|
||||
"approved": 70,
|
||||
"completed": 100,
|
||||
"closed": 100,
|
||||
"failed": 100,
|
||||
"cancelled": 100,
|
||||
}
|
||||
return mapping.get(self.status, 0)
|
||||
|
||||
|
||||
class TaskSourceIngestor:
|
||||
COMMAND_STATUS_MAP = {
|
||||
"active": "pending",
|
||||
"running": "running",
|
||||
"completed": "completed",
|
||||
"failed": "failed",
|
||||
"archive": "closed",
|
||||
}
|
||||
|
||||
def command_file_to_task(self, path: str, bucket: str = "active") -> dict[str, Any]:
|
||||
p = Path(path)
|
||||
title = p.stem
|
||||
return TaskSourceRecord(
|
||||
source_type="command_watcher",
|
||||
source_id=title,
|
||||
title=title,
|
||||
assigned_by="command-watcher",
|
||||
executor=self._infer_executor(title),
|
||||
status=self.COMMAND_STATUS_MAP.get(bucket, "pending"),
|
||||
priority="high" if "P0" in title or "fix" in title.lower() else "normal",
|
||||
evidence=[str(p)],
|
||||
metadata={"bucket": bucket},
|
||||
).to_task()
|
||||
|
||||
def agent_execution_to_task(self, record: dict[str, Any]) -> dict[str, Any]:
|
||||
source_id = record.get("execution_id", record.get("id", "unknown"))
|
||||
status = record.get("status", "running")
|
||||
return TaskSourceRecord(
|
||||
source_type="agent_execution",
|
||||
source_id=source_id,
|
||||
title=record.get("title", f"Agent execution {source_id}"),
|
||||
assigned_by=record.get("assigned_by", record.get("entry_agent", "agent-system")),
|
||||
executor=record.get("closed_by", record.get("entry_agent", "unknown-agent")),
|
||||
status=status,
|
||||
priority=record.get("priority", "normal"),
|
||||
created_at=record.get("started_at", utc_now()),
|
||||
updated_at=record.get("ended_at", utc_now()),
|
||||
evidence=[record.get("execution_id", source_id)],
|
||||
metadata=record,
|
||||
).to_task()
|
||||
|
||||
def event_to_task(self, event: dict[str, Any]) -> dict[str, Any] | None:
|
||||
kind = event.get("kind")
|
||||
payload = event.get("payload", {})
|
||||
if kind not in {"task_created", "task_updated", "agent_started", "agent_completed", "agent_failed", "search_started", "search_completed"}:
|
||||
return None
|
||||
source_id = event.get("correlation_id") or event.get("id", "event")
|
||||
status = self._status_from_event(kind, payload)
|
||||
return TaskSourceRecord(
|
||||
source_type="event_bus",
|
||||
source_id=source_id,
|
||||
title=payload.get("title") or event.get("summary", kind),
|
||||
assigned_by=event.get("source", "event-bus"),
|
||||
executor=event.get("target", "task-center"),
|
||||
status=status,
|
||||
created_at=event.get("created_at", utc_now()),
|
||||
updated_at=event.get("created_at", utc_now()),
|
||||
evidence=[event.get("id", source_id)],
|
||||
metadata={"event_kind": kind, "payload": payload},
|
||||
).to_task()
|
||||
|
||||
def _status_from_event(self, kind: str, payload: dict[str, Any]) -> str:
|
||||
if kind.endswith("completed"):
|
||||
return "completed"
|
||||
if kind.endswith("failed"):
|
||||
return "failed"
|
||||
return payload.get("status", "running")
|
||||
|
||||
def _infer_executor(self, title: str) -> str:
|
||||
lower = title.lower()
|
||||
if "openclaw" in lower:
|
||||
return "openclaw"
|
||||
if "hermes" in lower:
|
||||
return "hermes"
|
||||
if "codex" in lower:
|
||||
return "codex"
|
||||
if "search" in lower:
|
||||
return "openclaw"
|
||||
return "openclaw"
|
||||
|
||||
|
||||
__all__ = ["TaskSourceRecord", "TaskSourceIngestor"]
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Task Center statistics."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class TaskStatistics:
|
||||
def summarize(self, tasks: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
tasks = tasks or []
|
||||
completed = [t for t in tasks if t.get("status") == "completed"]
|
||||
blocked = [t for t in tasks if t.get("status") == "blocked"]
|
||||
waiting = [t for t in tasks if t.get("status") == "waiting_approval"]
|
||||
elapsed = [t.get("elapsed_seconds", 0) for t in tasks if t.get("elapsed_seconds") is not None]
|
||||
return {
|
||||
"total": len(tasks),
|
||||
"completed": len(completed),
|
||||
"blocked": len(blocked),
|
||||
"waiting_approval": len(waiting),
|
||||
"completion_rate": round(len(completed) / len(tasks) * 100, 2) if tasks else 0,
|
||||
"average_elapsed_seconds": round(sum(elapsed) / len(elapsed), 2) if elapsed else 0,
|
||||
"top_blockers": self._top_values(blocked, "blocker"),
|
||||
"executor_distribution": self._top_values(tasks, "executor"),
|
||||
}
|
||||
|
||||
def _top_values(self, tasks: list[dict[str, Any]], key: str):
|
||||
result: dict[str, int] = {}
|
||||
for task in tasks:
|
||||
value = task.get(key)
|
||||
if value:
|
||||
result[value] = result.get(value, 0) + 1
|
||||
return result
|
||||
|
||||
|
||||
__all__ = ["TaskStatistics"]
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Persistent Task Center store."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class TaskStore:
|
||||
def __init__(self, root: str = "runtime/task-center"):
|
||||
self.root = Path(root)
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
self.path = self.root / "tasks.jsonl"
|
||||
|
||||
def append(self, task: dict[str, Any]) -> None:
|
||||
with self.path.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(task, ensure_ascii=False) + "\n")
|
||||
|
||||
def list_all(self) -> list[dict[str, Any]]:
|
||||
if not self.path.exists():
|
||||
return []
|
||||
return [json.loads(line) for line in self.path.read_text(encoding="utf-8").splitlines() if line]
|
||||
|
||||
def latest(self) -> list[dict[str, Any]]:
|
||||
return self.list_all()[-100:]
|
||||
|
||||
|
||||
__all__ = ["TaskStore"]
|
||||
@@ -0,0 +1,43 @@
|
||||
CREATE TABLE IF NOT EXISTS portal_roles (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
group_id TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portal_approvals (
|
||||
id TEXT PRIMARY KEY,
|
||||
request_type TEXT NOT NULL,
|
||||
requester_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
scope TEXT NOT NULL,
|
||||
group_id TEXT,
|
||||
payload_json TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portal_alerts (
|
||||
id TEXT PRIMARY KEY,
|
||||
level TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'open',
|
||||
scope TEXT NOT NULL,
|
||||
group_id TEXT,
|
||||
owner_user_id TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portal_audit_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
actor_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
resource_type TEXT,
|
||||
resource_id TEXT,
|
||||
outcome TEXT NOT NULL DEFAULT 'success',
|
||||
detail_json TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -1,2 +0,0 @@
|
||||
# Agent Acknowledgement
|
||||
Initialized by CMD-20260708-fix-git-actions-write-and-enable-audit-plane
|
||||
@@ -1,36 +0,0 @@
|
||||
# CMD-20260708-fix-git-actions-write-and-enable-audit-plane
|
||||
|
||||
## Command ID
|
||||
CMD-20260708-fix-git-actions-write-and-enable-audit-plane
|
||||
|
||||
## Priority
|
||||
P0
|
||||
|
||||
## Mode
|
||||
autonomous
|
||||
|
||||
## Target
|
||||
Primary: OpenClaw
|
||||
Supporting: Hermes
|
||||
Review: Codex
|
||||
|
||||
## Objective
|
||||
解决当前 AIOS Git Actions 写权限问题,恢复 ChatGPT Actions → AIOS GitOps API → Git Commit 链路,
|
||||
同时建立 Agent 全生命周期审计体系。
|
||||
|
||||
## Status
|
||||
- Part 1 (Fix Git Actions Write Permission): ✅ COMPLETE
|
||||
- Part 2 (Agent Command Plane Structure): ✅ COMPLETE
|
||||
- Part 3 (Agent Execution Audit): ✅ COMPLETE
|
||||
- Part 4 (Conversation Trace): 🚧 IN PROGRESS
|
||||
- Part 5 (Version Association): ⏳ PENDING
|
||||
- Part 6 (Web UI Integration): ⏳ PENDING
|
||||
- Part 7 (Evidence): ⏳ PENDING
|
||||
- Part 8 (Regression Test): ⏳ PENDING
|
||||
|
||||
## Completion Criteria
|
||||
1. Code committed: agents/, audit/, evidence/, web-ui/
|
||||
2. Doc: docs/architecture/AIOS_AGENT_AUDIT_PLANE.md
|
||||
3. Report: evidence/CMD-20260708.../final-report.md
|
||||
4. Output: AIOS_AGENT_AUDIT_PLANE_READY, AIOS_GIT_ACTION_WRITE_READY
|
||||
5. Next command created: CMD-20260709-autonomous-controller-enable
|
||||
@@ -1,35 +0,0 @@
|
||||
# CMD-20260709-enable-autonomous-controller
|
||||
|
||||
## Command ID
|
||||
CMD-20260709-enable-autonomous-controller
|
||||
|
||||
## Priority
|
||||
P0
|
||||
|
||||
## Mode
|
||||
autonomous
|
||||
|
||||
## Target
|
||||
Primary: OpenClaw
|
||||
Supporting: Hermes
|
||||
Review: Codex
|
||||
|
||||
## Objective
|
||||
Enable autonomous controller for AIOS GitOps:
|
||||
- Auto-detect issues
|
||||
- Auto-diagnose
|
||||
- Auto-generate fixes
|
||||
- Auto-test
|
||||
- Auto-create patches
|
||||
- Auto-request approval
|
||||
|
||||
## Preconditions
|
||||
- Agent Command Plane initialized (CMD-BOOTSTRAP-001)
|
||||
- Git Actions write permission fixed (CMD-20260708)
|
||||
- Audit plane enabled (CMD-20260708)
|
||||
- Notification service enabled (CMD-20260708)
|
||||
|
||||
## Completion Criteria
|
||||
- Autonomous controller service running
|
||||
- Can detect, diagnose, fix, and report
|
||||
- Pipeline: detect → diagnose → fix → test → patch → approve → commit → notify
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
# CMD-20260708-fix-git-actions-write-and-enable-audit-plane
|
||||
|
||||
## Command ID
|
||||
CMD-20260708-fix-git-actions-write-and-enable-audit-plane
|
||||
|
||||
## Priority
|
||||
P0
|
||||
|
||||
## Mode
|
||||
autonomous
|
||||
|
||||
## Target
|
||||
Primary: OpenClaw
|
||||
Supporting: Hermes
|
||||
Review: Codex
|
||||
|
||||
## Objective
|
||||
解决当前 AIOS Git Actions 写权限问题,恢复 ChatGPT Actions → AIOS GitOps API → Git Commit 链路,
|
||||
同时建立 Agent 全生命周期审计体系。
|
||||
|
||||
## Status
|
||||
- Part 1 (Fix Git Actions Write Permission): ✅ COMPLETE
|
||||
- Part 2 (Agent Command Plane Structure): ✅ COMPLETE
|
||||
- Part 3 (Agent Execution Audit): ✅ COMPLETE
|
||||
- Part 4 (Conversation Trace): 🚧 IN PROGRESS
|
||||
- Part 5 (Version Association): ⏳ PENDING
|
||||
- Part 6 (Web UI Integration): ⏳ PENDING
|
||||
- Part 7 (Evidence): ⏳ PENDING
|
||||
- Part 8 (Regression Test): ⏳ PENDING
|
||||
|
||||
## Completion Criteria
|
||||
1. Code committed: agents/, audit/, evidence/, web-ui/
|
||||
2. Doc: docs/architecture/AIOS_AGENT_AUDIT_PLANE.md
|
||||
3. Report: evidence/CMD-20260708.../final-report.md
|
||||
4. Output: AIOS_AGENT_AUDIT_PLANE_READY, AIOS_GIT_ACTION_WRITE_READY
|
||||
5. Next command created: CMD-20260709-autonomous-controller-enable
|
||||
@@ -1,35 +0,0 @@
|
||||
# CMD-20260709-enable-autonomous-controller
|
||||
|
||||
## Command ID
|
||||
CMD-20260709-enable-autonomous-controller
|
||||
|
||||
## Priority
|
||||
P0
|
||||
|
||||
## Mode
|
||||
autonomous
|
||||
|
||||
## Target
|
||||
Primary: OpenClaw
|
||||
Supporting: Hermes
|
||||
Review: Codex
|
||||
|
||||
## Objective
|
||||
Enable autonomous controller for AIOS GitOps:
|
||||
- Auto-detect issues
|
||||
- Auto-diagnose
|
||||
- Auto-generate fixes
|
||||
- Auto-test
|
||||
- Auto-create patches
|
||||
- Auto-request approval
|
||||
|
||||
## Preconditions
|
||||
- Agent Command Plane initialized (CMD-BOOTSTRAP-001)
|
||||
- Git Actions write permission fixed (CMD-20260708)
|
||||
- Audit plane enabled (CMD-20260708)
|
||||
- Notification service enabled (CMD-20260708)
|
||||
|
||||
## Completion Criteria
|
||||
- Autonomous controller service running
|
||||
- Can detect, diagnose, fix, and report
|
||||
- Pipeline: detect → diagnose → fix → test → patch → approve → commit → notify
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783527185",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "10 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T00:13:05+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783529900",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "10 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T00:58:20+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783529914",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "10 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T00:58:34+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783530514",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "10 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T01:08:34+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783531114",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "10 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T01:18:34+0800"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user