generated from coulomb/repo-seed
Add event-payload context resolver
This commit is contained in:
@@ -11,6 +11,7 @@ activities that need DB access.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -65,6 +66,24 @@ def _bind_resolver_result(bind_key: str, result: Any) -> Any:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_event_envelope(event_envelope_json: str | None) -> dict[str, Any] | None:
|
||||||
|
"""Parse an event envelope JSON string for context resolvers."""
|
||||||
|
if not event_envelope_json:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = json.loads(event_envelope_json)
|
||||||
|
except (TypeError, json.JSONDecodeError) as exc:
|
||||||
|
activity.logger.warning("Invalid event envelope JSON - %s", exc)
|
||||||
|
return None
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
activity.logger.warning(
|
||||||
|
"Invalid event envelope JSON - expected object, got %s",
|
||||||
|
type(payload).__name__,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
# ── Activities ─────────────────────────────────────────────────────────────────
|
# ── Activities ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@activity.defn
|
@activity.defn
|
||||||
@@ -124,6 +143,7 @@ async def resolve_context(
|
|||||||
from activity_core.context_resolvers.base import CONTEXT_RESOLVER_REGISTRY
|
from activity_core.context_resolvers.base import CONTEXT_RESOLVER_REGISTRY
|
||||||
|
|
||||||
snapshot: dict = {}
|
snapshot: dict = {}
|
||||||
|
event_envelope = _parse_event_envelope(event_envelope_json)
|
||||||
for source in context_sources:
|
for source in context_sources:
|
||||||
source_type = source.get("type", "")
|
source_type = source.get("type", "")
|
||||||
query = source.get("query", "")
|
query = source.get("query", "")
|
||||||
@@ -152,7 +172,7 @@ async def resolve_context(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resolved = resolver_cls().resolve(query, None, params)
|
resolved = resolver_cls().resolve(query, event_envelope, params)
|
||||||
snapshot[bind_key] = _bind_resolver_result(bind_key, resolved)
|
snapshot[bind_key] = _bind_resolver_result(bind_key, resolved)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if required:
|
if required:
|
||||||
|
|||||||
@@ -1 +1,7 @@
|
|||||||
from activity_core.context_resolvers import kaizen, ops_inventory, repo_scoping, state_hub # noqa: F401
|
from activity_core.context_resolvers import ( # noqa: F401
|
||||||
|
event_payload,
|
||||||
|
kaizen,
|
||||||
|
ops_inventory,
|
||||||
|
repo_scoping,
|
||||||
|
state_hub,
|
||||||
|
)
|
||||||
|
|||||||
51
src/activity_core/context_resolvers/event_payload.py
Normal file
51
src/activity_core/context_resolvers/event_payload.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""Event payload context adapter.
|
||||||
|
|
||||||
|
Registered as source type ``event-payload``. It exposes the triggering
|
||||||
|
EventEnvelope attributes to event-triggered ActivityDefinitions without
|
||||||
|
requiring an external context service call.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from activity_core.context_resolvers.base import CONTEXT_RESOLVER_REGISTRY, ContextResolver
|
||||||
|
|
||||||
|
|
||||||
|
class EventPayloadContextResolver(ContextResolver):
|
||||||
|
"""Resolve context from the triggering event envelope attributes."""
|
||||||
|
|
||||||
|
def resolve(self, query: str, event: Any, params: dict[str, Any]) -> Any:
|
||||||
|
attributes = _event_attributes(event)
|
||||||
|
if query in {"", "attributes"}:
|
||||||
|
return attributes
|
||||||
|
if query.startswith("attributes."):
|
||||||
|
return _resolve_path(attributes, query.removeprefix("attributes."))
|
||||||
|
return _resolve_path(attributes, query)
|
||||||
|
|
||||||
|
|
||||||
|
def _event_attributes(event: Any) -> dict[str, Any]:
|
||||||
|
if not isinstance(event, dict):
|
||||||
|
raise RuntimeError("event-payload source requires an event envelope")
|
||||||
|
attributes = event.get("attributes")
|
||||||
|
if not isinstance(attributes, dict):
|
||||||
|
raise RuntimeError("event-payload source requires envelope attributes")
|
||||||
|
return attributes
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_path(root: dict[str, Any], path: str) -> Any:
|
||||||
|
if not path:
|
||||||
|
return root
|
||||||
|
current: Any = root
|
||||||
|
for part in path.split("."):
|
||||||
|
if not part:
|
||||||
|
return {}
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
return {}
|
||||||
|
current = current.get(part)
|
||||||
|
if current is None:
|
||||||
|
return {}
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
CONTEXT_RESOLVER_REGISTRY["event-payload"] = EventPayloadContextResolver
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from temporalio.exceptions import ApplicationError
|
||||||
|
|
||||||
|
from activity_core import activities
|
||||||
from activity_core.activities import _bind_resolver_result, resolve_context
|
from activity_core.activities import _bind_resolver_result, resolve_context
|
||||||
|
|
||||||
|
|
||||||
@@ -42,4 +46,115 @@ async def test_resolve_context_unwraps_kaizen_projects(monkeypatch) -> None:
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
assert snapshot == {"projects": [{"repo": "pilot", "has_metrics": True}]}
|
assert snapshot == {"projects": [{"repo": "pilot", "has_metrics": True}]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_context_binds_event_payload_attributes() -> None:
|
||||||
|
envelope = {
|
||||||
|
"type": "kaizen.metrics.recorded",
|
||||||
|
"attributes": {
|
||||||
|
"agent": "coach",
|
||||||
|
"project": "kaizen-agentic",
|
||||||
|
"summary": {
|
||||||
|
"success_rate": 0.75,
|
||||||
|
"execution_count": 12,
|
||||||
|
"avg_quality": 0.81,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot = await resolve_context(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "event-payload",
|
||||||
|
"bind_to": "context.metrics",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
json.dumps(envelope),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert snapshot == {
|
||||||
|
"metrics": {
|
||||||
|
"agent": "coach",
|
||||||
|
"project": "kaizen-agentic",
|
||||||
|
"summary": {
|
||||||
|
"success_rate": 0.75,
|
||||||
|
"execution_count": 12,
|
||||||
|
"avg_quality": 0.81,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_event_payload_context_supports_low_success_rate_rule() -> None:
|
||||||
|
snapshot = await resolve_context(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "event-payload",
|
||||||
|
"bind_to": "context.metrics",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
json.dumps({
|
||||||
|
"type": "kaizen.metrics.recorded",
|
||||||
|
"attributes": {
|
||||||
|
"agent": "coach",
|
||||||
|
"project": "kaizen-agentic",
|
||||||
|
"summary": {"success_rate": 0.75},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await activities.evaluate_rules({
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"id": "flag-low-success-rate",
|
||||||
|
"condition": "context.metrics.summary.success_rate < 0.8",
|
||||||
|
"action": {
|
||||||
|
"task_template": (
|
||||||
|
"Review low success rate for {context.metrics.agent}"
|
||||||
|
),
|
||||||
|
"target_repo": "context.metrics.project",
|
||||||
|
"priority": "high",
|
||||||
|
"labels": ["kaizen", "{context.metrics.agent}"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"event": {},
|
||||||
|
"context": snapshot,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["source_id"] == "flag-low-success-rate"
|
||||||
|
assert result[0]["title"] == "Review low success rate for coach"
|
||||||
|
assert result[0]["target_repo"] == "kaizen-agentic"
|
||||||
|
assert result[0]["labels"] == ["kaizen", "coach"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_event_payload_context_binds_empty_when_optional_envelope_missing() -> None:
|
||||||
|
snapshot = await resolve_context(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "event-payload",
|
||||||
|
"bind_to": "context.metrics",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert snapshot == {"metrics": {}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_event_payload_context_fails_when_required_envelope_missing() -> None:
|
||||||
|
with pytest.raises(ApplicationError, match="Required context resolver"):
|
||||||
|
await resolve_context(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "event-payload",
|
||||||
|
"bind_to": "context.metrics",
|
||||||
|
"required": True,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|||||||
155
workplans/ACTIVITY-WP-0011-event-payload-context-resolver.md
Normal file
155
workplans/ACTIVITY-WP-0011-event-payload-context-resolver.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
---
|
||||||
|
id: ACTIVITY-WP-0011
|
||||||
|
type: workplan
|
||||||
|
title: "Event Payload Context Resolver"
|
||||||
|
domain: custodian
|
||||||
|
repo: activity-core
|
||||||
|
status: blocked
|
||||||
|
owner: codex
|
||||||
|
topic_slug: custodian
|
||||||
|
created: "2026-06-18"
|
||||||
|
updated: "2026-06-18"
|
||||||
|
state_hub_workstream_id: "4efe4bcf-2148-4489-b57c-87f6039d4ed5"
|
||||||
|
---
|
||||||
|
|
||||||
|
# ACTIVITY-WP-0011 - Event Payload Context Resolver
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
State Hub message `d561ebd7-ba01-4dc6-8ffc-fe87d45304ee` from
|
||||||
|
`kaizen-agentic` handed off an urgent blocker for LOOP-WP-0002:
|
||||||
|
event-triggered definitions can receive the triggering EventEnvelope JSON, but
|
||||||
|
activity-core did not bind `source.type: event-payload` into the context
|
||||||
|
snapshot. The immediate customer is the disabled
|
||||||
|
`coulomb-low-success-rate-review` ActivityDefinition, whose
|
||||||
|
`flag-low-success-rate` rule needs to evaluate
|
||||||
|
`context.metrics.summary.success_rate`.
|
||||||
|
|
||||||
|
This is in activity-core scope because the repo owns ActivityDefinition context
|
||||||
|
resolution and the Event Bridge workflow boundary. The remaining event type
|
||||||
|
registry and live NATS smoke evidence are cross-repo/operator gates and should
|
||||||
|
wait in State Hub rather than depending on local kubectl or ad hoc live cluster
|
||||||
|
access from this repo.
|
||||||
|
|
||||||
|
## Implement Event Payload Resolver
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: ACTIVITY-WP-0011-T01
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "5c87ce0b-3bd0-4a44-aae5-10d7586c939e"
|
||||||
|
```
|
||||||
|
|
||||||
|
Register resolver type `event-payload` so event-triggered definitions can bind
|
||||||
|
the triggering EventEnvelope attributes into `context.*`.
|
||||||
|
|
||||||
|
Done when:
|
||||||
|
|
||||||
|
- `activity_core.context_resolvers` imports and registers an `event-payload`
|
||||||
|
resolver.
|
||||||
|
- `resolve_context` parses `event_envelope_json` once and passes the parsed
|
||||||
|
envelope to registered resolvers.
|
||||||
|
- `source.type: event-payload` extracts envelope `attributes`.
|
||||||
|
- `bind_to: context.metrics` strips the `context.` prefix and unwraps a
|
||||||
|
single-key `{"metrics": ...}` attributes payload into `snapshot["metrics"]`.
|
||||||
|
- Missing or malformed envelopes fail required sources visibly and bind `{}` for
|
||||||
|
optional sources.
|
||||||
|
|
||||||
|
2026-06-18: Completed in `src/activity_core/activities.py` and
|
||||||
|
`src/activity_core/context_resolvers/event_payload.py`.
|
||||||
|
|
||||||
|
## Cover Binding And Rule Evaluation
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: ACTIVITY-WP-0011-T02
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "c6f7dea6-9adc-4997-a22e-4bf2e94dc05a"
|
||||||
|
```
|
||||||
|
|
||||||
|
Add focused tests for the handoff acceptance contract.
|
||||||
|
|
||||||
|
Done when:
|
||||||
|
|
||||||
|
- sample `kaizen.metrics.recorded` envelope attributes resolve to:
|
||||||
|
`{"metrics": {"agent": "coach", "project": "kaizen-agentic", "summary": ...}}`;
|
||||||
|
- `flag-low-success-rate` evaluates
|
||||||
|
`context.metrics.summary.success_rate < 0.8`;
|
||||||
|
- optional missing envelopes bind `{}`;
|
||||||
|
- required missing envelopes raise a visible activity failure.
|
||||||
|
|
||||||
|
2026-06-18: Completed in `tests/test_resolve_context_binding.py`. Focused
|
||||||
|
tests passed:
|
||||||
|
`.venv/bin/python -m pytest tests/test_resolve_context_binding.py tests/test_rule_evaluation_activity.py`
|
||||||
|
reported 8 passed, and adjacent rule tests
|
||||||
|
`.venv/bin/python -m pytest tests/rules/test_evaluator.py tests/rules/test_actions.py`
|
||||||
|
reported 55 passed.
|
||||||
|
|
||||||
|
## Wait For Event Type Registry
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: ACTIVITY-WP-0011-T03
|
||||||
|
status: wait
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "a4f277de-eb83-41bc-860e-b26586c72495"
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm that `kaizen.metrics.recorded` is registered in the shared event type
|
||||||
|
catalog through the owning State Hub / producer workflow.
|
||||||
|
|
||||||
|
Done when:
|
||||||
|
|
||||||
|
- State Hub or the producer-owned event catalog exposes
|
||||||
|
`kaizen.metrics.recorded` with an attributes schema covering
|
||||||
|
`metrics.agent`, `metrics.project`, and `metrics.summary.success_rate`;
|
||||||
|
- the registry decision names the owning repo for future schema changes;
|
||||||
|
- activity-core has no local-only event type drift from the producer contract.
|
||||||
|
|
||||||
|
Current wait reason: the event type is producer/catalog owned. Activity-core has
|
||||||
|
implemented the resolver side and should wait for State Hub-backed registry
|
||||||
|
confirmation before treating the live customer definition as fully unblocked.
|
||||||
|
|
||||||
|
## Wait For Live Event Smoke
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: ACTIVITY-WP-0011-T04
|
||||||
|
status: wait
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "3b636d5e-8f93-49b4-ae53-3da4f736a4d9"
|
||||||
|
```
|
||||||
|
|
||||||
|
After T03, run the live event-triggered path without relying on local kubectl
|
||||||
|
from activity-core.
|
||||||
|
|
||||||
|
Done when State Hub records non-secret evidence that:
|
||||||
|
|
||||||
|
- a sample `kaizen.metrics.recorded` envelope was published on the expected NATS
|
||||||
|
subject;
|
||||||
|
- activity-core triggered `coulomb-low-success-rate-review`;
|
||||||
|
- the resolved context snapshot contained `context.metrics.summary.success_rate`;
|
||||||
|
- `flag-low-success-rate` matched and produced the expected task/report output;
|
||||||
|
- any disabled-definition or operator-controlled enablement state was recorded.
|
||||||
|
|
||||||
|
Current wait reason: this is a cross-repo/live-runtime smoke owned by the event
|
||||||
|
producer, customer definition owner, and cluster/operator path. Activity-core
|
||||||
|
should consume the evidence from State Hub.
|
||||||
|
|
||||||
|
## Close Handoff
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: ACTIVITY-WP-0011-T05
|
||||||
|
status: wait
|
||||||
|
priority: medium
|
||||||
|
state_hub_task_id: "5169d8c5-769f-4272-97cf-c25b31087601"
|
||||||
|
```
|
||||||
|
|
||||||
|
Close the urgent handoff once State Hub has the registry and live smoke
|
||||||
|
evidence.
|
||||||
|
|
||||||
|
Done when:
|
||||||
|
|
||||||
|
- State Hub message `d561ebd7-ba01-4dc6-8ffc-fe87d45304ee` is answered or
|
||||||
|
linked to this workplan;
|
||||||
|
- `kaizen-agentic` / LOOP-WP-0002 can proceed without an activity-core code
|
||||||
|
blocker;
|
||||||
|
- this workplan is marked `finished`.
|
||||||
Reference in New Issue
Block a user