Enforce bounded operation guardrails
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a028de-e2c8-7732-8521-46a7fc5db82f
This commit is contained in:
parent
c384f60530
commit
26934e25b9
51 changed files with 1843 additions and 472 deletions
|
|
@ -22,10 +22,11 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|||
from temporalio import activity
|
||||
from temporalio.exceptions import ApplicationError
|
||||
|
||||
from activity_core.audit_projection import bounded_audit_projection
|
||||
from activity_core.db import make_engine
|
||||
from activity_core.issue_sink import get_issue_sink
|
||||
from activity_core.orm import ActivityDefinition as ActivityDefinitionRow
|
||||
from activity_core.orm import ActivityRun, TaskInstance, TaskSpawnLog
|
||||
from activity_core.orm import ActivityRun, TaskSpawnLog
|
||||
from activity_core.ops_run_queue import create_ops_run_from_spec
|
||||
from activity_core.llm_client import get_llm_client
|
||||
from activity_core.models import InstructionDef
|
||||
|
|
@ -111,6 +112,16 @@ async def load_activity_definition(activity_id: str) -> dict:
|
|||
non_retryable=True,
|
||||
)
|
||||
|
||||
from activity_core.bounded_operations import validate_bounded_operations
|
||||
|
||||
try:
|
||||
validate_bounded_operations(row.context_sources, row.instructions_json)
|
||||
except ValueError as exc:
|
||||
raise ApplicationError(
|
||||
f"ActivityDefinition {activity_id!r} violates bounded-operation policy: {exc}",
|
||||
non_retryable=True,
|
||||
) from exc
|
||||
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"name": row.name,
|
||||
|
|
@ -141,6 +152,11 @@ async def resolve_context(
|
|||
The 'static' type is handled inline without a registry entry.
|
||||
"""
|
||||
import activity_core.context_resolvers # noqa: F401 — registers all adapters
|
||||
from activity_core.bounded_operations import (
|
||||
READ_ONLY_SHELL_QUERIES,
|
||||
operation_spec_for_source,
|
||||
pending_operation_value,
|
||||
)
|
||||
from activity_core.context_resolvers.base import CONTEXT_RESOLVER_REGISTRY
|
||||
|
||||
snapshot: dict = {}
|
||||
|
|
@ -156,6 +172,22 @@ async def resolve_context(
|
|||
# Strip the 'context.' namespace prefix so evaluator can find the key.
|
||||
bind_key = raw_bind.removeprefix("context.") if raw_bind.startswith("context.") else raw_bind
|
||||
|
||||
operation_spec = operation_spec_for_source(source)
|
||||
if operation_spec is not None:
|
||||
if source.get("operation") != operation_spec.operation_id:
|
||||
raise ApplicationError(
|
||||
f"Bounded operation {source_type!r}/{query!r} is not explicitly admitted",
|
||||
non_retryable=True,
|
||||
)
|
||||
if not operation_spec.resolve_before_execute:
|
||||
snapshot[bind_key] = pending_operation_value(operation_spec)
|
||||
continue
|
||||
elif source_type == "shell" and query not in READ_ONLY_SHELL_QUERIES:
|
||||
raise ApplicationError(
|
||||
f"Shell query {query!r} is not registered as read-only",
|
||||
non_retryable=True,
|
||||
)
|
||||
|
||||
if source_type == "static":
|
||||
value = source.get("config", {}).get("value")
|
||||
if isinstance(value, str) and (
|
||||
|
|
@ -199,6 +231,40 @@ async def resolve_context(
|
|||
return snapshot
|
||||
|
||||
|
||||
@activity.defn
|
||||
async def execute_bounded_operation(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Execute one code-registered non-SBOM operation and return its context patch."""
|
||||
from activity_core.bounded_operations import (
|
||||
normalize_operation_result,
|
||||
operation_spec_for_source,
|
||||
)
|
||||
|
||||
source = payload.get("source")
|
||||
if not isinstance(source, dict):
|
||||
raise ApplicationError("bounded operation source must be a mapping", non_retryable=True)
|
||||
spec = operation_spec_for_source(source)
|
||||
if spec is None or source.get("operation") != spec.operation_id:
|
||||
raise ApplicationError("bounded operation is not registered", non_retryable=True)
|
||||
params = source.get("params") or {}
|
||||
if spec.operation_id == "forgejo_package_prune":
|
||||
from activity_core.context_resolvers.forgejo_prune import forgejo_package_prune
|
||||
|
||||
result = forgejo_package_prune(params)
|
||||
elif spec.operation_id == "cnpg_option_a_backup":
|
||||
from activity_core.context_resolvers.cnpg_backup import cnpg_option_a_backup
|
||||
|
||||
result = cnpg_option_a_backup(params)
|
||||
else:
|
||||
raise ApplicationError(
|
||||
f"bounded operation {spec.operation_id!r} uses a dedicated activity",
|
||||
non_retryable=True,
|
||||
)
|
||||
|
||||
raw_bind = source.get("bind_to") or source.get("name") or source.get("type") or "operation"
|
||||
bind_key = str(raw_bind).removeprefix("context.")
|
||||
return {bind_key: normalize_operation_result(spec.operation_id, result)}
|
||||
|
||||
|
||||
def _sbom_heartbeat_state(run_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
details = activity.info().heartbeat_details
|
||||
|
|
@ -307,7 +373,7 @@ async def log_run(run_payload: dict) -> str:
|
|||
activity_id=uuid.UUID(run_payload["activity_id"]),
|
||||
scheduled_for=scheduled_for,
|
||||
fired_at=datetime.now(tz=timezone.utc),
|
||||
context_snapshot=run_payload["context_snapshot"],
|
||||
context_snapshot=bounded_audit_projection(run_payload["context_snapshot"]),
|
||||
tasks_spawned=run_payload["tasks_spawned"],
|
||||
version_used=run_payload["version_used"],
|
||||
)
|
||||
|
|
@ -321,44 +387,6 @@ async def log_run(run_payload: dict) -> str:
|
|||
return str(run_id)
|
||||
|
||||
|
||||
@activity.defn
|
||||
async def persist_task_instance(task_payload: dict) -> str:
|
||||
"""Write a TaskInstance row and return its id.
|
||||
|
||||
Idempotent: uses INSERT … ON CONFLICT (id) DO NOTHING.
|
||||
|
||||
Expected keys in task_payload:
|
||||
id (str UUID — deterministic, computed in TaskExecutorWorkflow)
|
||||
run_id (str UUID)
|
||||
type (str)
|
||||
params (dict)
|
||||
status (str, default "done" for stub)
|
||||
|
||||
Returns:
|
||||
task instance id as a str UUID.
|
||||
"""
|
||||
Session = _get_session_factory()
|
||||
task_id = uuid.UUID(task_payload["id"])
|
||||
|
||||
stmt = (
|
||||
pg_insert(TaskInstance)
|
||||
.values(
|
||||
id=task_id,
|
||||
run_id=uuid.UUID(task_payload["run_id"]),
|
||||
type=task_payload["type"],
|
||||
params=task_payload.get("params", {}),
|
||||
status=task_payload.get("status", "done"),
|
||||
)
|
||||
.on_conflict_do_nothing(index_elements=["id"])
|
||||
)
|
||||
|
||||
async with Session() as session:
|
||||
async with session.begin():
|
||||
await session.execute(stmt)
|
||||
|
||||
return str(task_id)
|
||||
|
||||
|
||||
@activity.defn
|
||||
async def evaluate_rules(payload: dict) -> list[dict]:
|
||||
"""Evaluate rules and render matching actions as task specs.
|
||||
|
|
@ -453,7 +481,7 @@ async def evaluate_instructions(payload: dict) -> dict:
|
|||
)
|
||||
report = result.report
|
||||
output_validated = result.output_validated
|
||||
review_required = result.review_required
|
||||
review_advisory = result.review_advisory
|
||||
validation_error = result.validation_error
|
||||
# ACTIVITY-WP-0021-T05: when LLM produces nothing but a curated digest
|
||||
# is present and the instruction has report sinks, still emit a
|
||||
|
|
@ -471,7 +499,7 @@ async def evaluate_instructions(payload: dict) -> dict:
|
|||
"digest_preview": digest[:4000],
|
||||
}
|
||||
output_validated = False
|
||||
review_required = True
|
||||
review_advisory = True
|
||||
validation_error = (
|
||||
validation_error or "no_llm_report; posted deterministic digest"
|
||||
)
|
||||
|
|
@ -484,7 +512,8 @@ async def evaluate_instructions(payload: dict) -> dict:
|
|||
"prompt_hash": result.prompt_hash,
|
||||
"model": result.model,
|
||||
"output_validated": output_validated,
|
||||
"review_required": review_required,
|
||||
"review_advisory": review_advisory,
|
||||
"review_gate_applied": False,
|
||||
"validation_error": validation_error,
|
||||
"llm_response_metadata": result.llm_response_metadata,
|
||||
})
|
||||
|
|
@ -502,7 +531,8 @@ async def evaluate_instructions(payload: dict) -> dict:
|
|||
"prompt_hash": result.prompt_hash,
|
||||
"model": result.model,
|
||||
"output_validated": result.output_validated,
|
||||
"review_required": result.review_required,
|
||||
"review_advisory": result.review_advisory,
|
||||
"review_gate_applied": False,
|
||||
"approach_hint": instruction.approach_hint,
|
||||
"harness_profile_ref": instruction.harness_profile_ref,
|
||||
"execution_refs": instruction.execution_refs,
|
||||
|
|
@ -636,14 +666,16 @@ async def emit_tasks(payload: dict) -> list[str]:
|
|||
activity_def_id=uuid.UUID(activity_id),
|
||||
source_type=spec.source_type,
|
||||
source_id=spec.source_id,
|
||||
source_version="1",
|
||||
source_version=str(payload.get("version_used", "1")),
|
||||
triggering_event_id=triggering_event_id,
|
||||
task_ref=ref.external_id,
|
||||
condition_matched=spec_dict.get("condition"),
|
||||
prompt_hash=spec_dict.get("prompt_hash"),
|
||||
model=spec_dict.get("model"),
|
||||
output_validated=spec_dict.get("output_validated"),
|
||||
review_required=spec_dict.get("review_required"),
|
||||
review_advisory=spec_dict.get(
|
||||
"review_advisory", spec_dict.get("review_required")
|
||||
),
|
||||
)
|
||||
session.add(log_row)
|
||||
except Exception as exc:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"""FastAPI REST API for activity-core.
|
||||
|
||||
T30: CRUD for ActivityDefinition + manual one-shot trigger.
|
||||
T30: basic row administration for ActivityDefinition + manual one-shot trigger.
|
||||
The REST schema does not round-trip markdown-authored rules or instructions;
|
||||
use source sync for the complete definition contract.
|
||||
ACTIVITY-WP-0024: operator automation console under /ops.
|
||||
|
||||
Endpoints:
|
||||
|
|
@ -40,6 +42,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
|
|||
from temporalio.api.workflowservice.v1 import GetSystemInfoRequest
|
||||
from temporalio.client import Client
|
||||
|
||||
from activity_core.bounded_operations import validate_bounded_operations
|
||||
from activity_core.models import ActivityDefinition, CronTriggerConfig
|
||||
from activity_core.execution_api import router as execution_router
|
||||
from activity_core.ops_api import bind_ops_deps, router as ops_router
|
||||
|
|
@ -83,7 +86,14 @@ async def lifespan(app: FastAPI): # type: ignore[type-arg]
|
|||
await engine.dispose()
|
||||
|
||||
|
||||
app = FastAPI(title="activity-core API", lifespan=lifespan)
|
||||
app = FastAPI(
|
||||
title="activity-core API",
|
||||
description=(
|
||||
"Automation operations plus basic ActivityDefinition row administration. "
|
||||
"Markdown source sync owns the full rules/instructions contract."
|
||||
),
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.include_router(webhook_router)
|
||||
app.include_router(ops_router)
|
||||
app.include_router(ops_runs_router)
|
||||
|
|
@ -152,6 +162,16 @@ def _row_to_response(row: ActivityDefinitionRow) -> ActivityDefinitionResponse:
|
|||
)
|
||||
|
||||
|
||||
def _validate_context_admission(
|
||||
context_sources: list[dict[str, Any]],
|
||||
instructions: list[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
validate_bounded_operations(context_sources, instructions or [])
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
async def _upsert_schedule_if_cron(row: ActivityDefinitionRow) -> None:
|
||||
"""Upsert a Temporal Schedule for the row if it uses a cron trigger."""
|
||||
try:
|
||||
|
|
@ -197,7 +217,8 @@ async def get_definition(definition_id: uuid.UUID) -> ActivityDefinitionResponse
|
|||
|
||||
@app.post("/activity-definitions/", response_model=ActivityDefinitionResponse, status_code=201)
|
||||
async def create_definition(body: ActivityDefinitionCreate) -> ActivityDefinitionResponse:
|
||||
"""Create a new ActivityDefinition. Upserts a Temporal Schedule if trigger_type='cron'."""
|
||||
"""Create a basic definition row; source sync owns rules and instructions."""
|
||||
_validate_context_admission(body.context_sources)
|
||||
trigger_type = body.trigger_config.get("trigger_type", "")
|
||||
row = ActivityDefinitionRow(
|
||||
id=uuid.uuid4(),
|
||||
|
|
@ -222,13 +243,20 @@ async def create_definition(body: ActivityDefinitionCreate) -> ActivityDefinitio
|
|||
async def update_definition(
|
||||
definition_id: uuid.UUID, body: ActivityDefinitionUpdate
|
||||
) -> ActivityDefinitionResponse:
|
||||
"""Update an ActivityDefinition. Re-upserts the Temporal Schedule if trigger_type='cron'."""
|
||||
"""Update basic row fields; source sync owns rules and instructions."""
|
||||
Session = _get_db()
|
||||
async with Session() as session:
|
||||
row = await session.get(ActivityDefinitionRow, definition_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="ActivityDefinition not found")
|
||||
|
||||
_validate_context_admission(
|
||||
body.context_sources
|
||||
if body.context_sources is not None
|
||||
else list(row.context_sources or []),
|
||||
list(row.instructions_json or []),
|
||||
)
|
||||
|
||||
if body.name is not None:
|
||||
row.name = body.name
|
||||
if body.enabled is not None:
|
||||
|
|
|
|||
68
src/activity_core/audit_projection.py
Normal file
68
src/activity_core/audit_projection.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""Bounded, non-secret projections for durable audit surfaces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
_SENSITIVE_KEYS = frozenset(
|
||||
{
|
||||
"api_key",
|
||||
"api_token",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"authorization",
|
||||
"client_secret",
|
||||
"cookie",
|
||||
"credential",
|
||||
"credentials",
|
||||
"messages",
|
||||
"model_messages",
|
||||
"password",
|
||||
"private_key",
|
||||
"provider_payload",
|
||||
"provider_response",
|
||||
"raw_output",
|
||||
"raw_output_preview",
|
||||
"raw_prompt",
|
||||
"rendered_prompt",
|
||||
"secret",
|
||||
"token",
|
||||
"tool_error",
|
||||
"tool_output",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def bounded_audit_projection(
|
||||
value: Any,
|
||||
*,
|
||||
max_depth: int = 8,
|
||||
max_items: int = 100,
|
||||
max_string: int = 4000,
|
||||
) -> Any:
|
||||
"""Return a JSON-like audit projection without raw or credential fields.
|
||||
|
||||
The projection is intentionally lossy. Workflow evaluation uses the full
|
||||
in-memory context; only the durable audit copy is bounded here.
|
||||
"""
|
||||
|
||||
def project(item: Any, depth: int) -> Any:
|
||||
if depth > max_depth:
|
||||
return "<depth-limit>"
|
||||
if item is None or isinstance(item, (bool, int, float)):
|
||||
return item
|
||||
if isinstance(item, str):
|
||||
return item[:max_string]
|
||||
if isinstance(item, dict):
|
||||
result: dict[str, Any] = {}
|
||||
for raw_key, child in list(item.items())[:max_items]:
|
||||
key = str(raw_key)
|
||||
if key.strip().lower() in _SENSITIVE_KEYS:
|
||||
continue
|
||||
result[key] = project(child, depth + 1)
|
||||
return result
|
||||
if isinstance(item, (list, tuple)):
|
||||
return [project(child, depth + 1) for child in item[:max_items]]
|
||||
return str(item)[:max_string]
|
||||
|
||||
return project(value, 0)
|
||||
292
src/activity_core/bounded_operations.py
Normal file
292
src/activity_core/bounded_operations.py
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
"""Admission policy for ACT-ADR-007 bounded operations.
|
||||
|
||||
Definitions may select only code-registered operations. This module validates
|
||||
their safety envelope during markdown parsing and exposes immutable runtime
|
||||
metadata to the workflow. It never accepts a command or import path from a
|
||||
definition.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BoundedOperationSpec:
|
||||
operation_id: str
|
||||
source_type: str
|
||||
query: str
|
||||
resolve_before_execute: bool
|
||||
max_timeout_seconds: int
|
||||
temporal_max_attempts: int
|
||||
idempotency: str
|
||||
credential_route: str
|
||||
evidence_mode: str
|
||||
|
||||
|
||||
_SPECS = (
|
||||
BoundedOperationSpec(
|
||||
operation_id="sbom_nexus_ingest",
|
||||
source_type="sbom-nexus",
|
||||
query="catch_up",
|
||||
resolve_before_execute=True,
|
||||
max_timeout_seconds=900,
|
||||
temporal_max_attempts=10,
|
||||
idempotency="activity-run-id + repository; heartbeat acknowledged outcomes",
|
||||
credential_route="activity-core-sbom-nexus",
|
||||
evidence_mode="instruction-report:sbom_catchup",
|
||||
),
|
||||
BoundedOperationSpec(
|
||||
operation_id="forgejo_package_prune",
|
||||
source_type="shell",
|
||||
query="forgejo_package_prune",
|
||||
resolve_before_execute=False,
|
||||
max_timeout_seconds=900,
|
||||
temporal_max_attempts=1,
|
||||
idempotency="single Temporal attempt; operator reconciles ambiguous failure",
|
||||
credential_route="forgejo-admin-api-token",
|
||||
evidence_mode="context-evidence-sink",
|
||||
),
|
||||
BoundedOperationSpec(
|
||||
operation_id="cnpg_option_a_backup",
|
||||
source_type="shell",
|
||||
query="cnpg_option_a_backup",
|
||||
resolve_before_execute=False,
|
||||
max_timeout_seconds=7200,
|
||||
temporal_max_attempts=1,
|
||||
idempotency="single Temporal attempt; platform backup receipt is authoritative",
|
||||
credential_route="railiance-cnpg-option-a-backup",
|
||||
evidence_mode="context-evidence-sink",
|
||||
),
|
||||
)
|
||||
|
||||
BOUNDED_OPERATION_REGISTRY = {spec.operation_id: spec for spec in _SPECS}
|
||||
_BY_SOURCE_QUERY = {(spec.source_type, spec.query): spec for spec in _SPECS}
|
||||
|
||||
# The generic ``shell`` adapter is retained only as a compatibility namespace
|
||||
# for these known read-only queries. Adding a name here requires code review;
|
||||
# definition data cannot supply an arbitrary command.
|
||||
READ_ONLY_SHELL_QUERIES = frozenset(
|
||||
{
|
||||
"reuse_surface_report_gaps",
|
||||
"discover_kaizen_scheduled_repos",
|
||||
"discover_kaizen_projects",
|
||||
}
|
||||
)
|
||||
|
||||
_CANONICAL_SCRIPT_PATHS = {
|
||||
"forgejo_package_prune": "/opt/railiance-platform/tools/cmd/forgejo-package-prune",
|
||||
"cnpg_option_a_backup": "/opt/railiance-platform/tools/cmd/cnpg-option-a-backup",
|
||||
}
|
||||
_TARGET_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
|
||||
|
||||
|
||||
def operation_spec_for_source(source: dict[str, Any]) -> BoundedOperationSpec | None:
|
||||
return _BY_SOURCE_QUERY.get((str(source.get("type") or ""), str(source.get("query") or "")))
|
||||
|
||||
|
||||
def operation_sources(
|
||||
context_sources: Iterable[dict[str, Any]],
|
||||
) -> list[tuple[dict[str, Any], BoundedOperationSpec]]:
|
||||
found: list[tuple[dict[str, Any], BoundedOperationSpec]] = []
|
||||
for source in context_sources:
|
||||
spec = operation_spec_for_source(source)
|
||||
if spec is not None:
|
||||
found.append((source, spec))
|
||||
return found
|
||||
|
||||
|
||||
def pending_operation_value(spec: BoundedOperationSpec) -> dict[str, str]:
|
||||
return {"operation": spec.operation_id, "status": "pending"}
|
||||
|
||||
|
||||
def normalize_operation_result(operation_id: str, raw: Any) -> dict[str, Any]:
|
||||
"""Project subprocess output to the operation's non-secret evidence shape."""
|
||||
result = raw if isinstance(raw, dict) else {}
|
||||
if operation_id == "forgejo_package_prune":
|
||||
return {
|
||||
"kind": operation_id,
|
||||
"apply": bool(result.get("apply")),
|
||||
"candidate_count": _safe_count(result.get("candidate_count")),
|
||||
"deleted_count": _safe_count(result.get("deleted_count")),
|
||||
"skipped_protected_count": _safe_count(
|
||||
result.get("skipped_protected_count")
|
||||
),
|
||||
"error_count": len(result.get("errors") or [])
|
||||
if isinstance(result.get("errors"), list)
|
||||
else 0,
|
||||
}
|
||||
if operation_id == "cnpg_option_a_backup":
|
||||
return {
|
||||
"kind": operation_id,
|
||||
"overall": str(result.get("overall") or "unknown")[:40],
|
||||
"dry_run": bool(result.get("dry_run")),
|
||||
"dumped": _safe_count(result.get("dumped")),
|
||||
"uploaded": _safe_count(result.get("uploaded")),
|
||||
"failed": _safe_count(result.get("failed")),
|
||||
"script_exit_code": _safe_count(result.get("script_exit_code")),
|
||||
}
|
||||
raise ValueError(f"no result projection for bounded operation {operation_id!r}")
|
||||
|
||||
|
||||
def _safe_count(value: Any) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
return 0
|
||||
return max(0, value)
|
||||
|
||||
|
||||
def validate_bounded_operations(
|
||||
context_sources: list[dict[str, Any]],
|
||||
instructions: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Fail closed on unknown shell queries and incomplete operation envelopes."""
|
||||
found = operation_sources(context_sources)
|
||||
if len(found) > 1:
|
||||
names = ", ".join(spec.operation_id for _, spec in found)
|
||||
raise ValueError(
|
||||
"an ActivityDefinition may declare at most one bounded operation; "
|
||||
f"found: {names}"
|
||||
)
|
||||
|
||||
for source in context_sources:
|
||||
source_type = str(source.get("type") or "")
|
||||
query = str(source.get("query") or "")
|
||||
spec = operation_spec_for_source(source)
|
||||
if source_type == "shell" and spec is None and query not in READ_ONLY_SHELL_QUERIES:
|
||||
raise ValueError(
|
||||
f"shell query {query!r} is not registered as read-only or as a "
|
||||
"bounded operation"
|
||||
)
|
||||
if spec is None:
|
||||
if source.get("operation") is not None:
|
||||
raise ValueError(
|
||||
f"context source {source_type!r}/{query!r} declares unknown "
|
||||
f"operation {source.get('operation')!r}"
|
||||
)
|
||||
continue
|
||||
|
||||
declared = source.get("operation")
|
||||
if declared != spec.operation_id:
|
||||
raise ValueError(
|
||||
f"context source {source_type!r}/{query!r} must declare "
|
||||
f"operation: {spec.operation_id}"
|
||||
)
|
||||
params = source.get("params")
|
||||
if not isinstance(params, dict):
|
||||
raise ValueError(f"bounded operation {spec.operation_id} params must be a mapping")
|
||||
_validate_timeout(spec, params)
|
||||
|
||||
if spec.operation_id == "sbom_nexus_ingest":
|
||||
_validate_sbom(params, instructions)
|
||||
elif spec.operation_id == "forgejo_package_prune":
|
||||
_validate_prune(params)
|
||||
elif spec.operation_id == "cnpg_option_a_backup":
|
||||
_validate_backup(params)
|
||||
|
||||
|
||||
def _validate_timeout(spec: BoundedOperationSpec, params: dict[str, Any]) -> None:
|
||||
raw = params.get("timeout_seconds", spec.max_timeout_seconds)
|
||||
if isinstance(raw, bool) or not isinstance(raw, (int, float)):
|
||||
raise ValueError(f"bounded operation {spec.operation_id} timeout_seconds must be numeric")
|
||||
if raw <= 0 or raw > spec.max_timeout_seconds:
|
||||
raise ValueError(
|
||||
f"bounded operation {spec.operation_id} timeout_seconds must be in "
|
||||
f"1..{spec.max_timeout_seconds}"
|
||||
)
|
||||
|
||||
|
||||
def _explicit_bool(params: dict[str, Any], field: str, operation_id: str) -> bool:
|
||||
if field not in params or not isinstance(params[field], bool):
|
||||
raise ValueError(
|
||||
f"bounded operation {operation_id} must declare boolean {field} explicitly"
|
||||
)
|
||||
return bool(params[field])
|
||||
|
||||
|
||||
def _validate_sbom(
|
||||
params: dict[str, Any],
|
||||
instructions: list[dict[str, Any]],
|
||||
) -> None:
|
||||
_explicit_bool(params, "apply", "sbom_nexus_ingest")
|
||||
limit = params.get("limit")
|
||||
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 3:
|
||||
raise ValueError("bounded operation sbom_nexus_ingest limit must be in 1..3")
|
||||
if not _has_instruction_evidence(instructions, "sbom_catchup"):
|
||||
raise ValueError(
|
||||
"bounded operation sbom_nexus_ingest requires an instruction report "
|
||||
"sink with event_type: sbom_catchup"
|
||||
)
|
||||
|
||||
|
||||
def _validate_prune(params: dict[str, Any]) -> None:
|
||||
apply = _explicit_bool(params, "apply", "forgejo_package_prune")
|
||||
max_versions = params.get("max_versions")
|
||||
if (
|
||||
isinstance(max_versions, bool)
|
||||
or not isinstance(max_versions, int)
|
||||
or not 1 <= max_versions <= 10
|
||||
):
|
||||
raise ValueError("bounded operation forgejo_package_prune max_versions must be in 1..10")
|
||||
_validate_canonical_script(params, "prune_script", "forgejo_package_prune")
|
||||
if apply and not str(params.get("live_images_file") or "").strip():
|
||||
raise ValueError(
|
||||
"bounded operation forgejo_package_prune apply=true requires live_images_file"
|
||||
)
|
||||
_require_context_evidence(params, "forgejo_package_prune")
|
||||
|
||||
|
||||
def _validate_backup(params: dict[str, Any]) -> None:
|
||||
_explicit_bool(params, "dry_run", "cnpg_option_a_backup")
|
||||
_validate_canonical_script(params, "backup_script", "cnpg_option_a_backup")
|
||||
raw_targets = params.get("targets")
|
||||
if not isinstance(raw_targets, str):
|
||||
raise ValueError("bounded operation cnpg_option_a_backup requires explicit targets")
|
||||
targets = [item.strip() for item in raw_targets.split(",") if item.strip()]
|
||||
if not targets or len(targets) > 10 or any(_TARGET_RE.fullmatch(item) is None for item in targets):
|
||||
raise ValueError(
|
||||
"bounded operation cnpg_option_a_backup targets must contain 1..10 safe names"
|
||||
)
|
||||
_require_context_evidence(params, "cnpg_option_a_backup")
|
||||
|
||||
|
||||
def _validate_canonical_script(
|
||||
params: dict[str, Any], field: str, operation_id: str
|
||||
) -> None:
|
||||
expected = _CANONICAL_SCRIPT_PATHS[operation_id]
|
||||
if params.get(field) != expected:
|
||||
raise ValueError(
|
||||
f"bounded operation {operation_id} {field} must be the canonical path {expected!r}"
|
||||
)
|
||||
|
||||
|
||||
def _require_context_evidence(params: dict[str, Any], operation_id: str) -> None:
|
||||
sinks = params.get("evidence_sinks")
|
||||
if not isinstance(sinks, list) or not any(
|
||||
isinstance(item, dict)
|
||||
and item.get("type") == "state-hub-progress"
|
||||
and item.get("event_type") == operation_id
|
||||
for item in sinks
|
||||
):
|
||||
raise ValueError(
|
||||
f"bounded operation {operation_id} requires a state-hub-progress "
|
||||
f"evidence sink with event_type: {operation_id}"
|
||||
)
|
||||
|
||||
|
||||
def _has_instruction_evidence(
|
||||
instructions: list[dict[str, Any]], event_type: str
|
||||
) -> bool:
|
||||
for instruction in instructions:
|
||||
sinks = instruction.get("report_sinks")
|
||||
if not isinstance(sinks, list):
|
||||
continue
|
||||
if any(
|
||||
isinstance(sink, dict)
|
||||
and sink.get("type") == "state-hub-progress"
|
||||
and sink.get("event_type") == event_type
|
||||
for sink in sinks
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
|
@ -21,8 +21,6 @@ import httpx
|
|||
import yaml
|
||||
|
||||
from activity_core.context_resolvers.base import CONTEXT_RESOLVER_REGISTRY, ContextResolver
|
||||
from activity_core.context_resolvers.forgejo_prune import forgejo_package_prune
|
||||
from activity_core.context_resolvers.cnpg_backup import cnpg_option_a_backup
|
||||
from activity_core.context_resolvers.kaizen import KaizenContextResolver
|
||||
from activity_core.context_resolvers.state_hub import StateHubContextResolver
|
||||
|
||||
|
|
@ -506,15 +504,15 @@ class ReuseSurfaceContextResolver(ContextResolver):
|
|||
|
||||
|
||||
class ShellContextResolver(ContextResolver):
|
||||
"""Dispatch shell-backed context queries without breaking kaizen aliases."""
|
||||
"""Dispatch code-registered read-only shell compatibility queries."""
|
||||
|
||||
def resolve(self, query: str, event: Any, params: dict[str, Any]) -> dict[str, Any]:
|
||||
if query == "reuse_surface_report_gaps":
|
||||
return reuse_surface_report_gaps(params)
|
||||
if query == "forgejo_package_prune":
|
||||
return forgejo_package_prune(params)
|
||||
if query == "cnpg_option_a_backup":
|
||||
return cnpg_option_a_backup(params)
|
||||
if query in {"forgejo_package_prune", "cnpg_option_a_backup"}:
|
||||
raise RuntimeError(
|
||||
f"mutating query {query!r} must run through the bounded-operation stage"
|
||||
)
|
||||
return KaizenContextResolver().resolve(query, event, params)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from typing import Any
|
|||
|
||||
import yaml
|
||||
|
||||
from activity_core.bounded_operations import validate_bounded_operations
|
||||
from activity_core.glas_profile import (
|
||||
ProfileRefError,
|
||||
require_harness_profile,
|
||||
|
|
@ -122,6 +123,28 @@ def _validate_execution_declarations(
|
|||
)
|
||||
|
||||
|
||||
def _normalise_review_advisory(
|
||||
instructions: list[dict[str, Any]], file: Path
|
||||
) -> None:
|
||||
"""Migrate the legacy gate-sounding field to advisory-only semantics."""
|
||||
for instruction in instructions:
|
||||
legacy_present = "review_required" in instruction
|
||||
advisory_present = "review_advisory" in instruction
|
||||
if legacy_present and advisory_present and (
|
||||
bool(instruction["review_required"])
|
||||
!= bool(instruction["review_advisory"])
|
||||
):
|
||||
raise ParseError(
|
||||
file,
|
||||
None,
|
||||
f"instruction {instruction.get('id')!r} declares conflicting "
|
||||
"review_required and review_advisory values",
|
||||
)
|
||||
if legacy_present:
|
||||
instruction.setdefault("review_advisory", bool(instruction["review_required"]))
|
||||
instruction.pop("review_required", None)
|
||||
|
||||
|
||||
def _scan_dirs() -> list[Path]:
|
||||
dirs: list[Path] = []
|
||||
default_dir = Path("activity-definitions")
|
||||
|
|
@ -236,7 +259,12 @@ def parse_file(path: Path) -> ActivityDefinitionDef:
|
|||
raise ParseError(path, None, "instruction block missing required field 'id'")
|
||||
instructions.append(block_data)
|
||||
|
||||
_normalise_review_advisory(instructions, path)
|
||||
_validate_execution_declarations(rules, instructions, path)
|
||||
try:
|
||||
validate_bounded_operations(context_sources, instructions)
|
||||
except ValueError as exc:
|
||||
raise ParseError(path, None, str(exc)) from exc
|
||||
|
||||
return ActivityDefinitionDef(
|
||||
id=str(fm["id"]),
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from typing import Annotated, Any, Literal, Union
|
|||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
# ── EventEnvelope (T40) ───────────────────────────────────────────────────────
|
||||
|
|
@ -137,12 +137,34 @@ class InstructionDef(BaseModel):
|
|||
model_params: dict[str, Any] = Field(default_factory=dict)
|
||||
prompt: str = Field(description="Prompt template with {field.path} placeholders.")
|
||||
output_schema: str = Field(description="Path to JSON Schema file for output validation.")
|
||||
review_required: bool = Field(default=False)
|
||||
review_advisory: bool = Field(
|
||||
default=False,
|
||||
description="Advisory evidence only; activity-core applies no review gate.",
|
||||
)
|
||||
review_required: bool | None = Field(
|
||||
default=None,
|
||||
exclude=True,
|
||||
repr=False,
|
||||
description="Deprecated input alias for review_advisory.",
|
||||
json_schema_extra={"deprecated": True},
|
||||
)
|
||||
report_sinks: list[dict[str, Any]] = Field(default_factory=list)
|
||||
approach_hint: str | None = Field(default=None)
|
||||
harness_profile_ref: str | None = Field(default=None)
|
||||
execution_refs: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _accept_legacy_review_required(cls, data: Any) -> Any:
|
||||
if not isinstance(data, dict) or "review_required" not in data:
|
||||
return data
|
||||
normalized = dict(data)
|
||||
legacy = bool(normalized["review_required"])
|
||||
if "review_advisory" in normalized and bool(normalized["review_advisory"]) != legacy:
|
||||
raise ValueError("review_required conflicts with review_advisory")
|
||||
normalized["review_advisory"] = legacy
|
||||
return normalized
|
||||
|
||||
|
||||
# ── Context sources ───────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -166,9 +188,11 @@ class ContextSource(BaseModel):
|
|||
# ── Task templates (legacy) ───────────────────────────────────────────────────
|
||||
|
||||
class TaskTemplate(BaseModel):
|
||||
"""Legacy task template — ignored when ActivityDefinition.rules is non-empty."""
|
||||
"""Legacy persisted shape; no in-repo task executor consumes it."""
|
||||
|
||||
task_type: str
|
||||
task_type: str = Field(
|
||||
description="Legacy downstream task type metadata; not an executor selector."
|
||||
)
|
||||
condition: str | None = None
|
||||
params_template: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
|
@ -186,7 +210,7 @@ class ActivityDefinition(BaseModel):
|
|||
# New rule/instruction pipeline (T34)
|
||||
rules: list[RuleDef] = Field(default_factory=list)
|
||||
instructions: list[InstructionDef] = Field(default_factory=list)
|
||||
# Legacy — ignored when rules is non-empty
|
||||
# Legacy persisted/API compatibility; RunActivityWorkflow does not execute it.
|
||||
task_templates: list[TaskTemplate] = Field(default_factory=list)
|
||||
dedupe_key_strategy: Literal["skip", "catchup", "compress"] = Field(
|
||||
default="skip",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from uuid import NAMESPACE_URL, UUID, uuid5
|
|||
|
||||
import httpx
|
||||
|
||||
from activity_core.audit_projection import bounded_audit_projection
|
||||
from activity_core.context_resolvers.ops_inventory import _sanitize_url
|
||||
from activity_core.state_hub_write import (
|
||||
apply_progress_scope_fields,
|
||||
|
|
@ -55,7 +56,9 @@ def persist_ops_inventory_evidence(payload: dict[str, Any]) -> list[dict[str, An
|
|||
continue
|
||||
|
||||
bind_key = _context_bind_key(source)
|
||||
probe_result = (payload.get("context") or {}).get(bind_key)
|
||||
probe_result = bounded_audit_projection(
|
||||
(payload.get("context") or {}).get(bind_key)
|
||||
)
|
||||
if isinstance(probe_result, dict) and probe_result.get("skipped"):
|
||||
results.append({
|
||||
"type": "state-hub-progress",
|
||||
|
|
@ -685,8 +688,8 @@ def _forgejo_package_prune_summary_text(result: dict[str, Any]) -> str:
|
|||
protected = result.get("skipped_protected_count", 0)
|
||||
apply = result.get("apply", False)
|
||||
mode = "apply" if apply else "dry-run"
|
||||
errors = result.get("errors") or []
|
||||
error_note = f"; {len(errors)} error(s)" if errors else ""
|
||||
error_count = result.get("error_count", 0)
|
||||
error_note = f"; {error_count} error(s)" if error_count else ""
|
||||
return (
|
||||
f"Forgejo package prune ({mode}): {deleted} deleted, "
|
||||
f"{candidates} candidate(s), {protected} protected skip(s){error_note}"
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ class TaskSpawnLog(Base):
|
|||
prompt_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
model: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
output_validated: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
review_required: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
review_advisory: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
|
@ -120,26 +120,6 @@ class EventType(Base):
|
|||
)
|
||||
|
||||
|
||||
class TaskInstance(Base):
|
||||
__tablename__ = "task_instances"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
run_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("activity_runs.run_id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
type: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
params: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
status: Mapped[str] = mapped_column(Text, nullable=False, default="pending")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
class OpsRun(Base):
|
||||
"""Claimable automation run instance (ACT-ADR-005 / ACTIVITY-WP-0026)."""
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from zoneinfo import ZoneInfo
|
|||
|
||||
import httpx
|
||||
|
||||
from activity_core.audit_projection import bounded_audit_projection
|
||||
from activity_core.runtime_paths import (
|
||||
custodian_repo_relative,
|
||||
custodian_repo_root,
|
||||
|
|
@ -34,7 +35,7 @@ def persist_reports(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
|||
"""
|
||||
results: list[dict[str, Any]] = []
|
||||
for report_entry in payload.get("reports", []):
|
||||
report_context = dict(report_entry)
|
||||
report_context = bounded_audit_projection(dict(report_entry))
|
||||
for sink in report_entry.get("sinks", []):
|
||||
sink_type = sink.get("type")
|
||||
try:
|
||||
|
|
@ -139,7 +140,10 @@ def _post_state_hub_progress(
|
|||
"instruction_id": instruction_id,
|
||||
"scheduled_for": payload.get("scheduled_for"),
|
||||
"output_validated": report_entry.get("output_validated"),
|
||||
"review_required": report_entry.get("review_required"),
|
||||
"review_advisory": report_entry.get(
|
||||
"review_advisory", report_entry.get("review_required")
|
||||
),
|
||||
"review_gate_applied": False,
|
||||
"validation_error": report_entry.get("validation_error"),
|
||||
"llm_response_metadata": report_entry.get("llm_response_metadata"),
|
||||
"report": report,
|
||||
|
|
@ -222,7 +226,8 @@ def _render_markdown(
|
|||
f"instruction_id: {instruction_id}",
|
||||
f"scheduled_for: {payload.get('scheduled_for')}",
|
||||
f"output_validated: {str(bool(report_entry.get('output_validated'))).lower()}",
|
||||
f"review_required: {str(bool(report_entry.get('review_required'))).lower()}",
|
||||
f"review_advisory: {str(bool(report_entry.get('review_advisory', report_entry.get('review_required')))).lower()}",
|
||||
"review_gate_applied: false",
|
||||
f"model: {report_entry.get('model') or ''}",
|
||||
f"prompt_hash: {report_entry.get('prompt_hash') or ''}",
|
||||
f"created: {datetime.now(tz=timezone.utc).isoformat()}",
|
||||
|
|
|
|||
|
|
@ -38,12 +38,20 @@ class InstructionResult:
|
|||
prompt_hash: str | None = None
|
||||
model: str | None = None
|
||||
output_validated: bool = False
|
||||
review_required: bool = False
|
||||
review_advisory: bool = False
|
||||
condition_matched: str | None = None
|
||||
validation_error: str | None = None
|
||||
llm_response_metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _review_advisory(instr: Any) -> bool:
|
||||
"""Read the renamed advisory flag while accepting legacy caller objects."""
|
||||
legacy = getattr(instr, "review_required", None)
|
||||
if legacy is not None:
|
||||
return bool(legacy)
|
||||
return bool(getattr(instr, "review_advisory", False))
|
||||
|
||||
|
||||
def _resolve_path(obj: Any, path: str) -> Any:
|
||||
"""Walk a dot-separated path on obj or dict. Returns None if not found."""
|
||||
parts = path.split(".")
|
||||
|
|
@ -134,7 +142,7 @@ def execute_instruction_with_audit(
|
|||
prompt_hash=None,
|
||||
model=getattr(instr, "model", None),
|
||||
output_validated=False,
|
||||
review_required=True,
|
||||
review_advisory=True,
|
||||
condition_matched=getattr(instr, "condition", "") or None,
|
||||
validation_error=str(exc),
|
||||
)
|
||||
|
|
@ -149,7 +157,7 @@ def execute_instruction_with_audit(
|
|||
prompt_hash=None,
|
||||
model=getattr(instr, "model", None),
|
||||
output_validated=False,
|
||||
review_required=True,
|
||||
review_advisory=True,
|
||||
condition_matched=getattr(instr, "condition", "") or None,
|
||||
validation_error=str(exc),
|
||||
)
|
||||
|
|
@ -200,13 +208,10 @@ def _execute(
|
|||
response_metadata = _llm_response_metadata(llm_client)
|
||||
task_specs, report, error = _validate_output(raw_output, instr, allow_list)
|
||||
if error:
|
||||
# Truncate to keep log volume bounded but long enough to see the
|
||||
# actual JSON shape mismatch (typical reports are <2KB).
|
||||
preview = (raw_output or "")[:2000]
|
||||
logger.warning(
|
||||
"instruction_output_error: instruction=%r, prompt_hash=%s, "
|
||||
"error=%s, raw_output_preview=%r",
|
||||
instr.id, prompt_hash, error, preview,
|
||||
"error=%s",
|
||||
instr.id, prompt_hash, error,
|
||||
)
|
||||
# Posture B (WP-0016-T03): try to recover a partial-but-usable
|
||||
# report from individually-parseable items before declaring total
|
||||
|
|
@ -227,7 +232,7 @@ def _execute(
|
|||
prompt_hash=prompt_hash,
|
||||
model=instr.model,
|
||||
output_validated=False,
|
||||
review_required=True,
|
||||
review_advisory=True,
|
||||
condition_matched=instr.condition or None,
|
||||
validation_error=error,
|
||||
llm_response_metadata=response_metadata,
|
||||
|
|
@ -240,7 +245,7 @@ def _execute(
|
|||
prompt_hash=prompt_hash,
|
||||
model=instr.model,
|
||||
output_validated=True,
|
||||
review_required=bool(getattr(instr, "review_required", False)),
|
||||
review_advisory=_review_advisory(instr),
|
||||
condition_matched=instr.condition or None,
|
||||
llm_response_metadata=response_metadata,
|
||||
)
|
||||
|
|
@ -272,7 +277,7 @@ def _empty_result(
|
|||
prompt_hash=prompt_hash,
|
||||
model=getattr(instr, "model", None),
|
||||
output_validated=False,
|
||||
review_required=bool(getattr(instr, "review_required", False)),
|
||||
review_advisory=_review_advisory(instr),
|
||||
condition_matched=getattr(instr, "condition", "") or None,
|
||||
validation_error=validation_error,
|
||||
)
|
||||
|
|
@ -281,47 +286,28 @@ def _empty_result(
|
|||
def _invalid_output_report(
|
||||
instr: Any,
|
||||
validation_error: str,
|
||||
raw_output: Any,
|
||||
_raw_output: Any,
|
||||
response_metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Build a durable diagnostic report for invalid report-sink output.
|
||||
|
||||
Task-only instructions keep the legacy empty-result behavior. Instructions
|
||||
with report sinks should leave operators a bounded artifact that preserves
|
||||
the partial model output without marking it as schema-valid.
|
||||
with report sinks leave a bounded diagnostic without retaining unvalidated
|
||||
provider output.
|
||||
"""
|
||||
if not getattr(instr, "report_sinks", None):
|
||||
return None
|
||||
|
||||
partial_output: Any
|
||||
raw_preview: str | None = None
|
||||
if isinstance(raw_output, str):
|
||||
try:
|
||||
partial_output = _parse_json_output(raw_output)
|
||||
except json.JSONDecodeError:
|
||||
partial_output = None
|
||||
raw_preview = raw_output[:_RAW_OUTPUT_PREVIEW_LIMIT]
|
||||
else:
|
||||
partial_output = raw_output
|
||||
|
||||
report: dict[str, Any] = {
|
||||
"summary": (
|
||||
f"Instruction {instr.id} produced output that failed validation; "
|
||||
"partial output was preserved for operator review."
|
||||
"unvalidated provider output was discarded."
|
||||
),
|
||||
"status": "validation_failed",
|
||||
"validation_error": validation_error,
|
||||
}
|
||||
if response_metadata:
|
||||
report["llm_response_metadata"] = response_metadata
|
||||
if isinstance(partial_output, dict):
|
||||
if isinstance(partial_output.get("summary"), str):
|
||||
report["partial_summary"] = partial_output["summary"]
|
||||
report["partial_report"] = partial_output
|
||||
elif isinstance(partial_output, list):
|
||||
report["partial_report"] = partial_output
|
||||
elif raw_preview is not None:
|
||||
report["raw_output_preview"] = raw_preview
|
||||
return report
|
||||
|
||||
|
||||
|
|
@ -343,7 +329,6 @@ _SNIPPET_LIMIT = 200
|
|||
# fail the whole report or flow unbounded into a downstream consumer.
|
||||
_MAX_STRING_LEN = 4000
|
||||
_MAX_DEPTH = 8
|
||||
_RAW_OUTPUT_PREVIEW_LIMIT = 12000
|
||||
_SUMMARY_RE = re.compile(r'"summary"\s*:\s*"((?:[^"\\]|\\.)*)"')
|
||||
|
||||
|
||||
|
|
@ -670,7 +655,7 @@ def _resilient_report(
|
|||
prompt_hash=prompt_hash,
|
||||
model=getattr(instr, "model", None),
|
||||
output_validated=True,
|
||||
review_required=True,
|
||||
review_advisory=True,
|
||||
condition_matched=getattr(instr, "condition", "") or None,
|
||||
validation_error=None,
|
||||
llm_response_metadata=response_metadata,
|
||||
|
|
@ -745,7 +730,7 @@ def _deterministic_context_report(instr: Any, context: dict) -> InstructionResul
|
|||
prompt_hash=None,
|
||||
model=getattr(instr, "model", None),
|
||||
output_validated=True,
|
||||
review_required=bool(getattr(instr, "review_required", False)),
|
||||
review_advisory=_review_advisory(instr),
|
||||
condition_matched=getattr(instr, "condition", "") or None,
|
||||
)
|
||||
|
||||
|
|
@ -806,7 +791,7 @@ def _sbom_catchup_report(instr: Any, catchup: dict) -> InstructionResult:
|
|||
prompt_hash=None,
|
||||
model=getattr(instr, "model", None),
|
||||
output_validated=True,
|
||||
review_required=bool(getattr(instr, "review_required", False)),
|
||||
review_advisory=_review_advisory(instr),
|
||||
condition_matched=getattr(instr, "condition", "") or None,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from urllib.parse import quote
|
|||
from sqlalchemy import Select, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from activity_core.audit_projection import bounded_audit_projection
|
||||
from activity_core.glas_evidence import normalise_ops_result
|
||||
from activity_core.orm import ActivityRun, OpsRun, TaskSpawnLog
|
||||
|
||||
|
|
@ -37,6 +38,14 @@ def forgejo_org() -> str:
|
|||
)
|
||||
|
||||
|
||||
def public_context_keys(context_snapshot: Any) -> list[str]:
|
||||
"""Return safe top-level context names for the public run projection."""
|
||||
projected = bounded_audit_projection(context_snapshot or {})
|
||||
if not isinstance(projected, dict):
|
||||
return []
|
||||
return sorted(projected.keys())[:40]
|
||||
|
||||
|
||||
def build_forgejo_blob_url(
|
||||
*,
|
||||
target_repo: str | None,
|
||||
|
|
@ -310,7 +319,7 @@ async def enrich_activity_runs(
|
|||
"artifacts": artifacts[:12],
|
||||
"evidence": {
|
||||
"task_spawns": spawns[:20],
|
||||
"context_keys": sorted((r.context_snapshot or {}).keys())[:40],
|
||||
"context_keys": public_context_keys(r.context_snapshot),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
"""Temporal worker entrypoint for activity-core.
|
||||
|
||||
Starts two workers (wired up in T20):
|
||||
- orchestrator-tq: RunActivityWorkflow + its activities
|
||||
- task-execution-tq: TaskExecutorWorkflow
|
||||
Starts the ``orchestrator-tq`` worker for RunActivityWorkflow and its activities.
|
||||
|
||||
T23: Calls sync_schedules before entering the worker run loop to ensure
|
||||
all cron ActivityDefinitions have live Temporal Schedules.
|
||||
|
|
@ -35,6 +33,7 @@ from temporalio.worker import Worker
|
|||
from activity_core.activities import (
|
||||
apply_sbom_catchup,
|
||||
emit_tasks,
|
||||
execute_bounded_operation,
|
||||
evaluate_instructions,
|
||||
evaluate_rules,
|
||||
init_session_factory,
|
||||
|
|
@ -42,13 +41,12 @@ from activity_core.activities import (
|
|||
log_run,
|
||||
persist_instruction_reports,
|
||||
persist_ops_evidence,
|
||||
persist_task_instance,
|
||||
resolve_context,
|
||||
)
|
||||
from activity_core.db import make_engine
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
from activity_core.sync_service import run_sync
|
||||
from activity_core.workflows import RunActivityWorkflow, TaskExecutorWorkflow
|
||||
from activity_core.workflows import RunActivityWorkflow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -57,7 +55,6 @@ TEMPORAL_NAMESPACE = os.environ.get("TEMPORAL_NAMESPACE", "default")
|
|||
PROMETHEUS_BIND_ADDR = os.environ.get("PROMETHEUS_BIND_ADDR", "0.0.0.0:9090")
|
||||
|
||||
ORCHESTRATOR_TASK_QUEUE = "orchestrator-tq"
|
||||
TASK_EXECUTION_TASK_QUEUE = "task-execution-tq"
|
||||
|
||||
|
||||
async def run() -> None:
|
||||
|
|
@ -106,6 +103,7 @@ async def run() -> None:
|
|||
load_activity_definition,
|
||||
resolve_context,
|
||||
apply_sbom_catchup,
|
||||
execute_bounded_operation,
|
||||
log_run,
|
||||
evaluate_rules,
|
||||
evaluate_instructions,
|
||||
|
|
@ -115,51 +113,15 @@ async def run() -> None:
|
|||
],
|
||||
)
|
||||
|
||||
# ACTIVITY-WP-0023-T08: only register the legacy task-execution stub when
|
||||
# explicitly enabled. Default is orchestrator-only so production does not
|
||||
# advertise a fake execution surface on task-execution-tq.
|
||||
enable_task_stub = os.environ.get(
|
||||
"ACTIVITY_CORE_ENABLE_TASK_EXECUTOR_STUB", ""
|
||||
).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
task_worker: Worker | None = None
|
||||
if enable_task_stub:
|
||||
task_worker = Worker(
|
||||
client,
|
||||
task_queue=TASK_EXECUTION_TASK_QUEUE,
|
||||
workflows=[TaskExecutorWorkflow],
|
||||
activities=[persist_task_instance],
|
||||
)
|
||||
logger.warning(
|
||||
"TaskExecutorWorkflow stub ENABLED on %s — not for production execution",
|
||||
TASK_EXECUTION_TASK_QUEUE,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"TaskExecutorWorkflow stub not registered "
|
||||
"(set ACTIVITY_CORE_ENABLE_TASK_EXECUTOR_STUB=true for legacy tests only)"
|
||||
)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
stop = asyncio.Event()
|
||||
loop.add_signal_handler(signal.SIGTERM, stop.set)
|
||||
loop.add_signal_handler(signal.SIGINT, stop.set)
|
||||
|
||||
workers = [orchestrator_worker]
|
||||
if task_worker is not None:
|
||||
workers.append(task_worker)
|
||||
|
||||
from contextlib import AsyncExitStack
|
||||
|
||||
async with AsyncExitStack() as stack:
|
||||
for w in workers:
|
||||
await stack.enter_async_context(w)
|
||||
queues = [ORCHESTRATOR_TASK_QUEUE]
|
||||
if enable_task_stub:
|
||||
queues.append(TASK_EXECUTION_TASK_QUEUE)
|
||||
async with orchestrator_worker:
|
||||
logger.info(
|
||||
"Workers running — queues: %r (namespace=%r)",
|
||||
queues,
|
||||
[ORCHESTRATOR_TASK_QUEUE],
|
||||
TEMPORAL_NAMESPACE,
|
||||
)
|
||||
await stop.wait()
|
||||
|
|
|
|||
|
|
@ -1,12 +1,7 @@
|
|||
"""Temporal workflow definitions for activity-core.
|
||||
|
||||
Two workflows are registered here:
|
||||
- RunActivityWorkflow → orchestrator-tq
|
||||
- TaskExecutorWorkflow → task-execution-tq
|
||||
|
||||
Workflow IDs follow the conventions in docs/conventions.md:
|
||||
RunActivityWorkflow: activity-{activity_id}:{trigger_key}
|
||||
TaskExecutorWorkflow: task-{run_id}:{task_type}:{index}
|
||||
RunActivityWorkflow is registered on ``orchestrator-tq``. Workflow IDs follow
|
||||
``activity-{activity_id}:{trigger_key}``; see docs/conventions.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -22,15 +17,16 @@ with workflow.unsafe.imports_passed_through():
|
|||
from activity_core.activities import (
|
||||
apply_sbom_catchup,
|
||||
emit_tasks,
|
||||
execute_bounded_operation,
|
||||
evaluate_rules,
|
||||
evaluate_instructions,
|
||||
load_activity_definition,
|
||||
log_run,
|
||||
persist_instruction_reports,
|
||||
persist_ops_evidence,
|
||||
persist_task_instance,
|
||||
resolve_context,
|
||||
)
|
||||
from activity_core.bounded_operations import operation_sources
|
||||
from activity_core.ops_run_queue import emit_triggering_event_id
|
||||
from activity_core.schedule_manager import SCHEDULED_TRIGGER_KEY
|
||||
|
||||
|
|
@ -49,7 +45,7 @@ _RETRY_POLICY = RetryPolicy(
|
|||
_ACTIVITY_TIMEOUT = timedelta(
|
||||
seconds=int(os.environ.get("ACTIVITY_TIMEOUT_SECONDS", "900"))
|
||||
)
|
||||
_TASK_QUEUE = "task-execution-tq"
|
||||
_NO_RETRY_POLICY = RetryPolicy(maximum_attempts=1)
|
||||
|
||||
|
||||
@workflow.defn
|
||||
|
|
@ -59,9 +55,10 @@ class RunActivityWorkflow:
|
|||
Sequence:
|
||||
1. load_activity_definition(activity_id) → defn dict
|
||||
2. resolve_context(defn.context_sources) → read-only context snapshot
|
||||
3. apply_sbom_catchup(fixed selection) → bounded outcome patch
|
||||
4. evaluate rules/instructions → TaskSpec dicts and reports
|
||||
5. log run, then emit tasks
|
||||
3. execute admitted bounded operations → bounded outcome patches
|
||||
4. persist operation evidence
|
||||
5. evaluate rules/instructions → TaskSpec dicts and reports
|
||||
6. log run, then emit tasks
|
||||
"""
|
||||
|
||||
@workflow.run
|
||||
|
|
@ -114,7 +111,11 @@ class RunActivityWorkflow:
|
|||
retry_policy=_RETRY_POLICY,
|
||||
)
|
||||
|
||||
# ── 3. Apply declared bounded side-effects to the fixed selection ────
|
||||
# ── 3. Execute admitted bounded operations ──────────────────────────
|
||||
# SBOM retains its dedicated heartbeat/retry activity because it has a
|
||||
# stable remote idempotency key. Shell-backed operations are one-shot:
|
||||
# their platform tools do not expose a receipt activity-core can use to
|
||||
# prove an ambiguous mutation safe to repeat.
|
||||
if any(
|
||||
isinstance(source, dict)
|
||||
and source.get("type") == "sbom-nexus"
|
||||
|
|
@ -138,6 +139,22 @@ class RunActivityWorkflow:
|
|||
if isinstance(current, dict) and isinstance(patch, dict):
|
||||
current.update(patch)
|
||||
|
||||
for source, operation_spec in operation_sources(
|
||||
defn.get("context_sources", [])
|
||||
):
|
||||
if operation_spec.operation_id == "sbom_nexus_ingest":
|
||||
continue
|
||||
operation_patch: dict = await workflow.execute_activity(
|
||||
execute_bounded_operation,
|
||||
{"source": source, "run_id": run_id},
|
||||
start_to_close_timeout=timedelta(
|
||||
seconds=operation_spec.max_timeout_seconds
|
||||
),
|
||||
retry_policy=_NO_RETRY_POLICY,
|
||||
)
|
||||
context_snapshot.update(operation_patch)
|
||||
|
||||
# ── 4. Persist bounded operation/read evidence ───────────────────────
|
||||
await workflow.execute_activity(
|
||||
persist_ops_evidence,
|
||||
{
|
||||
|
|
@ -152,7 +169,7 @@ class RunActivityWorkflow:
|
|||
retry_policy=_RETRY_POLICY,
|
||||
)
|
||||
|
||||
# ── 4. Evaluate rules ─────────────────────────────────────────────────
|
||||
# ── 5. Evaluate rules ─────────────────────────────────────────────────
|
||||
import json as _json
|
||||
event_attrs: dict = {}
|
||||
if event_envelope_json:
|
||||
|
|
@ -187,7 +204,7 @@ class RunActivityWorkflow:
|
|||
task_spec_dicts.extend(instruction_result.get("task_specs", []))
|
||||
report_dicts.extend(instruction_result.get("reports", []))
|
||||
|
||||
# ── 5. Persist reports ────────────────────────────────────────────────
|
||||
# ── 6. Persist reports ────────────────────────────────────────────────
|
||||
if report_dicts:
|
||||
await workflow.execute_activity(
|
||||
persist_instruction_reports,
|
||||
|
|
@ -202,7 +219,7 @@ class RunActivityWorkflow:
|
|||
retry_policy=_RETRY_POLICY,
|
||||
)
|
||||
|
||||
# ── 6. Log the run BEFORE emit ────────────────────────────────────────
|
||||
# ── 7. Log the run BEFORE emit ────────────────────────────────────────
|
||||
# ACTIVITY-WP-0021: emit_tasks sink failures used to abort the workflow
|
||||
# before log_run, so failed Binky/SBOM fires left no activity_runs row
|
||||
# and automation-status could not observe them. Always record the run;
|
||||
|
|
@ -221,7 +238,7 @@ class RunActivityWorkflow:
|
|||
retry_policy=_RETRY_POLICY,
|
||||
)
|
||||
|
||||
# ── 7. Emit tasks (may fail independently of run audit) ───────────────
|
||||
# ── 8. Emit tasks (may fail independently of run audit) ───────────────
|
||||
if task_spec_dicts:
|
||||
# Cron schedules pass trigger_key="scheduled" for *every* fire.
|
||||
# ops_run idempotency is {def}:{source}:{triggering_event_id}, so
|
||||
|
|
@ -239,70 +256,10 @@ class RunActivityWorkflow:
|
|||
"activity_id": activity_id,
|
||||
"triggering_event_id": emit_trigger_id,
|
||||
"run_id": run_id,
|
||||
"version_used": defn["version"],
|
||||
},
|
||||
start_to_close_timeout=_ACTIVITY_TIMEOUT,
|
||||
retry_policy=_RETRY_POLICY,
|
||||
)
|
||||
|
||||
return {"run_id": run_id, "tasks_spawned": len(task_spec_dicts)}
|
||||
|
||||
|
||||
@workflow.defn
|
||||
class TaskExecutorWorkflow:
|
||||
"""LEGACY NO-OP — not a production execution surface (ACTIVITY-WP-0023-T08).
|
||||
|
||||
Historical compatibility stub. Real task execution belongs in per-repo
|
||||
workers / agent-harness, not activity-core.
|
||||
|
||||
Behaviour is controlled by ``ACTIVITY_CORE_ENABLE_TASK_EXECUTOR_STUB``:
|
||||
|
||||
- unset / false (default): refuse to run (logs error, raises) so the stub
|
||||
cannot attract production work by accident.
|
||||
- true: legacy behaviour — persist a done ``task_instances`` row for
|
||||
idempotent dev/test callers only.
|
||||
"""
|
||||
|
||||
@workflow.run
|
||||
async def run(self, run_id: str, task_type: str, params: dict) -> dict:
|
||||
enabled = (
|
||||
os.environ.get("ACTIVITY_CORE_ENABLE_TASK_EXECUTOR_STUB", "")
|
||||
.strip()
|
||||
.lower()
|
||||
in {"1", "true", "yes", "on"}
|
||||
)
|
||||
task_id = str(
|
||||
uuid.uuid5(uuid.NAMESPACE_URL, workflow.info().workflow_id)
|
||||
)
|
||||
|
||||
if not enabled:
|
||||
workflow.logger.error(
|
||||
"TaskExecutorWorkflow refused: stub disabled "
|
||||
"(set ACTIVITY_CORE_ENABLE_TASK_EXECUTOR_STUB=true only for legacy tests). "
|
||||
"Real execution belongs in per-repo workers / agent-harness. "
|
||||
"See docs/task-emission-consumer-contract.md"
|
||||
)
|
||||
raise RuntimeError(
|
||||
"TaskExecutorWorkflow is disabled (ACTIVITY-WP-0023-T08). "
|
||||
"Use per-repo executors; do not route production work here."
|
||||
)
|
||||
|
||||
workflow.logger.warning(
|
||||
"TaskExecutorWorkflow stub running (legacy mode)",
|
||||
extra={"run_id": run_id, "task_type": task_type, "task_id": task_id},
|
||||
)
|
||||
|
||||
await workflow.execute_activity(
|
||||
persist_task_instance,
|
||||
{
|
||||
"id": task_id,
|
||||
"run_id": run_id,
|
||||
"type": task_type,
|
||||
"params": params,
|
||||
"status": "done",
|
||||
},
|
||||
task_queue=_TASK_QUEUE,
|
||||
start_to_close_timeout=_ACTIVITY_TIMEOUT,
|
||||
retry_policy=_RETRY_POLICY,
|
||||
)
|
||||
|
||||
return {"task_id": task_id, "status": "done", "legacy_stub": True}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue