Skip to content

Commit ecb23c8

Browse files
Pigbibicodex
andcommitted
Add automation authority and ledger endpoints
Co-Authored-By: Codex <noreply@openai.com>
1 parent 09fc747 commit ecb23c8

11 files changed

Lines changed: 1024 additions & 3 deletions

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ When a source issue contains a `codex-pr-feedback` marker from a failed CI run o
3333

3434
This avoids hard-coding Codex CLI setup in every source repository and avoids depending on a repository outside the `QuantStrategyLab` organization.
3535

36+
Automation authority is intentionally split from model confidence. The service treats routine low-risk maintenance as auto-merge eligible, allows already-live same-family strategy optimization to auto-merge only when service-owned checks provide trusted proof, and keeps new/reconstructed strategies, live-candidate promotion, plugin position-control changes, broker/order execution changes, workflow permissions, and secrets behind human review. `POST /v1/ai/automation/authority` accepts request metadata only as restrictive hints; it cannot relax the policy for live-impacting automation.
37+
38+
Automation run state is persisted through the service ledger. Async Codex jobs are recorded automatically; external workflows can also call `POST /v1/ai/automation/runs`. Ledger ownership follows `source_repository` when present, otherwise the authenticated repository. Operators and dashboards can read `GET /v1/ai/automation/runs`, `GET /v1/ai/automation/runs/{run_id}`, and `GET /v1/ai/automation/control` to decide whether to continue, pause auto-fix, switch to review-only, or escalate.
39+
3640
## Compatibility governance role
3741

3842
`QuantStrategyLab/AIAuditBridge` is an ops/control-plane consumer only:

README.zh-CN.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ Codex 执行现在只走 service backend:workflow 从 GitHub-hosted runner 调
3333

3434
这样可以避免每个 source repository 都硬编码 Codex CLI,也不会依赖 `QuantStrategyLab` 组织外的仓库。
3535

36+
自动化授权层和模型置信度分开处理。service 将低风险日常维护视为可自动合并;已 live 的同族策略优化只有在 service-owned 检查给出 trusted proof 时才允许自动合并;新策略/重构策略、live-candidate 提升、插件 position-control、券商/订单执行、workflow 权限和 secrets 相关变更都保留人工复核。`POST /v1/ai/automation/authority` 接收的请求 metadata 只能作为降权提示,不能放宽 live 影响自动化 policy。
37+
38+
自动化运行状态通过 service ledger 持久化。异步 Codex job 会自动记录;外部 workflow 也可调用 `POST /v1/ai/automation/runs` 写入状态。Ledger owner 优先使用 `source_repository`,没有时回退到认证仓库。Dashboard 或运维脚本可读取 `GET /v1/ai/automation/runs``GET /v1/ai/automation/runs/{run_id}``GET /v1/ai/automation/control`,判断继续执行、暂停 auto-fix、切到 review-only 或升级人工处理。
39+
3640
## 兼容性治理定位
3741

3842
`QuantStrategyLab/AIAuditBridge` 只作为 ops/control-plane 的消费侧参与兼容治理:

docs/architecture.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ operations: **Analyze**, **Execute**, and **Review**.
4343
| `health.py` | Online endpoint metrics, error rates, latency tracking |
4444
| `quota.py` | Per-repo budget enforcement, model cost estimation |
4545
| `autonomy.py` | Confidence scoring, change risk classification |
46+
| `automation_authority.py` | Machine-readable live-impact authority policy |
47+
| `automation_run_ledger.py` | Persistent automation run state and runtime control suggestions |
4648
| `feedback.py` | Closed-loop change tracking and evaluation |
4749

4850
### Adapters (`service/adapters/`)
@@ -70,6 +72,10 @@ operations: **Analyze**, **Execute**, and **Review**.
7072
| `/v1/ai/health` | GET | Online service health snapshot | No |
7173
| `/healthz` | GET | Liveness check | No |
7274
| `/v1/ai/quota` | GET | Provider usage, Codex account limits, and internal estimates | No |
75+
| `/v1/ai/automation/authority` | POST | Evaluate auto/human-review authority for live-impacting changes | No |
76+
| `/v1/ai/automation/control` | GET | Convert health/quota/org-health into continue/review-only/pause/escalate | No |
77+
| `/v1/ai/automation/runs` | GET/POST | Read or record automation run ledger entries | No |
78+
| `/v1/ai/automation/runs/{id}` | GET | Read one automation run ledger entry | No |
7379
| `/v1/ai/feedback/*` | GET/POST | Change tracking and evaluation | No |
7480

7581
Health status vocabulary is defined in [`health_taxonomy.md`](health_taxonomy.md). Do not use `/v1/ai/health` as a substitute for monthly audit results, artifact freshness, or strategy-level health evidence.
@@ -114,6 +120,7 @@ POST /v1/ai/review
114120
→ optional Codex verification
115121
→ extract confidence scores
116122
→ compute consensus + recommended action
123+
→ cap action through automation authority policy
117124
→ return {results, consensus, recommended_action}
118125
```
119126

@@ -124,6 +131,13 @@ POST /v1/ai/review
124131
requirement.
125132
- **Authorization**: Source repository org must match OIDC claims repository org
126133
(prevents cross-org escalation).
134+
- **Automation authority**: Request metadata can only make a decision stricter.
135+
Live-equivalent auto-merge requires service-owned trusted proof; public
136+
endpoints cannot self-declare it.
137+
- **Automation ledger tenancy**: Ledger reads and writes use `source_repository`
138+
as owner when present, otherwise the authenticated repository.
139+
- **Automation control state**: Run task state may be reported by callers, but
140+
control action and health/quota/org-health fields are derived by the service.
127141
- **Sandbox**: Codex sandbox restricted to service-side allowlist (default: `read-only`).
128142
- **Codex reasoning effort**: `CODEX_AUDIT_SERVICE_REASONING_EFFORT` can hard
129143
override CLI effort; unset/`auto` routes low/medium/high by task complexity.

service/ai_gateway_service.py

Lines changed: 217 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import tempfile
2727
import threading
2828
import time
29+
from collections import Counter
2930
from http import HTTPStatus
3031
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
3132
from pathlib import Path
@@ -44,9 +45,15 @@
4445
from service.adapters.llm_adapter import LlmAdapter
4546
from service.adapters.codex_adapter import CodexAdapter
4647
from service.autonomy import (
48+
ACTION_AUTO_PR,
4749
load_autonomy_policy,
4850
recommended_action as compute_recommended_action,
4951
)
52+
from service.automation_authority import evaluate_automation_authority
53+
from service.automation_run_ledger import (
54+
get_automation_run_ledger,
55+
suggest_control_action,
56+
)
5057
from service.feedback import (
5158
write_change,
5259
read_change,
@@ -59,7 +66,7 @@
5966
_new_change_id,
6067
)
6168
from service.quota import get_quota_manager
62-
from service.task_state import job_task_state
69+
from service.task_state import TERMINAL_STATES, job_task_state
6370
from service.health import get_health_monitor
6471
from service.org_health import read_org_health
6572

@@ -394,6 +401,7 @@ def _mark_stale_job_failed(job: dict[str, Any]) -> dict[str, Any]:
394401
job["updated_at"] = _now()
395402
job["error"] = "codex audit job became stale before completion"
396403
_write_job(job)
404+
_record_job_automation_run(job)
397405
return job
398406

399407

@@ -425,6 +433,105 @@ def _public_job_payload(job: dict[str, Any]) -> dict[str, object]:
425433
return payload
426434

427435

436+
def _automation_control_snapshot(repo: str) -> dict[str, Any]:
437+
try:
438+
org_health = read_org_health()
439+
except Exception:
440+
org_health = {"status": "unavailable"}
441+
try:
442+
quota_status = get_quota_manager().runtime_status(repo or "unknown")
443+
except Exception:
444+
quota_status = {"status": "unavailable"}
445+
return suggest_control_action(get_health_monitor().status, quota_status, org_health)
446+
447+
448+
def _record_job_automation_run(job: dict[str, Any]) -> None:
449+
try:
450+
repo = str(job.get("source_repository") or job.get("repository") or "unknown")
451+
control = _automation_control_snapshot(repo)
452+
get_automation_run_ledger().record(
453+
str(job.get("job_id") or ""),
454+
job_task_state(job),
455+
task_name=str(job.get("task") or ""),
456+
suggested_action=str(control.get("action") or ""),
457+
service_health=str(control.get("service_health") or ""),
458+
quota_status=str(control.get("quota_status") or ""),
459+
org_health_status=str(control.get("org_health_status") or ""),
460+
metadata={
461+
"repository": repo,
462+
"source_repository": str(job.get("source_repository") or ""),
463+
"caller_repository": str(job.get("repository") or ""),
464+
"source_ref": str(job.get("source_ref") or ""),
465+
"mode": str(job.get("mode") or ""),
466+
},
467+
)
468+
except Exception as exc:
469+
_audit_log("automation_ledger_record_failed", job_id=job.get("job_id"), error=type(exc).__name__)
470+
471+
472+
def _automation_operator_claims(claims: dict[str, Any]) -> bool:
473+
return str(claims.get("auth_method") or "") in {"static_token", "none"}
474+
475+
476+
def _automation_run_owner_repository(run: dict[str, Any]) -> str:
477+
metadata = run.get("metadata") if isinstance(run.get("metadata"), dict) else {}
478+
return str(metadata.get("source_repository") or metadata.get("repository") or "")
479+
480+
481+
def _automation_run_access_allowed(
482+
run: dict[str, Any],
483+
claims: dict[str, Any],
484+
) -> bool:
485+
if _automation_operator_claims(claims):
486+
return True
487+
owner = _automation_run_owner_repository(run)
488+
caller_repository = str(claims.get("repository") or "")
489+
if bool(owner) and owner == caller_repository:
490+
return True
491+
metadata = run.get("metadata") if isinstance(run.get("metadata"), dict) else {}
492+
original_caller = str(metadata.get("caller_repository") or "")
493+
return bool(original_caller) and original_caller == caller_repository
494+
495+
496+
def _assert_automation_run_access(
497+
run: dict[str, Any],
498+
claims: dict[str, Any],
499+
) -> None:
500+
if not _automation_run_access_allowed(run, claims):
501+
raise PermissionError("automation run repository is not allowed")
502+
503+
504+
def _automation_snapshot_for_claims(
505+
snapshot: dict[str, Any],
506+
claims: dict[str, Any],
507+
*,
508+
limit: int | None = None,
509+
) -> dict[str, Any]:
510+
if _automation_operator_claims(claims):
511+
return snapshot
512+
visible_runs = [run for run in snapshot.get("runs", []) if _automation_run_access_allowed(run, claims)]
513+
runs = visible_runs[: max(0, int(limit))] if limit is not None else visible_runs
514+
task_states = Counter(str(run.get("task_state", "")).strip().lower() for run in visible_runs if run.get("task_state"))
515+
suggested_actions = Counter(
516+
str(run.get("suggested_action", "")).strip().lower()
517+
for run in visible_runs
518+
if run.get("suggested_action")
519+
)
520+
terminal_runs = sum(1 for run in visible_runs if str(run.get("task_state", "")).strip().lower() in TERMINAL_STATES)
521+
summary = dict(snapshot.get("summary") or {})
522+
summary.update(
523+
{
524+
"total_runs": len(visible_runs),
525+
"returned_runs": len(runs),
526+
"active_runs": len(visible_runs) - terminal_runs,
527+
"terminal_runs": terminal_runs,
528+
"task_states": dict(task_states),
529+
"suggested_actions": dict(suggested_actions),
530+
}
531+
)
532+
return {"runs": runs, "summary": summary}
533+
534+
428535
def _job_dedupe_key(payload: dict[str, Any]) -> str:
429536
issue_number = str(payload.get("issue_number") or "").strip()
430537
prompt_hash = hashlib.sha256(str(payload.get("prompt") or "").encode("utf-8")).hexdigest()
@@ -495,6 +602,7 @@ def _run_job(job_id: str, payload: dict[str, Any]) -> None:
495602
job["status"] = "running"
496603
job["updated_at"] = _now()
497604
_write_job(job)
605+
_record_job_automation_run(job)
498606
adapter = CodexAdapter()
499607
sandbox = _validate_sandbox(str(payload.get("sandbox") or ""))
500608
reasoning_effort = _resolve_codex_reasoning_effort(payload, str(payload.get("task") or TASK_EXECUTE))
@@ -516,6 +624,7 @@ def _run_job(job_id: str, payload: dict[str, Any]) -> None:
516624
job["failure_category"] = _classify_failure(result.error)
517625
job["updated_at"] = _now()
518626
_write_job(job)
627+
_record_job_automation_run(job)
519628
get_health_monitor().record(
520629
"/v1/ai/execute/jobs/run",
521630
time.time() - started,
@@ -534,6 +643,7 @@ def _run_job(job_id: str, payload: dict[str, Any]) -> None:
534643
job["error"] = str(exc)[-4000:]
535644
job["failure_category"] = _classify_failure(str(exc))
536645
_write_job(job)
646+
_record_job_automation_run(job)
537647
get_health_monitor().record("/v1/ai/execute/jobs/run", time.time() - started, False, type(exc).__name__)
538648
_audit_log("job_failed", job_id=job_id, error=type(exc).__name__,
539649
repository=job.get("repository"))
@@ -575,6 +685,7 @@ def _submit_job(claims: dict[str, Any], payload: dict[str, Any]) -> dict[str, ob
575685
"dedupe_key": dedupe_key,
576686
}
577687
_write_job(job)
688+
_record_job_automation_run(job)
578689
_audit_log("job_submitted", job_id=job_id, repository=job["repository"],
579690
task=job["task"], source_repository=job["source_repository"])
580691
thread = threading.Thread(target=_run_job, args=(job_id, payload), name=f"ai-gateway-job-{job_id}", daemon=True)
@@ -626,6 +737,16 @@ def do_GET(self) -> None:
626737
if request_path == "/v1/ai/quota":
627738
self._handle_quota_status()
628739
return
740+
if request_path == "/v1/ai/automation/control":
741+
self._handle_automation_control()
742+
return
743+
if request_path == "/v1/ai/automation/runs":
744+
self._handle_list_automation_runs()
745+
return
746+
if request_path.startswith("/v1/ai/automation/runs/"):
747+
run_id = request_path[len("/v1/ai/automation/runs/"):]
748+
self._handle_get_automation_run(run_id)
749+
return
629750

630751
# Feedback: list changes or get effectiveness report
631752
if request_path == "/v1/ai/changes/effectiveness":
@@ -698,6 +819,12 @@ def do_POST(self) -> None:
698819
elif self.path in {"/v1/ai/execute/jobs", "/v1/codex-audit/jobs"}:
699820
_assert_write_authz(claims, self.path)
700821
self._handle_execute_async(claims, payload)
822+
elif self.path in {"/v1/ai/automation/runs"}:
823+
_assert_write_authz(claims, self.path)
824+
self._handle_record_automation_run(claims, payload)
825+
elif self.path in {"/v1/ai/automation/authority"}:
826+
_assert_write_authz(claims, self.path)
827+
self._handle_automation_authority(claims, payload)
701828
elif self.path in {"/v1/ai/execute", "/v1/codex-audit"}:
702829
_assert_write_authz(claims, self.path)
703830
self._handle_execute_sync(claims, payload)
@@ -913,6 +1040,7 @@ def _handle_review(self, payload: dict[str, Any]) -> None:
9131040
changed_paths,
9141041
repo=repo if repo else None,
9151042
policy=load_autonomy_policy(),
1043+
automation_metadata=payload.get("automation_metadata") if isinstance(payload.get("automation_metadata"), dict) else {},
9161044
health_status=get_health_monitor().status,
9171045
quota_status=str(quota_status),
9181046
)
@@ -926,6 +1054,94 @@ def _handle_review(self, payload: dict[str, Any]) -> None:
9261054
"recommended_action": action,
9271055
})
9281056

1057+
# -- automation control handlers --
1058+
1059+
def _handle_automation_control(self) -> None:
1060+
from urllib.parse import parse_qs, urlparse
1061+
try:
1062+
authenticate(self.headers, audience=DEFAULT_AUDIENCE)
1063+
except PermissionError as exc:
1064+
_json_response(self, HTTPStatus.UNAUTHORIZED, {"status": "error", "error": str(exc)})
1065+
return
1066+
parsed = urlparse(self.path)
1067+
params = parse_qs(parsed.query)
1068+
repo = str(params.get("repo", ["unknown"])[0] or "unknown")
1069+
_json_response(self, HTTPStatus.OK, {"status": "ok", "control": _automation_control_snapshot(repo)})
1070+
1071+
def _handle_list_automation_runs(self) -> None:
1072+
from urllib.parse import parse_qs, urlparse
1073+
try:
1074+
claims = authenticate(self.headers, audience=DEFAULT_AUDIENCE)
1075+
except PermissionError as exc:
1076+
_json_response(self, HTTPStatus.UNAUTHORIZED, {"status": "error", "error": str(exc)})
1077+
return
1078+
parsed = urlparse(self.path)
1079+
params = parse_qs(parsed.query)
1080+
limit = int(params.get("limit", ["100"])[0])
1081+
include_events = str(params.get("include_events", ["false"])[0]).lower() in {"1", "true", "yes", "on"}
1082+
snapshot_limit = limit if _automation_operator_claims(claims) else None
1083+
snapshot = get_automation_run_ledger().snapshot(limit=snapshot_limit, include_events=include_events)
1084+
snapshot = _automation_snapshot_for_claims(snapshot, claims, limit=limit)
1085+
_json_response(self, HTTPStatus.OK, {"status": "ok", "ledger": snapshot})
1086+
1087+
def _handle_get_automation_run(self, run_id: str) -> None:
1088+
try:
1089+
claims = authenticate(self.headers, audience=DEFAULT_AUDIENCE)
1090+
except PermissionError as exc:
1091+
_json_response(self, HTTPStatus.UNAUTHORIZED, {"status": "error", "error": str(exc)})
1092+
return
1093+
record = get_automation_run_ledger().get(run_id)
1094+
if record is None:
1095+
_json_response(self, HTTPStatus.NOT_FOUND, {"status": "error", "error": "automation run not found"})
1096+
return
1097+
try:
1098+
_assert_automation_run_access(record, claims)
1099+
except PermissionError as exc:
1100+
_json_response(self, HTTPStatus.UNAUTHORIZED, {"status": "error", "error": str(exc)})
1101+
return
1102+
_json_response(self, HTTPStatus.OK, {"status": "ok", "run": record})
1103+
1104+
def _handle_record_automation_run(self, claims: dict[str, Any], payload: dict[str, Any]) -> None:
1105+
source_repo = str(payload.get("source_repository") or "")
1106+
if source_repo:
1107+
_validate_source_repo(source_repo)
1108+
_validate_source_repo_org(claims, source_repo)
1109+
repo = source_repo or str(claims.get("repository") or "unknown")
1110+
control = _automation_control_snapshot(repo)
1111+
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
1112+
ledger = get_automation_run_ledger()
1113+
run_id = str(payload.get("run_id") or payload.get("job_id") or "")
1114+
existing = ledger.get(run_id)
1115+
if existing is not None:
1116+
_assert_automation_run_access(existing, claims)
1117+
record = get_automation_run_ledger().record(
1118+
run_id,
1119+
str(payload.get("task_state") or payload.get("state") or "running"),
1120+
task_name=str(payload.get("task") or payload.get("task_name") or ""),
1121+
suggested_action=str(control.get("action") or ""),
1122+
service_health=str(control.get("service_health") or ""),
1123+
quota_status=control.get("quota_status") or "",
1124+
org_health_status=str(control.get("org_health_status") or ""),
1125+
metadata={
1126+
**metadata,
1127+
"repository": repo,
1128+
"source_repository": source_repo,
1129+
"caller_repository": str(claims.get("repository") or ""),
1130+
},
1131+
)
1132+
_json_response(self, HTTPStatus.OK, {"status": "ok", "run": record, "control": control})
1133+
1134+
def _handle_automation_authority(self, claims: dict[str, Any], payload: dict[str, Any]) -> None:
1135+
source_repo = str(payload.get("source_repository") or "")
1136+
if source_repo:
1137+
_validate_source_repo(source_repo)
1138+
_validate_source_repo_org(claims, source_repo)
1139+
changed_paths = [str(path) for path in payload.get("changed_paths", []) if isinstance(path, str)]
1140+
metadata = payload.get("automation_metadata") if isinstance(payload.get("automation_metadata"), dict) else {}
1141+
proposed_action = str(payload.get("proposed_action") or ACTION_AUTO_PR)
1142+
authority = evaluate_automation_authority(changed_paths, metadata=metadata, proposed_action=proposed_action)
1143+
_json_response(self, HTTPStatus.OK, {"status": "ok", "automation_authority": authority})
1144+
9291145
# -- feedback handlers (Phase 3: closed-loop change tracking) --
9301146

9311147
def _handle_feedback_register(self, claims: dict[str, Any], payload: dict[str, Any]) -> None:

0 commit comments

Comments
 (0)