Normalize Glas execution evidence
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 20s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a028de-e2c8-7732-8521-46a7fc5db82f
This commit is contained in:
tegwick 2026-08-22 23:11:52 +02:00
parent b933cf52c8
commit 7e81545b24
13 changed files with 528 additions and 33 deletions

View file

@ -0,0 +1,180 @@
"""Compact, non-secret Glas execution evidence for ops_run results.
Glas owns the versioned evidence envelope. Activity Core accepts that envelope
at its completion boundary, keeps only contract fields needed by operators, and
never persists direct-caller ``tool_output`` / ``tool_error`` or provider blobs.
"""
from __future__ import annotations
import math
from typing import Any
from activity_core.glas_profile import normalise_execution_refs
_LEGACY_RESULT_FIELDS = frozenset(
{
"ok",
"approach",
"path",
"report",
"date",
"wrote",
"committed",
"pushed",
"skipped_existing",
"head_after",
"target_repo",
"collection_candidates",
"reason",
"error",
}
)
_EVIDENCE_STRING_FIELDS = frozenset(
{
"request_id",
"correlation_id",
"actor",
"project",
"target_repo",
"contract_version",
"profile_ref",
"rein_id",
"rein_version",
"model_route",
"resolved_model",
"sandbox_profile",
"sandbox_id",
"tool_profile",
"outcome",
"failure_stage",
"error",
"started_at",
"finished_at",
"commit_sha",
"tool_events_completeness",
}
)
_EVIDENCE_NUMBER_FIELDS = frozenset(
{"duration_s", "tokens_spent", "token_budget", "tool_events_count"}
)
_EVIDENCE_ENUMS = {
"outcome": frozenset({"succeeded", "failed", "refused"}),
"failure_stage": frozenset(
{
"resolution",
"sandbox_create",
"session_start",
"execution",
"session_end",
"teardown",
}
),
"tool_events_completeness": frozenset({"complete", "partial", "unavailable"}),
}
def _compact_scalar(value: Any, *, max_length: int = 2000) -> Any | None:
if isinstance(value, bool):
return value
if (
isinstance(value, (int, float))
and not isinstance(value, bool)
and math.isfinite(value)
):
return value
if isinstance(value, str):
return value[:max_length]
return None
def _compact_legacy_value(value: Any) -> Any | None:
scalar = _compact_scalar(value)
if scalar is not None:
return scalar
if isinstance(value, list):
compact = [_compact_scalar(item, max_length=500) for item in value[:20]]
return [item for item in compact if item is not None]
return None
def normalise_execution_evidence(raw: Any) -> dict[str, Any]:
"""Return the safe subset of Glas ``ExecutionEvidence`` contract 1.0."""
if not isinstance(raw, dict):
return {}
evidence: dict[str, Any] = {}
for key in _EVIDENCE_STRING_FIELDS:
value = raw.get(key)
if isinstance(value, str):
allowed = _EVIDENCE_ENUMS.get(key)
if allowed is not None and value not in allowed:
continue
evidence[key] = value[:2000] if key == "error" else value[:500]
for key in _EVIDENCE_NUMBER_FIELDS:
value = raw.get(key)
if (
isinstance(value, (int, float))
and not isinstance(value, bool)
and math.isfinite(value)
and value >= 0
):
evidence[key] = value
artifacts = raw.get("artifacts")
if isinstance(artifacts, list):
evidence["artifacts"] = [
item[:1000]
for item in artifacts[:20]
if isinstance(item, str) and item
]
refs = normalise_execution_refs(raw.get("refs"))
if refs:
evidence["refs"] = refs
return evidence
def _normalise_artifact_urls(raw: Any) -> list[dict[str, str]]:
if not isinstance(raw, list):
return []
urls: list[dict[str, str]] = []
for item in raw[:10]:
if not isinstance(item, dict):
continue
url = item.get("url")
if not isinstance(url, str) or not url.startswith("https://"):
continue
urls.append(
{
"kind": str(item.get("kind") or "link")[:80],
"label": str(item.get("label") or "artifact")[:120],
"url": url[:2000],
}
)
return urls
def normalise_ops_result(raw: Any) -> dict[str, Any]:
"""Allowlist legacy completion facts plus normalized Glas evidence."""
if not isinstance(raw, dict):
return {}
result: dict[str, Any] = {}
for key in _LEGACY_RESULT_FIELDS:
value = _compact_legacy_value(raw.get(key))
if value is not None:
result[key] = value
artifact_urls = _normalise_artifact_urls(raw.get("artifact_urls"))
if artifact_urls:
result["artifact_urls"] = artifact_urls
raw_evidence = raw.get("execution_evidence")
if not isinstance(raw_evidence, dict):
raw_evidence = raw.get("evidence")
evidence = normalise_execution_evidence(raw_evidence)
if evidence:
result["execution_evidence"] = evidence
return result

View file

@ -123,9 +123,13 @@ def normalise_execution_refs(raw: Any) -> dict[str, Any]:
value = raw.get(key)
if key in _LIST_REF_KEYS:
if isinstance(value, (list, tuple)):
items = [str(item).strip() for item in value if str(item).strip()]
items = [
item.strip()[:500]
for item in value[:20]
if isinstance(item, str) and item.strip()
]
if items:
refs[key] = items
elif isinstance(value, str) and value.strip():
refs[key] = value.strip()
refs[key] = value.strip()[:500]
return refs

View file

@ -15,6 +15,7 @@ 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.rules.models import TaskSpec
@ -98,7 +99,7 @@ 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 {}),
"result": dict(row.result or {}),
"result": normalise_ops_result(row.result),
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
@ -278,7 +279,7 @@ async def complete_ops_run(
now = datetime.now(timezone.utc)
row.state = "succeeded"
row.lease_until = None
row.result = dict(result or {})
row.result = normalise_ops_result(result)
row.updated_at = now
return row
@ -298,7 +299,7 @@ async def fail_ops_run(
if row.state != "claimed" or row.claim_owner != worker_id:
return None
now = datetime.now(timezone.utc)
payload = dict(result or {})
payload = normalise_ops_result(result)
if error:
payload["error"] = error[:2000]
row.result = payload

View file

@ -15,6 +15,7 @@ from urllib.parse import quote
from sqlalchemy import Select, select
from sqlalchemy.ext.asyncio import AsyncSession
from activity_core.glas_evidence import normalise_ops_result
from activity_core.orm import ActivityRun, OpsRun, TaskSpawnLog
# Default public Forgejo web base (no trailing slash). Override with FORGEJO_WEB_BASE.
@ -136,13 +137,33 @@ def artifacts_from_ops_result(
def _ops_summary(row: OpsRun) -> dict[str, Any]:
result = dict(row.result or {})
# Drop bulky keys if ever present
for bad in ("prompt", "raw_output", "messages", "token"):
result.pop(bad, None)
# Normalize again on read so historic rows cannot leak pre-allowlist blobs.
result = normalise_ops_result(row.result)
artifacts = artifacts_from_ops_result(
result, target_repo=row.target_repo, title=row.title
)
execution_evidence = dict(result.get("execution_evidence") or {})
compact_result = {
k: result[k]
for k in (
"ok",
"approach",
"path",
"date",
"wrote",
"committed",
"pushed",
"skipped_existing",
"head_after",
"target_repo",
"collection_candidates",
"reason",
"error",
)
if k in result
}
if execution_evidence:
compact_result["execution_evidence"] = execution_evidence
return {
"id": str(row.id),
"state": row.state,
@ -155,26 +176,10 @@ 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 {}),
"execution_evidence": execution_evidence,
"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": {
k: result[k]
for k in (
"ok",
"approach",
"path",
"date",
"wrote",
"committed",
"pushed",
"skipped_existing",
"head_after",
"target_repo",
"collection_candidates",
"reason",
)
if k in result
},
"result": compact_result,
"artifacts": artifacts,
}