feat: reconcile granted ops run closes
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a06bfe-2a55-7ed3-bacd-879977b099bf
This commit is contained in:
parent
b72fdb5452
commit
b63131e863
21 changed files with 1240 additions and 43 deletions
|
|
@ -536,6 +536,11 @@ async def evaluate_instructions(payload: dict) -> dict:
|
|||
"approach_hint": instruction.approach_hint,
|
||||
"harness_profile_ref": instruction.harness_profile_ref,
|
||||
"execution_refs": instruction.execution_refs,
|
||||
"repository_grant": (
|
||||
instruction.repository_grant.payload()
|
||||
if instruction.repository_grant is not None
|
||||
else None
|
||||
),
|
||||
})
|
||||
|
||||
return {"task_specs": task_specs, "reports": reports}
|
||||
|
|
@ -579,6 +584,10 @@ async def emit_tasks(payload: dict) -> list[str]:
|
|||
normalise_execution_refs,
|
||||
resolve_execution_selector,
|
||||
)
|
||||
from activity_core.repository_grant import (
|
||||
RepositoryGrantError,
|
||||
validate_repository_grant,
|
||||
)
|
||||
|
||||
validated_specs: list[dict] = []
|
||||
for index, raw_spec in enumerate(task_specs_raw):
|
||||
|
|
@ -604,6 +613,19 @@ async def emit_tasks(payload: dict) -> list[str]:
|
|||
spec_dict["execution_refs"] = normalise_execution_refs(
|
||||
spec_dict.get("execution_refs")
|
||||
)
|
||||
raw_grant = spec_dict.get("repository_grant")
|
||||
if raw_grant is not None:
|
||||
try:
|
||||
spec_dict["repository_grant"] = validate_repository_grant(raw_grant)
|
||||
except RepositoryGrantError as exc:
|
||||
source = (
|
||||
f"{spec_dict.get('source_type', 'rule')}:"
|
||||
f"{spec_dict.get('source_id', '')}"
|
||||
)
|
||||
raise ApplicationError(
|
||||
f"repository grant refused for {source}: {exc}",
|
||||
non_retryable=True,
|
||||
) from exc
|
||||
validated_specs.append(spec_dict)
|
||||
|
||||
sink = get_issue_sink()
|
||||
|
|
@ -640,6 +662,7 @@ async def emit_tasks(payload: dict) -> list[str]:
|
|||
approach_hint=spec_dict.get("approach_hint"),
|
||||
harness_profile_ref=spec_dict.get("harness_profile_ref"),
|
||||
execution_refs=spec_dict.get("execution_refs"),
|
||||
repository_grant=spec_dict.get("repository_grant"),
|
||||
)
|
||||
if ops_id is not None:
|
||||
activity.logger.info(
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ from activity_core.glas_profile import (
|
|||
require_harness_profile,
|
||||
validate_profile_ref,
|
||||
)
|
||||
from activity_core.repository_grant import (
|
||||
RepositoryGrantError,
|
||||
validate_repository_grant,
|
||||
)
|
||||
|
||||
|
||||
class ParseError(Exception):
|
||||
|
|
@ -122,6 +126,19 @@ def _validate_execution_declarations(
|
|||
f"{location} execution_refs must be a YAML mapping",
|
||||
)
|
||||
|
||||
repository_grant = declaration.get("repository_grant")
|
||||
if repository_grant is not None:
|
||||
try:
|
||||
declaration["repository_grant"] = validate_repository_grant(
|
||||
repository_grant
|
||||
)
|
||||
except RepositoryGrantError as exc:
|
||||
raise ParseError(
|
||||
file,
|
||||
None,
|
||||
f"{location} has invalid repository_grant: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
def _normalise_review_advisory(
|
||||
instructions: list[dict[str, Any]], file: Path
|
||||
|
|
|
|||
|
|
@ -156,6 +156,137 @@ def _normalise_artifact_urls(raw: Any) -> list[dict[str, str]]:
|
|||
return urls
|
||||
|
||||
|
||||
def _normalise_repository_baseline(raw: Any) -> dict[str, Any]:
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
result: dict[str, Any] = {}
|
||||
for key in (
|
||||
"repo_id",
|
||||
"repo_name",
|
||||
"head",
|
||||
"branch",
|
||||
"status_digest",
|
||||
"index_diff_digest",
|
||||
"worktree_diff_digest",
|
||||
"upstream_ref",
|
||||
"upstream_oid",
|
||||
"remote_refs_digest",
|
||||
"protected_git_metadata_digest",
|
||||
):
|
||||
value = raw.get(key)
|
||||
if isinstance(value, str):
|
||||
result[key] = value[:500]
|
||||
elif value is None and key in {"branch", "upstream_ref", "upstream_oid"}:
|
||||
result[key] = None
|
||||
for key in ("detached", "clean"):
|
||||
value = raw.get(key)
|
||||
if isinstance(value, bool):
|
||||
result[key] = value
|
||||
for key in ("dirty_entries", "remote_ref_count"):
|
||||
value = raw.get(key)
|
||||
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
|
||||
result[key] = min(value, 1_000_000)
|
||||
return result
|
||||
|
||||
|
||||
def _normalise_repository_grant_evidence(raw: Any) -> dict[str, Any]:
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
result: dict[str, Any] = {}
|
||||
for key in (
|
||||
"grant_id",
|
||||
"acceptance_policy_id",
|
||||
"version",
|
||||
"allowed_paths_digest",
|
||||
):
|
||||
value = raw.get(key)
|
||||
if isinstance(value, str):
|
||||
result[key] = value[:500]
|
||||
for key in ("allowed_path_count", "min_commits", "max_commits"):
|
||||
value = raw.get(key)
|
||||
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
|
||||
result[key] = min(value, 1_000_000)
|
||||
publish = raw.get("publish")
|
||||
if isinstance(publish, bool):
|
||||
result["publish"] = publish
|
||||
return result
|
||||
|
||||
|
||||
def _normalise_repository_acceptance(raw: Any) -> dict[str, Any]:
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
result: dict[str, Any] = {}
|
||||
for key in (
|
||||
"policy_id",
|
||||
"head",
|
||||
"branch",
|
||||
"changed_paths_digest",
|
||||
):
|
||||
value = raw.get(key)
|
||||
if isinstance(value, str):
|
||||
result[key] = value[:500]
|
||||
elif value is None and key == "branch":
|
||||
result[key] = None
|
||||
for key in (
|
||||
"accepted",
|
||||
"changed_paths_truncated",
|
||||
"clean_post_state",
|
||||
"remote_refs_unchanged",
|
||||
"protected_git_metadata_unchanged",
|
||||
):
|
||||
value = raw.get(key)
|
||||
if isinstance(value, bool):
|
||||
result[key] = value
|
||||
for key in ("commit_count", "changed_path_count"):
|
||||
value = raw.get(key)
|
||||
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
|
||||
result[key] = min(value, 1_000_000)
|
||||
for key, limit in (("commits", 32), ("changed_paths", 100)):
|
||||
value = raw.get(key)
|
||||
if isinstance(value, list):
|
||||
result[key] = [
|
||||
item[:500] for item in value[:limit] if isinstance(item, str)
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
def _normalise_repository_metrics(raw: Any) -> dict[str, Any]:
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
result: dict[str, Any] = {}
|
||||
for key in ("storage", "session_id"):
|
||||
value = raw.get(key)
|
||||
if isinstance(value, str):
|
||||
result[key] = value[:500]
|
||||
projection_ready = raw.get("projection_ready")
|
||||
if isinstance(projection_ready, bool):
|
||||
result["projection_ready"] = projection_ready
|
||||
return result
|
||||
|
||||
|
||||
def normalise_repository_transaction(raw: Any) -> dict[str, Any]:
|
||||
"""Allowlist bounded rein repository transaction/acceptance evidence."""
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
for key in ("transaction_id", "correlation_id"):
|
||||
value = raw.get(key)
|
||||
if isinstance(value, str):
|
||||
result[key] = value[:500]
|
||||
|
||||
for key, normalizer in (
|
||||
("baseline", _normalise_repository_baseline),
|
||||
("repository_grant", _normalise_repository_grant_evidence),
|
||||
("acceptance", _normalise_repository_acceptance),
|
||||
("metrics", _normalise_repository_metrics),
|
||||
):
|
||||
value = normalizer(raw.get(key))
|
||||
if value:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def normalise_ops_result(raw: Any) -> dict[str, Any]:
|
||||
"""Allowlist legacy completion facts plus normalized Glas evidence."""
|
||||
if not isinstance(raw, dict):
|
||||
|
|
@ -177,4 +308,11 @@ def normalise_ops_result(raw: Any) -> dict[str, Any]:
|
|||
evidence = normalise_execution_evidence(raw_evidence)
|
||||
if evidence:
|
||||
result["execution_evidence"] = evidence
|
||||
|
||||
raw_transaction = raw.get("repository_transaction")
|
||||
if not isinstance(raw_transaction, dict):
|
||||
raw_transaction = raw.get("transaction")
|
||||
transaction = normalise_repository_transaction(raw_transaction)
|
||||
if transaction:
|
||||
result["repository_transaction"] = transaction
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ from uuid import UUID
|
|||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from activity_core.repository_grant import RepositoryGrant
|
||||
|
||||
|
||||
# ── EventEnvelope (T40) ───────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -102,6 +104,7 @@ class ActionDef(BaseModel):
|
|||
approach_hint: str | None = Field(default=None)
|
||||
harness_profile_ref: str | None = Field(default=None)
|
||||
execution_refs: dict[str, Any] = Field(default_factory=dict)
|
||||
repository_grant: RepositoryGrant | None = Field(default=None)
|
||||
|
||||
|
||||
class RuleDef(BaseModel):
|
||||
|
|
@ -152,6 +155,7 @@ class InstructionDef(BaseModel):
|
|||
approach_hint: str | None = Field(default=None)
|
||||
harness_profile_ref: str | None = Field(default=None)
|
||||
execution_refs: dict[str, Any] = Field(default_factory=dict)
|
||||
repository_grant: RepositoryGrant | None = Field(default=None)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -11,18 +14,47 @@ from sqlalchemy import Select, and_, func, select, update
|
|||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from activity_core.glas_evidence import normalise_ops_result
|
||||
from activity_core.glas_profile import (
|
||||
normalise_execution_refs,
|
||||
resolve_execution_selector,
|
||||
)
|
||||
from activity_core.glas_evidence import normalise_ops_result
|
||||
from activity_core.orm import OpsRun
|
||||
from activity_core.repository_grant import (
|
||||
RepositoryGrant,
|
||||
validate_repository_grant,
|
||||
)
|
||||
from activity_core.rules.models import TaskSpec
|
||||
|
||||
OPS_RUN_STATES = frozenset(
|
||||
{"open", "claimed", "succeeded", "failed", "expired"}
|
||||
)
|
||||
|
||||
CLOSE_DISPOSITIONS = frozenset(
|
||||
{
|
||||
"applied",
|
||||
"reconciled",
|
||||
"not_found",
|
||||
"wrong_owner",
|
||||
"expired_lease",
|
||||
"state_conflict",
|
||||
"terminal_conflict",
|
||||
"evidence_conflict",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CloseOpsRunOutcome:
|
||||
"""Explicit result of a row-locked terminal/reopen mutation decision."""
|
||||
|
||||
disposition: str
|
||||
row: OpsRun | None
|
||||
|
||||
@property
|
||||
def accepted(self) -> bool:
|
||||
return self.disposition in {"applied", "reconciled"}
|
||||
|
||||
|
||||
def ops_run_queue_enabled() -> bool:
|
||||
raw = (os.environ.get("OPS_RUN_QUEUE_ENABLED") or "true").strip().lower()
|
||||
|
|
@ -113,7 +145,11 @@ def ops_run_to_dict(row: OpsRun) -> dict[str, Any]:
|
|||
"approach_hint": row.approach_hint,
|
||||
"harness_profile_ref": row.harness_profile_ref,
|
||||
"execution_refs": dict(row.execution_refs or {}),
|
||||
"repository_grant": (
|
||||
dict(row.repository_grant) if row.repository_grant is not None else None
|
||||
),
|
||||
"result": normalise_ops_result(row.result),
|
||||
"close_intent_digest": row.close_intent_digest,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
|
@ -126,6 +162,7 @@ async def create_ops_run_from_spec(
|
|||
approach_hint: str | None = None,
|
||||
harness_profile_ref: str | None = None,
|
||||
execution_refs: dict | None = None,
|
||||
repository_grant: dict | None = None,
|
||||
) -> uuid.UUID | None:
|
||||
"""Insert ops_run if queue enabled; return id or None if disabled/duplicate."""
|
||||
if not ops_run_queue_enabled():
|
||||
|
|
@ -163,7 +200,13 @@ async def create_ops_run_from_spec(
|
|||
approach_hint=hint,
|
||||
harness_profile_ref=profile_ref,
|
||||
execution_refs=normalise_execution_refs(execution_refs),
|
||||
repository_grant=(
|
||||
validate_repository_grant(repository_grant)
|
||||
if repository_grant is not None
|
||||
else None
|
||||
),
|
||||
result={},
|
||||
close_intent_digest=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
|
@ -287,19 +330,14 @@ async def complete_ops_run(
|
|||
worker_id: str,
|
||||
result: dict[str, Any] | None = None,
|
||||
) -> OpsRun | None:
|
||||
row = await session.get(OpsRun, run_id, with_for_update=True)
|
||||
if row is None:
|
||||
return None
|
||||
if row.state != "claimed" or row.claim_owner != worker_id:
|
||||
return None
|
||||
now = _utcnow()
|
||||
if not _has_active_lease(row, now=now):
|
||||
return None
|
||||
row.state = "succeeded"
|
||||
row.lease_until = None
|
||||
row.result = normalise_ops_result(result)
|
||||
row.updated_at = now
|
||||
return row
|
||||
outcome = await close_ops_run(
|
||||
session,
|
||||
run_id,
|
||||
worker_id=worker_id,
|
||||
action="complete",
|
||||
result=result,
|
||||
)
|
||||
return outcome.row if outcome.accepted else None
|
||||
|
||||
|
||||
async def fail_ops_run(
|
||||
|
|
@ -311,27 +349,142 @@ async def fail_ops_run(
|
|||
reopen: bool = False,
|
||||
result: dict[str, Any] | None = None,
|
||||
) -> OpsRun | None:
|
||||
outcome = await close_ops_run(
|
||||
session,
|
||||
run_id,
|
||||
worker_id=worker_id,
|
||||
action="fail",
|
||||
error=error,
|
||||
reopen=reopen,
|
||||
result=result,
|
||||
)
|
||||
return outcome.row if outcome.accepted else None
|
||||
|
||||
|
||||
async def close_ops_run(
|
||||
session: AsyncSession,
|
||||
run_id: uuid.UUID,
|
||||
*,
|
||||
worker_id: str,
|
||||
action: str,
|
||||
error: str = "",
|
||||
reopen: bool = False,
|
||||
result: dict[str, Any] | None = None,
|
||||
) -> CloseOpsRunOutcome:
|
||||
"""Apply or reconcile one completion/failure under a row lock.
|
||||
|
||||
A first close still requires the authenticated owner and an active lease.
|
||||
Once terminal, only an exact repeat of the persisted close intent by that
|
||||
same owner is accepted, making a lost HTTP response safely reconcilable.
|
||||
"""
|
||||
if action not in {"complete", "fail"}:
|
||||
raise ValueError("action must be 'complete' or 'fail'")
|
||||
|
||||
row = await session.get(OpsRun, run_id, with_for_update=True)
|
||||
if row is None:
|
||||
return None
|
||||
if row.state != "claimed" or row.claim_owner != worker_id:
|
||||
return None
|
||||
return CloseOpsRunOutcome("not_found", None)
|
||||
|
||||
payload = normalise_ops_result(result)
|
||||
if action == "fail" and error:
|
||||
payload["error"] = error[:2000]
|
||||
intent_digest = _close_intent_digest(
|
||||
action=action,
|
||||
result=payload,
|
||||
reopen=reopen,
|
||||
)
|
||||
terminal_state = "succeeded" if action == "complete" else "failed"
|
||||
|
||||
if row.state in {"succeeded", "failed"}:
|
||||
if row.claim_owner != worker_id:
|
||||
return CloseOpsRunOutcome("wrong_owner", row)
|
||||
if not _granted_close_evidence_matches(row, action=action, result=payload):
|
||||
return CloseOpsRunOutcome("evidence_conflict", row)
|
||||
if (
|
||||
row.state == terminal_state
|
||||
and row.close_intent_digest == intent_digest
|
||||
and normalise_ops_result(row.result) == payload
|
||||
):
|
||||
return CloseOpsRunOutcome("reconciled", row)
|
||||
return CloseOpsRunOutcome("terminal_conflict", row)
|
||||
|
||||
if row.state != "claimed":
|
||||
return CloseOpsRunOutcome("state_conflict", row)
|
||||
if row.claim_owner != worker_id:
|
||||
return CloseOpsRunOutcome("wrong_owner", row)
|
||||
|
||||
now = _utcnow()
|
||||
if not _has_active_lease(row, now=now):
|
||||
return None
|
||||
payload = normalise_ops_result(result)
|
||||
if error:
|
||||
payload["error"] = error[:2000]
|
||||
return CloseOpsRunOutcome("expired_lease", row)
|
||||
if not _granted_close_evidence_matches(row, action=action, result=payload):
|
||||
return CloseOpsRunOutcome("evidence_conflict", row)
|
||||
|
||||
row.result = payload
|
||||
row.updated_at = now
|
||||
if reopen and int(row.attempt or 0) < max_attempts():
|
||||
if action == "fail" and reopen and int(row.attempt or 0) < max_attempts():
|
||||
row.state = "open"
|
||||
row.claim_owner = None
|
||||
row.lease_until = None
|
||||
row.close_intent_digest = None
|
||||
else:
|
||||
row.state = "failed"
|
||||
row.state = terminal_state
|
||||
row.lease_until = None
|
||||
return row
|
||||
row.close_intent_digest = intent_digest
|
||||
return CloseOpsRunOutcome("applied", row)
|
||||
|
||||
|
||||
def _close_intent_digest(
|
||||
*,
|
||||
action: str,
|
||||
result: dict[str, Any],
|
||||
reopen: bool,
|
||||
) -> str:
|
||||
payload = json.dumps(
|
||||
{"action": action, "reopen": reopen, "result": result},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _granted_close_evidence_matches(
|
||||
row: OpsRun,
|
||||
*,
|
||||
action: str,
|
||||
result: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Bind granted close evidence to the authority stored on the queue row."""
|
||||
raw_grant = row.repository_grant
|
||||
if raw_grant is None:
|
||||
return True
|
||||
try:
|
||||
grant = RepositoryGrant.model_validate(raw_grant)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
transaction = result.get("repository_transaction")
|
||||
if not isinstance(transaction, dict):
|
||||
return action == "fail"
|
||||
|
||||
transaction_id = transaction.get("transaction_id")
|
||||
grant_evidence = transaction.get("repository_grant")
|
||||
if not isinstance(transaction_id, str) or not transaction_id:
|
||||
return False
|
||||
if not isinstance(grant_evidence, dict):
|
||||
return False
|
||||
if grant_evidence.get("grant_id") != grant.grant_id:
|
||||
return False
|
||||
if grant_evidence.get("acceptance_policy_id") != grant.acceptance_policy_id:
|
||||
return False
|
||||
|
||||
if action == "fail":
|
||||
return True
|
||||
acceptance = transaction.get("acceptance")
|
||||
return bool(
|
||||
isinstance(acceptance, dict)
|
||||
and acceptance.get("accepted") is True
|
||||
and acceptance.get("policy_id") == grant.acceptance_policy_id
|
||||
)
|
||||
|
||||
|
||||
async def list_ops_runs(
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ from __future__ import annotations
|
|||
import hmac
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Header, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
|
|
@ -20,10 +21,10 @@ from activity_core.ops_auth import (
|
|||
require_operator,
|
||||
)
|
||||
from activity_core.ops_run_queue import (
|
||||
CloseOpsRunOutcome,
|
||||
claim_ops_runs,
|
||||
complete_ops_run,
|
||||
close_ops_run,
|
||||
default_lease_seconds,
|
||||
fail_ops_run,
|
||||
heartbeat_ops_run,
|
||||
list_ops_runs,
|
||||
ops_run_counts,
|
||||
|
|
@ -186,6 +187,35 @@ class FailBody(BaseModel):
|
|||
result: dict[str, Any] | None = None
|
||||
|
||||
|
||||
_CLOSE_MESSAGES = {
|
||||
"not_found": "ops_run not found",
|
||||
"wrong_owner": "ops_run is not owned by this worker",
|
||||
"expired_lease": "ops_run lease is missing or expired",
|
||||
"state_conflict": "ops_run is not actively claimed",
|
||||
"terminal_conflict": "terminal ops_run has a different close intent",
|
||||
"evidence_conflict": "repository evidence does not match the queued grant",
|
||||
}
|
||||
|
||||
|
||||
def _close_response(outcome: CloseOpsRunOutcome) -> dict[str, Any]:
|
||||
"""Return an applied/reconciled row or a machine-distinct refusal."""
|
||||
if outcome.accepted and outcome.row is not None:
|
||||
response = ops_run_to_dict(outcome.row)
|
||||
response["close_disposition"] = outcome.disposition
|
||||
return response
|
||||
|
||||
code = outcome.disposition
|
||||
status_code = 404 if code == "not_found" else 409
|
||||
raise HTTPException(
|
||||
status_code=status_code,
|
||||
detail={
|
||||
"code": code,
|
||||
"message": _CLOSE_MESSAGES.get(code, "ops_run close refused"),
|
||||
"state": getattr(outcome.row, "state", None),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_ops_runs(
|
||||
request: Request,
|
||||
|
|
@ -329,18 +359,14 @@ async def post_complete(
|
|||
Session = _db()
|
||||
async with Session() as session:
|
||||
async with session.begin():
|
||||
row = await complete_ops_run(
|
||||
outcome = await close_ops_run(
|
||||
session,
|
||||
run_id,
|
||||
worker_id=worker_id,
|
||||
action="complete",
|
||||
result=body.result,
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="not actively leased by this worker or not found",
|
||||
)
|
||||
return ops_run_to_dict(row)
|
||||
return _close_response(outcome)
|
||||
|
||||
|
||||
@router.post("/{run_id}/fail")
|
||||
|
|
@ -360,20 +386,16 @@ async def post_fail(
|
|||
Session = _db()
|
||||
async with Session() as session:
|
||||
async with session.begin():
|
||||
row = await fail_ops_run(
|
||||
outcome = await close_ops_run(
|
||||
session,
|
||||
run_id,
|
||||
worker_id=worker_id,
|
||||
action="fail",
|
||||
error=body.error,
|
||||
reopen=body.reopen,
|
||||
result=body.result,
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="not actively leased by this worker or not found",
|
||||
)
|
||||
return ops_run_to_dict(row)
|
||||
return _close_response(outcome)
|
||||
|
||||
|
||||
@router.post("/expire-leases")
|
||||
|
|
|
|||
|
|
@ -156,7 +156,14 @@ class OpsRun(Base):
|
|||
# Attribution refs carried through from workforce/leadership vocabulary;
|
||||
# activity-core does not author or interpret them.
|
||||
execution_refs: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
# Explicit repository mutation authority. This is not attribution and must
|
||||
# remain separate from execution_refs.
|
||||
repository_grant: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
result: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
# Canonical digest of the accepted terminal close request. It enables a
|
||||
# response-lost caller to reconcile an exact repeat without weakening the
|
||||
# active-lease requirement for a first mutation.
|
||||
close_intent_digest: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
|
|
|||
133
src/activity_core/repository_grant.py
Normal file
133
src/activity_core/repository_grant.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"""Typed repository mutation authority carried by Activity Core.
|
||||
|
||||
The v1 schema mirrors the accepted rein-aharness repository-grant contract,
|
||||
but Activity Core owns admission and unchanged queue carriage at its boundary.
|
||||
It deliberately does not infer grants from prose, labels, profiles, or
|
||||
organizational attribution references.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
StrictBool,
|
||||
StrictInt,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
|
||||
class RepositoryGrantError(ValueError):
|
||||
"""A repository grant is malformed, unsafe, or unsupported."""
|
||||
|
||||
|
||||
class CommitCount(BaseModel):
|
||||
"""Allowed number of descendant local commits for repository-grant v1."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
min: StrictInt = Field(ge=1, le=32)
|
||||
max: StrictInt = Field(ge=1, le=32)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _ordered_bounds(self) -> CommitCount:
|
||||
if self.min > self.max:
|
||||
raise ValueError("commit bounds must satisfy 1 <= min <= max <= 32")
|
||||
return self
|
||||
|
||||
|
||||
class RepositoryGrant(BaseModel):
|
||||
"""Versioned, local-only repository authority envelope."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
version: Literal["1"]
|
||||
allowed_paths: list[str] = Field(min_length=1, max_length=100)
|
||||
commit_count: CommitCount
|
||||
publish: StrictBool
|
||||
|
||||
@field_validator("allowed_paths")
|
||||
@classmethod
|
||||
def _validate_allowed_paths(cls, value: list[str]) -> list[str]:
|
||||
if len(set(value)) != len(value):
|
||||
raise ValueError("allowed_paths must not contain duplicates")
|
||||
for pattern in value:
|
||||
_validate_path_pattern(pattern)
|
||||
return value
|
||||
|
||||
@field_validator("publish")
|
||||
@classmethod
|
||||
def _local_only(cls, value: bool) -> bool:
|
||||
if value:
|
||||
raise ValueError(
|
||||
"version 1 does not grant publication; publish must be false"
|
||||
)
|
||||
return value
|
||||
|
||||
def payload(self) -> dict[str, Any]:
|
||||
"""Return the admitted field without semantic rewriting or inference."""
|
||||
return self.model_dump(mode="python")
|
||||
|
||||
@property
|
||||
def grant_id(self) -> str:
|
||||
"""Match rein-aharness's canonical, path-order-independent grant id."""
|
||||
return _digest(
|
||||
{
|
||||
"allowed_paths": sorted(self.allowed_paths),
|
||||
"commit_count": {
|
||||
"max": self.commit_count.max,
|
||||
"min": self.commit_count.min,
|
||||
},
|
||||
"publish": self.publish,
|
||||
"version": self.version,
|
||||
}
|
||||
)[:32]
|
||||
|
||||
@property
|
||||
def acceptance_policy_id(self) -> str:
|
||||
"""Match the rein-aharness v1 acceptance-policy identity."""
|
||||
return _digest(
|
||||
{
|
||||
"allowed_paths": sorted(self.allowed_paths),
|
||||
"max_commits": self.commit_count.max,
|
||||
"max_evidence_paths": 100,
|
||||
"min_commits": self.commit_count.min,
|
||||
}
|
||||
)[:32]
|
||||
|
||||
|
||||
def validate_repository_grant(value: Any) -> dict[str, Any]:
|
||||
"""Validate and return an unchanged-shape repository-grant v1 mapping."""
|
||||
try:
|
||||
return RepositoryGrant.model_validate(value).payload()
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RepositoryGrantError(str(exc)) from exc
|
||||
|
||||
|
||||
def _validate_path_pattern(pattern: str) -> None:
|
||||
if not pattern or len(pattern) > 256:
|
||||
raise ValueError(
|
||||
"path patterns must be non-empty strings of at most 256 characters"
|
||||
)
|
||||
normalized = pattern.rstrip("/")
|
||||
parts = tuple(normalized.split("/"))
|
||||
if (
|
||||
pattern.startswith("/")
|
||||
or "\\" in pattern
|
||||
or "\0" in pattern
|
||||
or any(part in {"", ".", ".."} for part in parts)
|
||||
):
|
||||
raise ValueError(f"path pattern must be repository-relative: {pattern!r}")
|
||||
if parts and parts[0] == ".git":
|
||||
raise ValueError("path patterns cannot grant protected .git metadata")
|
||||
|
||||
|
||||
def _digest(value: dict[str, Any]) -> str:
|
||||
payload = json.dumps(value, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
|
@ -7,6 +7,7 @@ activity_core.* module outside rules/.
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -89,6 +90,10 @@ def _task_spec_for_rule(rule: dict, event: Any, context: dict) -> dict:
|
|||
result["execution_refs"] = _render_execution_refs(
|
||||
action.get("execution_refs"), event, context
|
||||
)
|
||||
if "repository_grant" in action:
|
||||
# Authority is static definition data. It must never be templated from
|
||||
# event/context values or inferred from another declaration field.
|
||||
result["repository_grant"] = deepcopy(action.get("repository_grant"))
|
||||
result["condition"] = rule.get("condition", "")
|
||||
return result
|
||||
|
||||
|
|
|
|||
|
|
@ -173,6 +173,9 @@ def _ops_summary(row: OpsRun) -> dict[str, Any]:
|
|||
}
|
||||
if execution_evidence:
|
||||
compact_result["execution_evidence"] = execution_evidence
|
||||
repository_transaction = dict(result.get("repository_transaction") or {})
|
||||
if repository_transaction:
|
||||
compact_result["repository_transaction"] = repository_transaction
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"state": row.state,
|
||||
|
|
@ -185,7 +188,12 @@ def _ops_summary(row: OpsRun) -> dict[str, Any]:
|
|||
"approach_hint": row.approach_hint,
|
||||
"harness_profile_ref": row.harness_profile_ref,
|
||||
"execution_refs": dict(row.execution_refs or {}),
|
||||
"repository_grant": (
|
||||
dict(row.repository_grant) if row.repository_grant is not None else None
|
||||
),
|
||||
"execution_evidence": execution_evidence,
|
||||
"repository_transaction": repository_transaction,
|
||||
"close_intent_digest": row.close_intent_digest,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
"result": compact_result,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue