Enforce bounded operation guardrails
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 21s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a028de-e2c8-7732-8521-46a7fc5db82f
This commit is contained in:
tegwick 2026-08-23 12:31:13 +02:00
parent c384f60530
commit 26934e25b9
51 changed files with 1843 additions and 472 deletions

View file

@ -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:

View file

@ -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:

View 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)

View 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

View file

@ -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)

View file

@ -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"]),

View file

@ -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",

View file

@ -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}"

View file

@ -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)."""

View file

@ -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()}",

View file

@ -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,
)

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.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),
},
}
)

View file

@ -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()

View file

@ -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}