Compare 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,32 +0,0 @@
|
||||
{
|
||||
"contract_commit": "cbe33ecab8e9794ba5f318bea43aeaf447a56fbc",
|
||||
"created_at": "2026-07-17T14:13:38+00:00",
|
||||
"schema_version": 1,
|
||||
"state_writer": "openclaw",
|
||||
"work_units": [
|
||||
{
|
||||
"fallback": "codex",
|
||||
"gate": "R0_RUNNER_LIVE_RECOVERY",
|
||||
"id": "R0-DISCOVER-SECRET-PROVIDER",
|
||||
"primary": "openclaw",
|
||||
"status": "READY",
|
||||
"track": "R_RUNNER"
|
||||
},
|
||||
{
|
||||
"fallback": "codex",
|
||||
"gate": "P2_REAL_B3_EVIDENCE",
|
||||
"id": "P2-INVENTORY-REAL-EVIDENCE",
|
||||
"primary": "openclaw",
|
||||
"status": "READY",
|
||||
"track": "P_PERSONAL_WEIXIN"
|
||||
},
|
||||
{
|
||||
"fallback": "openclaw",
|
||||
"gate": "P1_REPAIR3_CODE",
|
||||
"id": "P1-POST-CONTRACT-REACK",
|
||||
"primary": "codex",
|
||||
"status": "READY",
|
||||
"track": "P_PERSONAL_WEIXIN"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
{
|
||||
"adaptive_dispatch": {
|
||||
"codex_first": [
|
||||
"complex_code",
|
||||
"security_boundary",
|
||||
"architecture",
|
||||
"high_risk_review",
|
||||
"strong_model_materially_better"
|
||||
],
|
||||
"default_orchestrator": "openclaw",
|
||||
"dual_required": [
|
||||
"release_gate",
|
||||
"crypto_or_identity_change",
|
||||
"production_migration_or_rollback"
|
||||
],
|
||||
"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,
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"contract_id": "V2R1-PERSONAL-WEIXIN-PRODUCTION-EXECUTION-CONTRACT",
|
||||
"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-17T14:13:38+00:00",
|
||||
"handoff": {
|
||||
"ack": "CONTRACT_ACK=<full_contract_commit_oid>",
|
||||
"advance": "independent_verification_required",
|
||||
"all_agents_match": true
|
||||
},
|
||||
"join_gate": {
|
||||
"requires": [
|
||||
"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": [
|
||||
"MAIN_MERGE",
|
||||
"STAGING_AND_CANARY",
|
||||
"PRODUCTION"
|
||||
],
|
||||
"schema_version": 3,
|
||||
"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"
|
||||
],
|
||||
"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,55 +0,0 @@
|
||||
---
|
||||
contract: cbe33ecab8e9794ba5f318bea43aeaf447a56fbc
|
||||
work_unit: A0-CODEX-AUTOMATION-CLOSURE
|
||||
status: PASS
|
||||
base_oid: 9195470ceb78fd57ffface3dcc38f40b75efa9d8
|
||||
baseline_remediation_commit: 378cc4d56ab58dde7da8814846ffd64a979b23f6
|
||||
orchestrator_implementation_oid: 95fc4d37f0b9502945e5ba97a1f8e5a146768f6b
|
||||
integration_merge_commit: 1b3b74fd626028ae5d5f11ae6933953fecf57df5
|
||||
implementation_commit: c3984ca2b4388875d2875b4fdfdf95a2984f6ab3
|
||||
full_unittest: PASS_78
|
||||
full_pytest: PASS_154_PLUS_34_SUBTESTS
|
||||
python_compile: PASS
|
||||
bash_syntax: PASS
|
||||
json_schema_openapi: PASS
|
||||
systemd_verify: PASS
|
||||
gitleaks_final_tree: PASS
|
||||
gitleaks_head_history: PASS_297_COMMITS
|
||||
gitleaks_implementation_range: PASS
|
||||
codex_auto_claim: PASS
|
||||
worktree_oid: c3984ca2b4388875d2875b4fdfdf95a2984f6ab3
|
||||
bare_oid: c3984ca2b4388875d2875b4fdfdf95a2984f6ab3
|
||||
gitea_oid: c3984ca2b4388875d2875b4fdfdf95a2984f6ab3
|
||||
all_equal: true
|
||||
---
|
||||
|
||||
# A0 Codex automation closure result
|
||||
|
||||
## A0-C1 baseline remediation
|
||||
|
||||
Lineage showed that `tests/test_wechat_send_backend.py` entered the authoritative ancestry without its source, while no source-deletion commit existed. The approved hardened source/test blobs were restored through their direct lineage. Relevant unittest and pytest each passed 26 tests; the remediation commit was pushed with equal worktree, bare, and Gitea OIDs.
|
||||
|
||||
## A0-C2 Gateway integration
|
||||
|
||||
The remediation commit and Gateway implementation were combined by a normal two-parent merge. Gateway tests passed, as did the repository suites and the compile, Bash, JSON/schema, OpenAPI, systemd, diff, rollback, and Gitleaks gates.
|
||||
|
||||
## A0-C3 persistent Codex watcher
|
||||
|
||||
The watcher is installed as a five-second systemd timer under `aios-orchestrator`. It uses a SQLite unique `work_unit_id`, optimistic atomic leases, stale-lease recovery, phase persistence, bounded retries, dead-letter handling, exact contract rejection, durable idempotent lifecycle events, sanitized executor environment, secure no-follow file reads, and a loopback-only Gateway endpoint. External execution is at-most-once: an ambiguous post-launch crash is not replayed.
|
||||
|
||||
The first installer run failed closed because systemd-tmpfiles rejected an ownership transition into the Gateway command tree. No task ran. The append-only repair limited tmpfiles to watcher-owned `/var` paths and validated the existing command-tree owner and modes before creating only the absent dead-letter directory.
|
||||
|
||||
The harmless live command was created in the production queue and claimed by the timer without manually starting the service. Watcher SQLite recorded `COMPLETED`, `DONE`, `attempts=1`; Gateway SQLite contained exact matching `work_unit_started` and `work_unit_ended` event JSON. The audit chain was:
|
||||
|
||||
```text
|
||||
COMMAND_CREATED
|
||||
-> CODEX_AUTO_CLAIM
|
||||
-> TASK_STARTED
|
||||
-> TASK_EXECUTED
|
||||
-> TASK_ENDED
|
||||
-> EVENT_POSTED
|
||||
```
|
||||
|
||||
The acceptance result retained only an input SHA-256 and exit code. A second live command with a wrong contract OID was moved to dead-letter, created no watcher command row, and posted no Gateway event. Journal scanning found no bearer token, API key, or secret pattern. The superseded broken watcher was disabled; the new timer is enabled and active.
|
||||
|
||||
The full all-ref Gitleaks diagnostic found four pre-existing findings only on unrelated refs (three governance/result documents, outside this ancestry). They were not treated as evidence for this work unit. The final tree, the complete `HEAD` ancestry, and the implementation range contained zero findings.
|
||||
@@ -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,15 +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,
|
||||
"production_release": "NOT_STARTED",
|
||||
"release": "paused",
|
||||
"repair3": "PASS_CANDIDATE_PREPARED",
|
||||
"runner": "BLOCKED_GATEWAY_SECRET_REF_UNAVAILABLE",
|
||||
"schema_version": 1,
|
||||
"single_state_writer": "openclaw",
|
||||
"updated_at": "2026-07-17T14:13:38+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.
|
||||
@@ -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