Compare commits
39
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 | ||
|
|
c38c575d8e |
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
);
|
||||
+10
-20
@@ -1,22 +1,12 @@
|
||||
{
|
||||
"version": "V1R1C0",
|
||||
"architecture_version": "V1",
|
||||
"release_version": "R1",
|
||||
"change_version": "C0",
|
||||
"components": [
|
||||
"webops",
|
||||
"owner-admin-user",
|
||||
"npm",
|
||||
"casdoor",
|
||||
"openclaw",
|
||||
"hermes",
|
||||
"one-api",
|
||||
"gitops-control",
|
||||
"baseline-registry",
|
||||
"custom-llm-registry",
|
||||
"runtime-selector"
|
||||
],
|
||||
"entry": "https://cnnetmkt.top",
|
||||
"proxy": "npm",
|
||||
"runtime_sync": "openclaw"
|
||||
"active_version": "V1R2C3",
|
||||
"active_tag": "V1R2C3",
|
||||
"previous_version": "V1R2C2",
|
||||
"rollback_version": "V1R2C2",
|
||||
"mode": "gitops-online",
|
||||
"update_mode": "git_patch_only",
|
||||
"zip_update_deprecated": true,
|
||||
"chatgpt_access": true,
|
||||
"codex_access": true,
|
||||
"updated_at": "2026-07-08T00:00:00Z"
|
||||
}
|
||||
|
||||
+7
-16
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"active_version": "V1R2C2",
|
||||
"active_tag": "V1R2C2",
|
||||
"active_version": "V1R2C3",
|
||||
"active_tag": "V1R2C3",
|
||||
"mode": "gitops-online",
|
||||
"version_policy": "VxRxCx",
|
||||
"update_mode": "git_patch_only",
|
||||
@@ -28,8 +28,8 @@
|
||||
"human_shortlinks_login_required": true,
|
||||
"autonomous_recovery_loop": true
|
||||
},
|
||||
"version": "V1R2C2",
|
||||
"baseline_tag": "V1R2C2-auto-recovery-baseline",
|
||||
"version": "V1R2C3",
|
||||
"baseline_tag": "V1R2C3",
|
||||
"source": "AIOS_V1R1C0_MULTIUSER_GITOPS_CUSTOM_LLM_PROD_FULL_DEPLOY_20260706.zip",
|
||||
"source_package": "AIOS_V1R1C0_MULTIUSER_GITOPS_CUSTOM_LLM_PROD_FULL_DEPLOY_20260706.zip",
|
||||
"zip_update_mode": "deprecated",
|
||||
@@ -47,7 +47,7 @@
|
||||
"/api",
|
||||
"/hook"
|
||||
],
|
||||
"updated_at": "2026-07-07T19:45:00Z",
|
||||
"updated_at": "2026-07-08T00:00:00Z",
|
||||
"auth": {
|
||||
"primary_domain": "https://auth.cnnetmkt.top",
|
||||
"backup_domain": "https://auth.h.imruyi.top:8443",
|
||||
@@ -56,7 +56,7 @@
|
||||
"casdoor_application": "admin/aios-portal",
|
||||
"backup_domain_role": "standby-only"
|
||||
},
|
||||
"previous_version": "V1R2C1",
|
||||
"previous_version": "V1R2C2",
|
||||
"codex_gitops": {
|
||||
"config": "config/codex/gitops-online.json",
|
||||
"token_account": "codex-gitops-bot",
|
||||
@@ -96,14 +96,5 @@
|
||||
"owner_password_sync": "aios-gitea-owner-sync.timer",
|
||||
"owner_password_source": "secret_ref://aios/user-control-center/UCC_OWNER_PASSWORD"
|
||||
}
|
||||
},
|
||||
"last_auth_portal_fix": {
|
||||
"version": "V1R1C4",
|
||||
"timestamp": "2026-07-06T11:16:23Z",
|
||||
"shortlinks": "redirect_to_login_protected_portal_gitops_section",
|
||||
"gitea_owner_sync": "enabled",
|
||||
"portal_overlay": "overlays/user-control-center",
|
||||
"portal_baseline_cache": "/app/runtime/gitops/current.json",
|
||||
"sync_log_secret_hygiene": "runuser_without_sudo_command_logging"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
self_loop:
|
||||
enabled: true
|
||||
mode: evaluate_plan_verify
|
||||
secrets_in_git: false
|
||||
direct_main_push: false
|
||||
git_patch_only: true
|
||||
owner_gate:
|
||||
enabled: true
|
||||
required_for:
|
||||
- runtime_recovery
|
||||
- security_policy
|
||||
- mainline_promotion
|
||||
- release_tag
|
||||
automatic_actions:
|
||||
allowed:
|
||||
- health_probe
|
||||
- test_execution
|
||||
- failure_classification
|
||||
- patch_plan_generation
|
||||
- report_generation
|
||||
restricted:
|
||||
- production_promotion
|
||||
- permission_escalation
|
||||
- secret_rotation
|
||||
@@ -0,0 +1,28 @@
|
||||
self_loop_runtime:
|
||||
enabled: true
|
||||
scheduler:
|
||||
triggers:
|
||||
- event
|
||||
- interval
|
||||
- manual
|
||||
retry:
|
||||
enabled: true
|
||||
max_attempts: 3
|
||||
api:
|
||||
prefix: /self-loop
|
||||
operations:
|
||||
- enqueueSelfLoopJob
|
||||
- listSelfLoopJobs
|
||||
- getSelfLoopJob
|
||||
- runSelfLoopJob
|
||||
- tickSelfLoopJobs
|
||||
- runSelfLoopOnce
|
||||
- evaluateSelfLoopProductionGate
|
||||
gitops:
|
||||
adapter: controlled_actions
|
||||
branch_policy: patch-only
|
||||
direct_main_push: false
|
||||
secrets_in_git: false
|
||||
promotion:
|
||||
acceptance_gate_required: true
|
||||
owner_approval_required: true
|
||||
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=AIOS Self-loop Supervisor
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/data/aios-gitops/workspace
|
||||
ExecStart=/usr/bin/python3 -m active_runtime.app.self_loop_worker
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,23 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: User Portal Operations API
|
||||
version: implementation
|
||||
paths:
|
||||
/portal/dashboard:
|
||||
get:
|
||||
operationId: getPortalDashboard
|
||||
/portal/approvals:
|
||||
post:
|
||||
operationId: createPortalApproval
|
||||
/portal/alerts:
|
||||
get:
|
||||
operationId: listPortalAlerts
|
||||
/portal/audit:
|
||||
get:
|
||||
operationId: listPortalAuditEvents
|
||||
/portal/rbac/check:
|
||||
post:
|
||||
operationId: checkPortalRBAC
|
||||
/owner/system:
|
||||
get:
|
||||
operationId: getOwnerSystemCockpit
|
||||
@@ -0,0 +1,195 @@
|
||||
# OpenClaw Execution Instructions: V1R3C1 Corrective Self-loop Release
|
||||
|
||||
## Purpose
|
||||
|
||||
Execute the corrective release path for `V1R3C1`.
|
||||
|
||||
`V1R3C0` already exists, but it must be treated as an infrastructure-fix tag only. Do not promote `V1R3C0` as the self-loop autonomous engine baseline.
|
||||
|
||||
The correct self-loop implementation target is this branch:
|
||||
|
||||
```text
|
||||
patch/user-portal-rbac-ops-dashboard
|
||||
```
|
||||
|
||||
The corrective release version is:
|
||||
|
||||
```text
|
||||
V1R3C1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Required checks
|
||||
|
||||
Use the Gitea bare repo:
|
||||
|
||||
```bash
|
||||
GITEA_REPO="/data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios-v23.git"
|
||||
```
|
||||
|
||||
Confirm the implementation branch exists:
|
||||
|
||||
```bash
|
||||
git --git-dir="$GITEA_REPO" rev-parse patch/user-portal-rbac-ops-dashboard
|
||||
```
|
||||
|
||||
Confirm the V1R3C1 proposal exists on the implementation branch:
|
||||
|
||||
```bash
|
||||
git --git-dir="$GITEA_REPO" ls-tree -r patch/user-portal-rbac-ops-dashboard | grep 'versions/proposals/V1R3C1.json'
|
||||
```
|
||||
|
||||
Confirm the implementation branch contains the self-loop files:
|
||||
|
||||
```bash
|
||||
git --git-dir="$GITEA_REPO" ls-tree -r patch/user-portal-rbac-ops-dashboard | grep -E 'autonomous_self_loop|self_loop_executor|self_loop_orchestrator|self_loop_patch_executor|self_loop_scheduler|self_loop_runtime_api|self_loop_gitops_adapter|production_acceptance_gate|failure_memory|failure_memory_store|adaptive_policy|self_loop_event_hooks|self_loop_supervisor|self_loop_worker|self_evolution_report'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Do not promote V1R3C0
|
||||
|
||||
Check `V1R3C0` only for reference:
|
||||
|
||||
```bash
|
||||
git --git-dir="$GITEA_REPO" tag --list 'V1R3C0'
|
||||
git --git-dir="$GITEA_REPO" rev-list -n 1 V1R3C0 2>/dev/null || true
|
||||
```
|
||||
|
||||
Do not delete, move, rewrite, or reuse `V1R3C0`.
|
||||
|
||||
`V1R3C0` must remain an infrastructure-fix tag.
|
||||
|
||||
---
|
||||
|
||||
## Create or repair V1R3C1 approval request
|
||||
|
||||
If the Actions endpoint `/api/tag-request` is broken, repair it first. The API should allow a tag request to be created for `V1R3C1`.
|
||||
|
||||
Search the handler:
|
||||
|
||||
```bash
|
||||
grep -R "tag-request\|createTagApprovalRequest\|requestOrCreateTag\|baseline/tag_requests" -n \
|
||||
/data/aios-gitops/runtime \
|
||||
/data/aios-gitops/workspace 2>/dev/null
|
||||
```
|
||||
|
||||
Expected approval request branch:
|
||||
|
||||
```text
|
||||
request/tag-V1R3C1-self-loop-corrective
|
||||
```
|
||||
|
||||
Expected request file:
|
||||
|
||||
```text
|
||||
baseline/tag_requests/V1R3C1.json
|
||||
```
|
||||
|
||||
Expected promotion files:
|
||||
|
||||
```text
|
||||
baseline/promotions/V1R3C1.release-manifest.json
|
||||
baseline/promotions/V1R3C1.acceptance-report.json
|
||||
```
|
||||
|
||||
The approval request must state:
|
||||
|
||||
```text
|
||||
V1R3C1 corrects the V1R3C0 release target.
|
||||
V1R3C0 is infrastructure fix only.
|
||||
V1R3C1 targets patch/user-portal-rbac-ops-dashboard.
|
||||
Owner approval required.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Create V1R3C1 tag at the correct commit
|
||||
|
||||
Get the intended target commit:
|
||||
|
||||
```bash
|
||||
PATCH_HEAD=$(git --git-dir="$GITEA_REPO" rev-parse patch/user-portal-rbac-ops-dashboard)
|
||||
echo "$PATCH_HEAD"
|
||||
```
|
||||
|
||||
Verify no existing V1R3C1 tag:
|
||||
|
||||
```bash
|
||||
git --git-dir="$GITEA_REPO" tag --list 'V1R3C1'
|
||||
```
|
||||
|
||||
If the tag does not exist and owner approval is present, create the annotated tag at the implementation branch HEAD:
|
||||
|
||||
```bash
|
||||
git --git-dir="$GITEA_REPO" tag -a V1R3C1 "$PATCH_HEAD" -m "release V1R3C1 self-loop autonomous engine corrective release"
|
||||
```
|
||||
|
||||
If the tag already exists, verify it points to the same target commit. Do not move the tag without explicit owner approval.
|
||||
|
||||
---
|
||||
|
||||
## Verify V1R3C1 tag content
|
||||
|
||||
```bash
|
||||
git --git-dir="$GITEA_REPO" tag --list 'V1R3C1'
|
||||
git --git-dir="$GITEA_REPO" rev-list -n 1 V1R3C1
|
||||
git --git-dir="$GITEA_REPO" ls-tree -r V1R3C1 | grep -E 'self_loop_supervisor|failure_memory|adaptive_policy|self_loop_worker|self_evolution_report'
|
||||
```
|
||||
|
||||
The tag must include the self-loop files. If these files are missing, stop and report failure.
|
||||
|
||||
---
|
||||
|
||||
## Promotion rule
|
||||
|
||||
Only after `V1R3C1` tag verification passes, promote baseline to:
|
||||
|
||||
```text
|
||||
V1R3C1
|
||||
```
|
||||
|
||||
Do not promote:
|
||||
|
||||
```text
|
||||
V1R3C0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Forbidden actions
|
||||
|
||||
- Do not delete or rewrite `V1R3C0`.
|
||||
- Do not promote `V1R3C0` as the self-loop release.
|
||||
- Do not push directly to `main`.
|
||||
- Do not write secrets to Git.
|
||||
- Do not modify Casdoor/SSO.
|
||||
- Do not modify Gitea branch index unless a new drift is found and owner approves.
|
||||
- Do not bypass owner gate except through an explicitly approved emergency path.
|
||||
|
||||
---
|
||||
|
||||
## Required final report
|
||||
|
||||
Output this matrix:
|
||||
|
||||
| Check | Result | Evidence |
|
||||
|---|---|---|
|
||||
| V1R3C0 treated as infrastructure fix only | YES/NO | tag/diff evidence |
|
||||
| V1R3C1 proposal exists | PASS/FAIL | file/SHA |
|
||||
| V1R3C1 approval request exists | PASS/FAIL | branch/file |
|
||||
| V1R3C1 tag exists | PASS/FAIL | tag/SHA |
|
||||
| V1R3C1 points to patch/user-portal-rbac-ops-dashboard HEAD | PASS/FAIL | SHA comparison |
|
||||
| V1R3C1 contains self-loop implementation files | PASS/FAIL | ls-tree output |
|
||||
| Baseline promotion to V1R3C1 completed | PASS/FAIL | baseline/current.json |
|
||||
| Runtime status after promotion | PASS/FAIL | runtime check |
|
||||
|
||||
Final expected release path:
|
||||
|
||||
```text
|
||||
V1R2C3
|
||||
-> V1R3C1
|
||||
-> baseline promotion
|
||||
-> self-loop supervisor / failure memory / adaptive policy active
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "branch",
|
||||
"branch": "patch/user-portal-rbac-ops-dashboard",
|
||||
"base": "main",
|
||||
"created_by": "chatgpt-gitops-bot",
|
||||
"created_at": "2026-07-08T01:56:42Z",
|
||||
"direct_main_push": false,
|
||||
"owner_approval_required": true
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
from active_runtime.app.autonomous_self_loop import AutonomousSelfLoop
|
||||
|
||||
|
||||
def test_self_loop_acceptance_report():
|
||||
loop = AutonomousSelfLoop(owner_approval_required=True)
|
||||
report = loop.evaluate(
|
||||
{
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"update_mode": "git_patch_only",
|
||||
"features": {
|
||||
"autonomous_recovery_loop": True,
|
||||
"portal_gitops_dashboard": True,
|
||||
},
|
||||
"codex_gitops": {
|
||||
"direct_main_push": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
result = loop.acceptance(report)
|
||||
assert result["owner_approval_required"] is True
|
||||
assert result["report"]["signals"]
|
||||
@@ -0,0 +1,26 @@
|
||||
from active_runtime.app.portal_rbac_ops import (
|
||||
ROLE_GROUP_ADMIN,
|
||||
ROLE_OWNER,
|
||||
ROLE_USER,
|
||||
PortalOpsService,
|
||||
build_context,
|
||||
)
|
||||
|
||||
|
||||
def test_full_three_role_portal_flow():
|
||||
service = PortalOpsService()
|
||||
|
||||
user = build_context("u1", ROLE_USER, "g1")
|
||||
admin = build_context("a1", ROLE_GROUP_ADMIN, "g1")
|
||||
owner = build_context("o1", ROLE_OWNER)
|
||||
|
||||
assert service.dashboard(user)["status"] == "ok"
|
||||
assert service.dashboard(admin)["status"] == "ok"
|
||||
assert service.owner_cockpit(owner)["status"] == "ok"
|
||||
|
||||
created = service.create_approval(user, "quota_change", {"quota": 100})
|
||||
assert created["status"] == "ok"
|
||||
|
||||
result = service.run_self_loop()
|
||||
assert result["status"] == "ok"
|
||||
assert all(result["checks"].values())
|
||||
@@ -0,0 +1,15 @@
|
||||
from active_runtime.app.portal_ops_api import PortalOpsAPI
|
||||
|
||||
|
||||
def test_three_role_dashboards():
|
||||
api = PortalOpsAPI()
|
||||
assert api.dashboard("user")["status"] == "ok"
|
||||
assert "group_users" in api.dashboard("group_admin")["modules"]
|
||||
assert "system" in api.dashboard("owner")["modules"]
|
||||
|
||||
|
||||
def test_ops_loop_records():
|
||||
api = PortalOpsAPI()
|
||||
assert api.approval("a1", "approve")["state"] == "recorded"
|
||||
assert api.audit_event("owner", "login")["recorded"]
|
||||
assert api.alert("x1", "high")["state"] == "open"
|
||||
@@ -0,0 +1,26 @@
|
||||
from active_runtime.app.self_loop_executor import SelfLoopAgentExecutor
|
||||
from active_runtime.app.autonomous_self_loop import AutonomousSelfLoop
|
||||
|
||||
|
||||
def test_executor_respects_owner_gate():
|
||||
loop = AutonomousSelfLoop(owner_approval_required=True)
|
||||
report = loop.evaluate(
|
||||
{"status": "active"},
|
||||
{
|
||||
"update_mode": "git_patch_only",
|
||||
"features": {
|
||||
"autonomous_recovery_loop": True,
|
||||
"portal_gitops_dashboard": True,
|
||||
},
|
||||
"codex_gitops": {"direct_main_push": False},
|
||||
},
|
||||
)
|
||||
|
||||
result = SelfLoopAgentExecutor(loop).execute_report(
|
||||
report,
|
||||
{"status": "active"},
|
||||
{"update_mode": "git_patch_only", "codex_gitops": {"direct_main_push": False}},
|
||||
)
|
||||
|
||||
assert result.owner_approval_required is True
|
||||
assert result.status in {"waiting_owner_gate", "completed"}
|
||||
@@ -0,0 +1,34 @@
|
||||
from active_runtime.app.production_acceptance_gate import ProductionAcceptanceGate
|
||||
from active_runtime.app.self_loop_scheduler import SelfLoopScheduler
|
||||
|
||||
|
||||
def test_scheduler_and_production_gate():
|
||||
scheduler = SelfLoopScheduler()
|
||||
job = scheduler.enqueue(
|
||||
"continue autonomous self-loop implementation",
|
||||
{"status": "active"},
|
||||
{
|
||||
"update_mode": "git_patch_only",
|
||||
"codex_gitops": {
|
||||
"direct_main_push": False,
|
||||
"api_keys_in_git": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
result = scheduler.run_job(job.id)
|
||||
assert result["status"] in {"waiting_owner", "completed"}
|
||||
|
||||
gate = ProductionAcceptanceGate().evaluate(
|
||||
{"status": "active"},
|
||||
{
|
||||
"update_mode": "git_patch_only",
|
||||
"codex_gitops": {
|
||||
"direct_main_push": False,
|
||||
"api_keys_in_git": False,
|
||||
},
|
||||
},
|
||||
{"status": "waiting_owner_gate"},
|
||||
)
|
||||
|
||||
assert gate.status == "waiting_owner_approval"
|
||||
@@ -0,0 +1,36 @@
|
||||
from active_runtime.app.failure_memory import FailureMemory
|
||||
from active_runtime.app.adaptive_policy import AdaptivePolicy
|
||||
from active_runtime.app.self_loop_event_hooks import SelfLoopEventHook
|
||||
from active_runtime.app.self_loop_supervisor import SelfLoopSupervisor
|
||||
|
||||
|
||||
def test_failure_memory_and_adaptive_policy():
|
||||
memory = FailureMemory()
|
||||
memory.record_failure("runtime", "test_failed", "acceptance failure")
|
||||
decision = AdaptivePolicy().evaluate(
|
||||
memory,
|
||||
{"status": "active"},
|
||||
{"codex_gitops": {"direct_main_push": False}},
|
||||
"repair failure",
|
||||
)
|
||||
|
||||
assert decision.owner_approval_required is True
|
||||
assert decision.retry_limit >= 1
|
||||
|
||||
|
||||
def test_supervisor_event_hook_cycle():
|
||||
supervisor = SelfLoopSupervisor()
|
||||
result = supervisor.handle_event(
|
||||
{
|
||||
"source": "acceptance",
|
||||
"event_type": "acceptance_failed",
|
||||
"goal": "repair failed acceptance",
|
||||
"runtime": {"status": "active"},
|
||||
"baseline": {
|
||||
"codex_gitops": {"direct_main_push": False}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert result["status"] == "queued"
|
||||
assert supervisor.status()["state"]["cycles"] == 1
|
||||
@@ -0,0 +1,20 @@
|
||||
from active_runtime.app.self_loop_orchestrator import SelfLoopAgentOrchestrator
|
||||
|
||||
|
||||
def test_multi_agent_cycle():
|
||||
result = SelfLoopAgentOrchestrator().run_cycle(
|
||||
{"status": "active"},
|
||||
{
|
||||
"update_mode": "git_patch_only",
|
||||
"features": {
|
||||
"autonomous_recovery_loop": True,
|
||||
"portal_gitops_dashboard": True,
|
||||
},
|
||||
"codex_gitops": {"direct_main_push": False},
|
||||
},
|
||||
"continue self-loop capability implementation",
|
||||
)
|
||||
|
||||
assert result["status"] == "ready_for_patch"
|
||||
assert len(result["agents"]) == 3
|
||||
assert result["patch_manifest"]["branch_policy"] == "patch-only"
|
||||
@@ -0,0 +1,20 @@
|
||||
from active_runtime.app.self_loop_patch_executor import SelfLoopPatchExecutor
|
||||
|
||||
|
||||
def test_patch_executor_waits_for_owner_gate():
|
||||
result = SelfLoopPatchExecutor().run_cycle_to_promotion(
|
||||
{"status": "active"},
|
||||
{
|
||||
"update_mode": "git_patch_only",
|
||||
"features": {
|
||||
"autonomous_recovery_loop": True,
|
||||
"portal_gitops_dashboard": True,
|
||||
},
|
||||
"codex_gitops": {"direct_main_push": False},
|
||||
},
|
||||
"implement missing self-loop acceptance capability",
|
||||
)
|
||||
|
||||
assert result["patch_bundle"]["direct_main_push"] is False
|
||||
assert result["patch_bundle"]["secrets_in_git"] is False
|
||||
assert result["promotion"]["status"] == "waiting_owner_approval"
|
||||
@@ -0,0 +1,19 @@
|
||||
from active_runtime.app.self_loop_runtime_api import enqueue_self_loop_job, run_self_loop_job
|
||||
|
||||
|
||||
def test_runtime_api_job_flow():
|
||||
queued = enqueue_self_loop_job(
|
||||
"continue self-loop runtime wiring",
|
||||
{"status": "active"},
|
||||
{
|
||||
"update_mode": "git_patch_only",
|
||||
"codex_gitops": {
|
||||
"direct_main_push": False,
|
||||
"api_keys_in_git": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert queued["status"] == "queued"
|
||||
result = run_self_loop_job(queued["job"]["id"])
|
||||
assert result["status"] == "ok"
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "V1R2C3",
|
||||
"tag": "V1R2C3",
|
||||
"previous_version": "V1R2C2",
|
||||
"rollback_version": "V1R2C2",
|
||||
"release_reason": "GPT Actions ClientResponseError compatibility recovery",
|
||||
"validated_actions": [
|
||||
"getRuntimeStatus",
|
||||
"getBaseline",
|
||||
"listVersions",
|
||||
"listPatches",
|
||||
"getDiff",
|
||||
"createBranch",
|
||||
"createPatch",
|
||||
"commitFile"
|
||||
],
|
||||
"secret_plaintext_written": false,
|
||||
"created_at": "2026-07-08T00:00:00Z",
|
||||
"author": "openclaw"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"version": "V1R3C0",
|
||||
"base": "V1R2C3",
|
||||
"status": "proposal",
|
||||
"scope": "self-loop-autonomous-engine",
|
||||
"features": [
|
||||
"self-loop-supervisor",
|
||||
"event-hook-triggering",
|
||||
"failure-memory",
|
||||
"adaptive-policy",
|
||||
"runtime-worker",
|
||||
"self-evolution-report"
|
||||
],
|
||||
"governance": {
|
||||
"direct_main_push": false,
|
||||
"owner_approval_required": true,
|
||||
"secrets_in_git": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"version": "V1R3C1",
|
||||
"base": "V1R2C3",
|
||||
"supersedes_candidate": "V1R3C0",
|
||||
"status": "proposal",
|
||||
"scope": "correct self-loop autonomous engine release target",
|
||||
"reason": "V1R3C0 tag exists but points to tag-confirmation/Gitea branch-index infrastructure fixes rather than the full self-loop implementation branch. Do not promote V1R3C0 baseline for self-loop. Use V1R3C1 for the correct self-loop implementation target.",
|
||||
"implementation_branch": "patch/user-portal-rbac-ops-dashboard",
|
||||
"features": [
|
||||
"self-loop-supervisor",
|
||||
"event-hook-triggering",
|
||||
"failure-memory",
|
||||
"failure-memory-persistence",
|
||||
"adaptive-policy",
|
||||
"runtime-worker",
|
||||
"self-evolution-report",
|
||||
"portal-rbac-ops-dashboard-foundation",
|
||||
"acceptance-pipeline",
|
||||
"production-gate"
|
||||
],
|
||||
"governance": {
|
||||
"direct_main_push": false,
|
||||
"owner_approval_required": true,
|
||||
"secrets_in_git": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user