fastapi-gsap/gsap_broker/functions/runtime.py
Tyler J King 0c77943ceb feat: governed function runtime + billing drain
GCAP-SPEC-FUNCTION-DESCRIPTOR-0001 implementation.
Mirrors connector runtime pattern exactly.

FunctionPlugin — trigger_events, handle(), descriptor(), knative_manifest()
FunctionRegistry — trigger_index for event-driven routing
FunctionRuntime — invoke() + dispatch() with Chronicle lineage
governed_function decorator — SDK surface for function authors
BillingProcessor — GSAP_CR_RECEIVED → billable event with Chronicle CID
EchoFunction — dev/test

API: /functions/ catalogue, invoke, dispatch, manifest, health
8 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:12:29 -04:00

106 lines
3.7 KiB
Python

"""FunctionRuntime — governed invocation with Chronicle emission."""
from __future__ import annotations
import time
import uuid
from typing import Any
from .base import FunctionContext, FunctionResult
from .registry import FunctionRegistry
class FunctionRuntime:
def __init__(
self, registry: FunctionRegistry, chronicle_client: Any = None
) -> None:
self.registry = registry
self.chronicle_client = chronicle_client
async def invoke(
self,
function_id: str,
event: dict[str, Any],
context: FunctionContext,
) -> FunctionResult:
"""Direct invocation of a function by ID."""
function = self.registry.get(function_id)
if function is None:
return FunctionResult(success=False, error=f"Unknown function: {function_id}")
if function.gsap_required and not context.gsap_context_id:
return FunctionResult(success=False, error="GSAP context required but not provided")
if not context.invocation_id:
context.invocation_id = str(uuid.uuid4())
# Emit FUNCTION_INVOKED (0x2A01) before execution
await self._emit_chronicle(
"FUNCTION_INVOKED",
{
"event_code": "0x2A01",
"function_id": function_id,
"trigger_event_kind": context.trigger_event_kind,
"trigger_event_cid": context.trigger_event_cid,
"chronicle_session_id": context.chronicle_session_id,
"gsap_context_id": context.gsap_context_id,
"invocation_id": context.invocation_id,
"pipeline_run_id": context.pipeline_run_id,
},
function,
)
start = time.monotonic()
result = await function.handle(event, context)
result.duration_ms = (time.monotonic() - start) * 1000
# Emit FUNCTION_COMPLETED (0x2A02) after execution
await self._emit_chronicle(
"FUNCTION_COMPLETED",
{
"event_code": "0x2A02",
"function_id": function_id,
"invocation_id": context.invocation_id,
"success": result.success,
"duration_ms": result.duration_ms,
"output_cid": result.output_cid() if result.data is not None else "",
"error": result.error,
},
function,
)
return result
async def dispatch(
self,
event_kind: str,
event_data: dict[str, Any],
trigger_cid: str = "",
) -> list[dict[str, Any]]:
"""Route an event to all functions registered for this event kind."""
functions = self.registry.by_trigger(event_kind)
results = []
for function in functions:
context = FunctionContext(
trigger_event_kind=event_kind,
trigger_event_cid=trigger_cid,
invocation_id=str(uuid.uuid4()),
)
result = await self.invoke(function.function_id, event_data, context)
results.append({
"function_id": function.function_id,
"success": result.success,
"data": result.data,
"error": result.error,
"duration_ms": result.duration_ms,
"lineage_cid": result.lineage_cid,
})
return results
async def _emit_chronicle(
self, event_type: str, payload: dict[str, Any], function: Any
) -> None:
if function.chronicle_enabled and self.chronicle_client is not None:
try:
await self.chronicle_client.emit(event_type, payload)
except Exception:
pass # Chronicle failure must not break invocation