Files
cayce-ops/cayceops/components/executor/executor.py
T
cayce 73ccdf7bdf
ci / lint-test-leaks (pull_request) Canceled after 0s
scanner + executor: discovery files proposals; execution gated on approval
scanner (read-only):
- sources from CAYCE_OPS_SCANNER_SOURCES JSON (rss + page types), stdlib only
- keyword scoring -> risk_score; kind via heuristics (affiliate/partnership/content_gap)
- idempotent: sha1 dedupe_key; repeated scans skip existing
- every proposal audited (actor daemon:scanner); source errors recorded, never fatal

executor (human gate):
- approve/reject audited with operator identity; only proposed -> approved
- AUTONOMOUS_MODE=true adds exactly one path: policy auto-approval
  (kinds allowlist + daily cap, actor auto:policy) — DECISIONS D5
- plugin registry by kind; unknown kinds fall back to log_action (D9)
- retries with max_attempts, then failed; every transition audited
- http_post plugin inert unless CAYCE_OPS_ENABLE_HTTP_ACTIONS=true

tests: 13 new (dedupe, keyword filter, autonomy bounds, retry-then-fail,
blocked http_post, fallback plugin) — 20 total passing
2026-10-19 21:42:34 +00:00

247 lines
8.0 KiB
Python

"""executor — executes APPROVED proposals through registered action plugins.
Guarantees
----------
- Only ``approved`` proposals execute (``AUTONOMOUS_MODE=false`` default).
- With ``AUTONOMOUS_MODE=true`` the executor gains exactly one capability:
bounded auto-approval of ``proposed`` items whose ``kind`` is in
``CAYCE_OPS_AUTONOMOUS_KINDS``, capped by ``CAYCE_OPS_AUTONOMOUS_DAILY_CAP``,
recorded with actor ``auto:policy`` (DECISIONS D5).
- Every transition writes an ``audit_log`` row: who approved, what ran,
plugin output, success/failure.
- Unknown proposal kinds fall back to the ``log_action`` plugin (never a
network side effect). See DECISIONS D9.
"""
from __future__ import annotations
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import func, select
from cayceops.components.executor import plugins as plugin_registry
from cayceops.config import Config
from cayceops.db import session_scope
from cayceops.models import AuditEntry, DaemonRun, Proposal, ProposalStatus, audit
Actor = str
# --- human transitions -------------------------------------------------------
def approve(proposal_id: int, actor: Actor, cfg: Config | None = None, sess=None) -> int:
"""Human approves a proposal. Audited with the operator's identity."""
return _decide(proposal_id, actor, ProposalStatus.APPROVED, cfg, sess)
def reject(proposal_id: int, actor: Actor, cfg: Config | None = None, sess=None) -> int:
"""Human rejects a proposal. Audited with the operator's identity."""
return _decide(proposal_id, actor, ProposalStatus.REJECTED, cfg, sess)
def _decide(
proposal_id: int,
actor: Actor,
decision: ProposalStatus,
cfg: Config | None,
sess,
) -> int:
owned = False
if sess is None:
outer = session_scope()
sess = outer.__enter__()
owned = True
try:
proposal = sess.get(Proposal, proposal_id)
if proposal is None:
return 1
if proposal.status != ProposalStatus.PROPOSED:
verb = "approve" if decision == ProposalStatus.APPROVED else "reject"
audit(
sess,
actor=actor,
action=verb,
proposal_id=proposal_id,
result="error",
detail={"reason": f"invalid state: {proposal.status.value}"},
)
return 1
proposal.status = decision
if decision == ProposalStatus.APPROVED:
proposal.approved_by = actor
proposal.approved_at = datetime.now(UTC)
verb = "approve" if decision == ProposalStatus.APPROVED else "reject"
audit(sess, actor=actor, action=verb, proposal_id=proposal_id)
return 0
finally:
if owned:
outer.__exit__(None, None, None)
# --- autonomous policy (the ONLY non-human approval path, D5) -----------------
def _auto_approve(cfg: Config, sess) -> int:
"""Auto-approve proposed items per policy. Returns count approved."""
if not cfg.autonomous_mode or not cfg.autonomous_kinds:
return 0
midnight = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
used = (
sess.query(func.count(AuditEntry.id))
.filter(
AuditEntry.actor == "auto:policy",
AuditEntry.action == "approve",
AuditEntry.ts >= midnight,
)
.scalar()
)
budget = max(0, cfg.autonomous_daily_cap - int(used or 0))
approved = 0
if budget == 0:
return 0
candidates = (
sess.query(Proposal)
.filter(
Proposal.status == ProposalStatus.PROPOSED,
Proposal.kind.in_(cfg.autonomous_kinds),
)
.order_by(Proposal.risk_score.asc(), Proposal.id.asc())
.limit(budget)
.all()
)
for proposal in candidates:
proposal.status = ProposalStatus.APPROVED
proposal.approved_by = "auto:policy"
proposal.approved_at = datetime.now(UTC)
audit(
sess,
actor="auto:policy",
action="approve",
proposal_id=proposal.id,
detail={
"reason": "autonomous_mode policy",
"kind": proposal.kind,
"risk_score": proposal.risk_score,
"daily_cap": cfg.autonomous_daily_cap,
},
)
approved += 1
return approved
# --- execution ----------------------------------------------------------------
def execute_one(proposal: Proposal, cfg: Config, sess) -> bool:
"""Run the plugin for one approved proposal. Returns True on success."""
proposal.status = ProposalStatus.RUNNING
proposal.attempts += 1
audit(
sess,
actor="daemon:executor",
action="execute_start",
proposal_id=proposal.id,
detail={"plugin": proposal.kind, "attempt": proposal.attempts},
)
plugin = plugin_registry.REGISTRY.get(proposal.kind)
try:
if plugin is None:
outcome: dict[str, Any] = plugin_registry.log_action(proposal, cfg, sess)
outcome["plugin_fallback"] = True
outcome["note"] = f"no plugin registered for kind {proposal.kind!r}"
else:
outcome = plugin(proposal, cfg, sess)
except Exception as exc: # noqa: BLE001 — plugin boundary: capture for audit
audit(
sess,
actor="daemon:executor",
action="execute_error",
proposal_id=proposal.id,
result="error",
detail={"error": str(exc)[:300], "attempt": proposal.attempts},
)
if proposal.attempts >= cfg.max_attempts:
proposal.status = ProposalStatus.FAILED
audit(
sess,
actor="daemon:executor",
action="fail",
proposal_id=proposal.id,
result="error",
detail={"attempts": proposal.attempts},
)
else:
proposal.status = ProposalStatus.APPROVED # retry next run
return False
proposal.status = ProposalStatus.DONE
audit(
sess,
actor="daemon:executor",
action="execute_done",
proposal_id=proposal.id,
detail={"outcome": outcome},
)
return True
def run(cfg: Config | None = None, sess=None) -> int:
"""CLI entry: auto-approve per policy, then execute all approved items."""
from cayceops.config import load
from cayceops.db import engine as _engine
cfg = cfg or load()
owned = False
if sess is None:
outer = session_scope()
sess = outer.__enter__()
owned = True
try:
run_row = DaemonRun(daemon="executor")
sess.add(run_row)
sess.flush()
try:
auto = _auto_approve(cfg, sess)
pending = (
sess.query(Proposal)
.filter(Proposal.status == ProposalStatus.APPROVED)
.order_by(Proposal.approved_at.asc())
.all()
)
done = failed = 0
for proposal in pending:
if execute_one(proposal, cfg, sess):
done += 1
else:
failed += 1
run_row.status = "ok"
run_row.detail = {"auto_approved": auto, "done": done, "failed": failed}
except Exception as exc: # noqa: BLE001 — daemon boundary
run_row.status = "error"
run_row.detail = {"error": str(exc)[:300]}
run_row.finished_at = datetime.now(UTC)
return 1
run_row.finished_at = datetime.now(UTC)
return 0
finally:
if owned:
outer.__exit__(None, None, None)
_engine().dispose()
def select_approved(sess) -> list[Proposal]:
"""Helper for tests/tools: currently approved proposals, oldest first."""
return list(
sess.scalars(
select(Proposal)
.where(Proposal.status == ProposalStatus.APPROVED)
.order_by(Proposal.approved_at.asc())
).all()
)
ActionPlugin = Callable[[Proposal, Config, Any], dict]