orca-runtime-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- orca_runtime_python/__init__.py +69 -0
- orca_runtime_python/bus.py +227 -0
- orca_runtime_python/effects.py +216 -0
- orca_runtime_python/logging.py +161 -0
- orca_runtime_python/machine.py +875 -0
- orca_runtime_python/parser.py +894 -0
- orca_runtime_python/persistence.py +83 -0
- orca_runtime_python/types.py +279 -0
- orca_runtime_python-0.1.0.dist-info/METADATA +246 -0
- orca_runtime_python-0.1.0.dist-info/RECORD +12 -0
- orca_runtime_python-0.1.0.dist-info/WHEEL +5 -0
- orca_runtime_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Orca Runtime Python
|
|
3
|
+
|
|
4
|
+
A first-class Python async runtime for Orca state machines.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .types import (
|
|
8
|
+
StateDef,
|
|
9
|
+
Transition,
|
|
10
|
+
GuardDef,
|
|
11
|
+
ActionSignature,
|
|
12
|
+
EffectDef,
|
|
13
|
+
MachineDef,
|
|
14
|
+
StateValue,
|
|
15
|
+
Context,
|
|
16
|
+
Effect,
|
|
17
|
+
EffectResult,
|
|
18
|
+
EffectStatus,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from .bus import (
|
|
22
|
+
EventBus,
|
|
23
|
+
Event,
|
|
24
|
+
EventType,
|
|
25
|
+
get_event_bus,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from .machine import OrcaMachine
|
|
29
|
+
|
|
30
|
+
from .parser import parse_orca_md, parse_orca_auto
|
|
31
|
+
|
|
32
|
+
from .persistence import PersistenceAdapter, FilePersistence
|
|
33
|
+
|
|
34
|
+
from .logging import LogSink, FileSink, ConsoleSink, MultiSink
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
# Types
|
|
40
|
+
"StateDef",
|
|
41
|
+
"Transition",
|
|
42
|
+
"GuardDef",
|
|
43
|
+
"ActionSignature",
|
|
44
|
+
"EffectDef",
|
|
45
|
+
"MachineDef",
|
|
46
|
+
"StateValue",
|
|
47
|
+
"Context",
|
|
48
|
+
"Effect",
|
|
49
|
+
"EffectResult",
|
|
50
|
+
"EffectStatus",
|
|
51
|
+
# Bus
|
|
52
|
+
"EventBus",
|
|
53
|
+
"Event",
|
|
54
|
+
"EventType",
|
|
55
|
+
"get_event_bus",
|
|
56
|
+
# Machine
|
|
57
|
+
"OrcaMachine",
|
|
58
|
+
# Parser
|
|
59
|
+
"parse_orca_md",
|
|
60
|
+
"parse_orca_auto",
|
|
61
|
+
# Persistence
|
|
62
|
+
"PersistenceAdapter",
|
|
63
|
+
"FilePersistence",
|
|
64
|
+
# Logging
|
|
65
|
+
"LogSink",
|
|
66
|
+
"FileSink",
|
|
67
|
+
"ConsoleSink",
|
|
68
|
+
"MultiSink",
|
|
69
|
+
]
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async event bus with pub/sub and request/response patterns.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Any, Awaitable, Callable
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
from .types import Effect, EffectResult, EffectStatus
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EventType(Enum):
|
|
18
|
+
"""Standard Orca event types."""
|
|
19
|
+
|
|
20
|
+
# State machine events
|
|
21
|
+
STATE_CHANGED = "orca.state.changed"
|
|
22
|
+
TRANSITION_STARTED = "orca.transition.started"
|
|
23
|
+
TRANSITION_COMPLETED = "orca.transition.completed"
|
|
24
|
+
EFFECT_EXECUTING = "orca.effect.executing"
|
|
25
|
+
EFFECT_COMPLETED = "orca.effect.completed"
|
|
26
|
+
EFFECT_FAILED = "orca.effect.failed"
|
|
27
|
+
MACHINE_STARTED = "orca.machine.started"
|
|
28
|
+
MACHINE_STOPPED = "orca.machine.stopped"
|
|
29
|
+
|
|
30
|
+
# Workflow events
|
|
31
|
+
WORKFLOW_STATE_CHANGED = "workflow.state.changed"
|
|
32
|
+
|
|
33
|
+
# Agent events
|
|
34
|
+
AGENT_TASK_ASSIGNED = "agent.task.assigned"
|
|
35
|
+
AGENT_TASK_COMPLETED = "agent.task.completed"
|
|
36
|
+
|
|
37
|
+
# Scheduling events
|
|
38
|
+
SCHEDULING_QUERY = "scheduling.query"
|
|
39
|
+
SCHEDULING_QUERY_RESPONSE = "scheduling.query_response"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Event:
|
|
44
|
+
"""
|
|
45
|
+
Represents a typed event with correlation IDs and source tracking.
|
|
46
|
+
"""
|
|
47
|
+
type: EventType
|
|
48
|
+
source: str
|
|
49
|
+
event_name: str | None = None # Original event name for custom events
|
|
50
|
+
correlation_id: str | None = None
|
|
51
|
+
timestamp: datetime = field(default_factory=datetime.utcnow)
|
|
52
|
+
payload: dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
def __str__(self) -> str:
|
|
55
|
+
return f"Event({self.type.value}, source={self.source})"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Type alias for effect handlers
|
|
59
|
+
EffectHandler = Callable[[Effect], Awaitable[EffectResult]]
|
|
60
|
+
|
|
61
|
+
# Type alias for event handlers
|
|
62
|
+
EventHandler = Callable[[Event], Awaitable[None]]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class EventBus:
|
|
66
|
+
"""
|
|
67
|
+
Async event bus with pub/sub and request/response patterns.
|
|
68
|
+
|
|
69
|
+
Supports:
|
|
70
|
+
- Subscribe/unsubscribe to event types
|
|
71
|
+
- Publish events to all subscribers
|
|
72
|
+
- Request/response pattern with correlation IDs
|
|
73
|
+
- Effect handler registration and execution
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self):
|
|
77
|
+
self._subscribers: dict[EventType, list[EventHandler]] = {}
|
|
78
|
+
self._effect_handlers: dict[str, EffectHandler] = {}
|
|
79
|
+
self._response_queues: dict[str, asyncio.Queue[Event]] = {}
|
|
80
|
+
|
|
81
|
+
def subscribe(self, event_type: EventType, handler: EventHandler) -> None:
|
|
82
|
+
"""Subscribe a handler to an event type."""
|
|
83
|
+
if event_type not in self._subscribers:
|
|
84
|
+
self._subscribers[event_type] = []
|
|
85
|
+
if handler not in self._subscribers[event_type]:
|
|
86
|
+
self._subscribers[event_type].append(handler)
|
|
87
|
+
|
|
88
|
+
def unsubscribe(self, event_type: EventType, handler: EventHandler) -> None:
|
|
89
|
+
"""Unsubscribe a handler from an event type."""
|
|
90
|
+
if event_type in self._subscribers:
|
|
91
|
+
if handler in self._subscribers[event_type]:
|
|
92
|
+
self._subscribers[event_type].remove(handler)
|
|
93
|
+
|
|
94
|
+
async def publish(self, event: Event) -> None:
|
|
95
|
+
"""
|
|
96
|
+
Publish an event to all subscribers.
|
|
97
|
+
|
|
98
|
+
All handlers are executed concurrently with return_exceptions=True
|
|
99
|
+
so one handler's exception doesn't affect others.
|
|
100
|
+
"""
|
|
101
|
+
if event.type in self._subscribers:
|
|
102
|
+
handlers = list(self._subscribers[event.type])
|
|
103
|
+
await asyncio.gather(
|
|
104
|
+
*[handler(event) for handler in handlers],
|
|
105
|
+
return_exceptions=True
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def register_effect_handler(self, effect_type: str, handler: EffectHandler) -> None:
|
|
109
|
+
"""
|
|
110
|
+
Register an effect handler for a specific effect type.
|
|
111
|
+
|
|
112
|
+
Effect handlers are async functions that receive an Effect
|
|
113
|
+
and return an EffectResult.
|
|
114
|
+
"""
|
|
115
|
+
self._effect_handlers[effect_type] = handler
|
|
116
|
+
|
|
117
|
+
async def execute_effect(self, effect: Effect) -> EffectResult:
|
|
118
|
+
"""
|
|
119
|
+
Execute an effect via registered handler.
|
|
120
|
+
|
|
121
|
+
Returns EffectResult with status SUCCESS or FAILURE.
|
|
122
|
+
"""
|
|
123
|
+
if effect.type not in self._effect_handlers:
|
|
124
|
+
return EffectResult(
|
|
125
|
+
status=EffectStatus.FAILURE,
|
|
126
|
+
error=f"No handler registered for effect type: {effect.type}"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
handler = self._effect_handlers[effect.type]
|
|
130
|
+
try:
|
|
131
|
+
return await handler(effect)
|
|
132
|
+
except Exception as e:
|
|
133
|
+
return EffectResult(
|
|
134
|
+
status=EffectStatus.FAILURE,
|
|
135
|
+
error=str(e)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
async def request_response(
|
|
139
|
+
self,
|
|
140
|
+
request_type: EventType,
|
|
141
|
+
request_payload: dict[str, Any],
|
|
142
|
+
response_type: EventType,
|
|
143
|
+
correlation_id: str | None = None,
|
|
144
|
+
timeout: float = 5.0,
|
|
145
|
+
source: str = "orca",
|
|
146
|
+
) -> Any:
|
|
147
|
+
"""
|
|
148
|
+
Request/response pattern via event bus.
|
|
149
|
+
|
|
150
|
+
Publishes a request event and waits for a matching response
|
|
151
|
+
with the same correlation ID.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
request_type: Event type for the request
|
|
155
|
+
request_payload: Data to send with request
|
|
156
|
+
response_type: Event type expected for response
|
|
157
|
+
correlation_id: Optional correlation ID (generated if not provided)
|
|
158
|
+
timeout: Seconds to wait for response
|
|
159
|
+
source: Source identifier for the request event
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
The payload from the response event
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
TimeoutError: If response not received within timeout
|
|
166
|
+
"""
|
|
167
|
+
corr_id = correlation_id or str(uuid4())
|
|
168
|
+
|
|
169
|
+
response_queue: asyncio.Queue[Event] = asyncio.Queue()
|
|
170
|
+
self._response_queues[corr_id] = response_queue
|
|
171
|
+
|
|
172
|
+
async def response_handler(event: Event) -> None:
|
|
173
|
+
if event.correlation_id == corr_id:
|
|
174
|
+
await response_queue.put(event)
|
|
175
|
+
|
|
176
|
+
self.subscribe(response_type, response_handler)
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
# Publish request
|
|
180
|
+
await self.publish(Event(
|
|
181
|
+
type=request_type,
|
|
182
|
+
source=source,
|
|
183
|
+
correlation_id=corr_id,
|
|
184
|
+
payload=request_payload
|
|
185
|
+
))
|
|
186
|
+
|
|
187
|
+
# Wait for response
|
|
188
|
+
try:
|
|
189
|
+
response_event = await asyncio.wait_for(
|
|
190
|
+
response_queue.get(),
|
|
191
|
+
timeout=timeout
|
|
192
|
+
)
|
|
193
|
+
return response_event.payload
|
|
194
|
+
except asyncio.TimeoutError:
|
|
195
|
+
raise TimeoutError(
|
|
196
|
+
f"Request {corr_id} timed out after {timeout}s"
|
|
197
|
+
)
|
|
198
|
+
finally:
|
|
199
|
+
self.unsubscribe(response_type, response_handler)
|
|
200
|
+
del self._response_queues[corr_id]
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def effect_handler_types(self) -> list[str]:
|
|
204
|
+
"""List of registered effect handler types."""
|
|
205
|
+
return list(self._effect_handlers.keys())
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# Global event bus instance
|
|
209
|
+
_bus: EventBus | None = None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def get_event_bus() -> EventBus:
|
|
213
|
+
"""
|
|
214
|
+
Get the global event bus instance.
|
|
215
|
+
|
|
216
|
+
Creates a new EventBus if one doesn't exist.
|
|
217
|
+
"""
|
|
218
|
+
global _bus
|
|
219
|
+
if _bus is None:
|
|
220
|
+
_bus = EventBus()
|
|
221
|
+
return _bus
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def reset_event_bus() -> None:
|
|
225
|
+
"""Reset the global event bus (useful for testing)."""
|
|
226
|
+
global _bus
|
|
227
|
+
_bus = None
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Effect system for Orca runtime.
|
|
3
|
+
|
|
4
|
+
Provides effect types and utilities for async operations
|
|
5
|
+
that can be executed by the runtime.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Awaitable, Callable
|
|
12
|
+
|
|
13
|
+
from .types import Effect, EffectResult, EffectStatus
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Type alias for effect handlers
|
|
17
|
+
EffectHandler = Callable[[Effect], Awaitable[EffectResult]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class EffectType:
|
|
22
|
+
"""
|
|
23
|
+
Defines an effect type with its handler.
|
|
24
|
+
"""
|
|
25
|
+
name: str
|
|
26
|
+
handler: EffectHandler
|
|
27
|
+
|
|
28
|
+
async def execute(self, payload: dict[str, Any]) -> EffectResult:
|
|
29
|
+
"""Execute this effect with the given payload."""
|
|
30
|
+
effect = Effect(type=self.name, payload=payload)
|
|
31
|
+
return await self.handler(effect)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class EffectRegistry:
|
|
35
|
+
"""
|
|
36
|
+
Registry for effect types and handlers.
|
|
37
|
+
|
|
38
|
+
Allows registering effect handlers and creating effect instances.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self):
|
|
42
|
+
self._handlers: dict[str, EffectHandler] = {}
|
|
43
|
+
|
|
44
|
+
def register(self, effect_type: str, handler: EffectHandler) -> None:
|
|
45
|
+
"""Register a handler for an effect type."""
|
|
46
|
+
self._handlers[effect_type] = handler
|
|
47
|
+
|
|
48
|
+
def get_handler(self, effect_type: str) -> EffectHandler | None:
|
|
49
|
+
"""Get the handler for an effect type."""
|
|
50
|
+
return self._handlers.get(effect_type)
|
|
51
|
+
|
|
52
|
+
def has_handler(self, effect_type: str) -> bool:
|
|
53
|
+
"""Check if a handler is registered for an effect type."""
|
|
54
|
+
return effect_type in self._handlers
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def effect_types(self) -> list[str]:
|
|
58
|
+
"""List all registered effect types."""
|
|
59
|
+
return list(self._handlers.keys())
|
|
60
|
+
|
|
61
|
+
async def execute(self, effect: Effect) -> EffectResult:
|
|
62
|
+
"""Execute an effect using the registered handler."""
|
|
63
|
+
if effect.type not in self._handlers:
|
|
64
|
+
return EffectResult(
|
|
65
|
+
status=EffectStatus.FAILURE,
|
|
66
|
+
error=f"No handler registered for effect type: {effect.type}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
handler = self._handlers[effect.type]
|
|
70
|
+
try:
|
|
71
|
+
return await handler(effect)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
return EffectResult(
|
|
74
|
+
status=EffectStatus.FAILURE,
|
|
75
|
+
error=str(e)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# Common effect payload types
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class NarrativeRequest:
|
|
83
|
+
"""Request for narrative generation (LLM)."""
|
|
84
|
+
action: str # look, move, take, etc.
|
|
85
|
+
context: dict[str, Any]
|
|
86
|
+
event: dict[str, Any] | None = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class NarrativeResponse:
|
|
91
|
+
"""Response from narrative generation."""
|
|
92
|
+
narrative: str
|
|
93
|
+
new_location: str | None = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class MoveRequest:
|
|
98
|
+
"""Request to move to a new location."""
|
|
99
|
+
direction: str
|
|
100
|
+
context: dict[str, Any]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class MoveResponse:
|
|
105
|
+
"""Response from move operation."""
|
|
106
|
+
new_location: str
|
|
107
|
+
description: str
|
|
108
|
+
visited: bool = False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class SaveRequest:
|
|
113
|
+
"""Request to save state."""
|
|
114
|
+
session_id: str
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class SaveResponse:
|
|
119
|
+
"""Response from save operation."""
|
|
120
|
+
saved: bool
|
|
121
|
+
timestamp: int
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class LoadRequest:
|
|
126
|
+
"""Request to load state."""
|
|
127
|
+
session_id: str
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class LoadResponse:
|
|
132
|
+
"""Response from load operation."""
|
|
133
|
+
loaded: bool
|
|
134
|
+
context: dict[str, Any]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# Default effect handlers
|
|
138
|
+
|
|
139
|
+
async def default_narrative_handler(effect: Effect) -> EffectResult:
|
|
140
|
+
"""Default narrative handler for development/testing."""
|
|
141
|
+
payload = effect.payload
|
|
142
|
+
|
|
143
|
+
narrative = f"The world shifts around you... (action: {payload.get('action', 'unknown')})"
|
|
144
|
+
|
|
145
|
+
return EffectResult(
|
|
146
|
+
status=EffectStatus.SUCCESS,
|
|
147
|
+
data={
|
|
148
|
+
"narrative": narrative,
|
|
149
|
+
"new_location": payload.get("context", {}).get("current_location"),
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def default_effect_handler(effect: Effect) -> EffectResult:
|
|
155
|
+
"""Default handler that returns success with no data."""
|
|
156
|
+
return EffectResult(
|
|
157
|
+
status=EffectStatus.SUCCESS,
|
|
158
|
+
data=None
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# Decorator for creating effect handlers
|
|
163
|
+
|
|
164
|
+
def effect_handler(effect_type: str):
|
|
165
|
+
"""
|
|
166
|
+
Decorator to register an effect handler.
|
|
167
|
+
|
|
168
|
+
Usage:
|
|
169
|
+
@effect_handler("NarrativeRequest")
|
|
170
|
+
async def handle_narrative(effect: Effect) -> EffectResult:
|
|
171
|
+
# Process the effect
|
|
172
|
+
return EffectResult(status=EffectStatus.SUCCESS, data={...})
|
|
173
|
+
"""
|
|
174
|
+
def decorator(handler: EffectHandler) -> EffectHandler:
|
|
175
|
+
# Store the effect type on the handler for later registration
|
|
176
|
+
handler._effect_type = effect_type # type: ignore
|
|
177
|
+
return handler
|
|
178
|
+
|
|
179
|
+
return decorator
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def register_effect_handlers(
|
|
183
|
+
registry: EffectRegistry,
|
|
184
|
+
handlers: dict[str, EffectHandler] | None = None
|
|
185
|
+
) -> None:
|
|
186
|
+
"""
|
|
187
|
+
Register multiple effect handlers with a registry.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
registry: EffectRegistry to register with
|
|
191
|
+
handlers: Dict mapping effect type names to handlers
|
|
192
|
+
"""
|
|
193
|
+
if handlers:
|
|
194
|
+
for effect_type, handler in handlers.items():
|
|
195
|
+
registry.register(effect_type, handler)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# Global registry
|
|
199
|
+
_global_registry: EffectRegistry | None = None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def get_effect_registry() -> EffectRegistry:
|
|
203
|
+
"""Get the global effect registry."""
|
|
204
|
+
global _global_registry
|
|
205
|
+
if _global_registry is None:
|
|
206
|
+
_global_registry = EffectRegistry()
|
|
207
|
+
# Register default handlers
|
|
208
|
+
_global_registry.register("NarrativeRequest", default_narrative_handler)
|
|
209
|
+
_global_registry.register("Effect", default_effect_handler)
|
|
210
|
+
return _global_registry
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def reset_effect_registry() -> None:
|
|
214
|
+
"""Reset the global effect registry."""
|
|
215
|
+
global _global_registry
|
|
216
|
+
_global_registry = None
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pluggable log sinks for Orca machine audit trails.
|
|
3
|
+
|
|
4
|
+
LogSink is a Protocol — any object implementing write/close can be used.
|
|
5
|
+
Three sinks are bundled:
|
|
6
|
+
|
|
7
|
+
FileSink — JSONL file, one entry per line, append-safe for resume
|
|
8
|
+
ConsoleSink — human-readable transitions printed to stdout
|
|
9
|
+
MultiSink — fan-out to multiple sinks simultaneously
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
from orca_runtime_python import FileSink, ConsoleSink, MultiSink
|
|
13
|
+
|
|
14
|
+
sink = MultiSink(
|
|
15
|
+
FileSink("./runs/exp-001/audit.jsonl"),
|
|
16
|
+
ConsoleSink(),
|
|
17
|
+
)
|
|
18
|
+
await run_pipeline(machines, ctx, run_id="exp-001", log_sink=sink)
|
|
19
|
+
sink.close()
|
|
20
|
+
|
|
21
|
+
Log entry format (dict written to each sink):
|
|
22
|
+
{
|
|
23
|
+
"ts": "2026-03-27T10:15:32.123456Z",
|
|
24
|
+
"run_id": "exp-001",
|
|
25
|
+
"machine": "TrainingLab",
|
|
26
|
+
"event": "DATA_READY",
|
|
27
|
+
"from": "data_prep",
|
|
28
|
+
"to": "hyper_search",
|
|
29
|
+
"context_delta": {"vocab_size": 65, "train_tokens": 900000}
|
|
30
|
+
}
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import sys
|
|
37
|
+
from datetime import datetime, timezone
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any, Protocol, runtime_checkable
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@runtime_checkable
|
|
43
|
+
class LogSink(Protocol):
|
|
44
|
+
"""Protocol for Orca audit log destinations."""
|
|
45
|
+
|
|
46
|
+
def write(self, entry: dict[str, Any]) -> None:
|
|
47
|
+
"""Record a single log entry."""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
def close(self) -> None:
|
|
51
|
+
"""Flush and release any held resources."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class FileSink:
|
|
56
|
+
"""
|
|
57
|
+
Appends log entries as newline-delimited JSON (JSONL) to a file.
|
|
58
|
+
|
|
59
|
+
Opens in append mode so resumed runs extend the same audit log
|
|
60
|
+
rather than overwriting it.
|
|
61
|
+
|
|
62
|
+
Example:
|
|
63
|
+
sink = FileSink("./runs/exp-001/audit.jsonl")
|
|
64
|
+
sink.write({"event": "DATA_READY", ...})
|
|
65
|
+
sink.close()
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, path: str | Path):
|
|
69
|
+
self._path = Path(path)
|
|
70
|
+
self._f = None
|
|
71
|
+
|
|
72
|
+
def _ensure_open(self) -> None:
|
|
73
|
+
if self._f is None:
|
|
74
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
self._f = open(self._path, "a", encoding="utf-8")
|
|
76
|
+
|
|
77
|
+
def write(self, entry: dict[str, Any]) -> None:
|
|
78
|
+
self._ensure_open()
|
|
79
|
+
self._f.write(json.dumps(entry, default=str) + "\n") # type: ignore[union-attr]
|
|
80
|
+
self._f.flush() # type: ignore[union-attr]
|
|
81
|
+
|
|
82
|
+
def close(self) -> None:
|
|
83
|
+
if self._f is not None:
|
|
84
|
+
self._f.close()
|
|
85
|
+
self._f = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ConsoleSink:
|
|
89
|
+
"""
|
|
90
|
+
Prints a compact, human-readable line for each transition.
|
|
91
|
+
|
|
92
|
+
Format:
|
|
93
|
+
[HH:MM:SS] Machine from → to (EVENT) key=val key=val
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(self, file=None):
|
|
97
|
+
self._file = file or sys.stdout
|
|
98
|
+
|
|
99
|
+
def write(self, entry: dict[str, Any]) -> None:
|
|
100
|
+
ts = entry.get("ts", "")
|
|
101
|
+
time_part = ts[11:19] if len(ts) >= 19 else ts # HH:MM:SS
|
|
102
|
+
machine = entry.get("machine", "")
|
|
103
|
+
from_s = entry.get("from", "?")
|
|
104
|
+
to_s = entry.get("to", "?")
|
|
105
|
+
event = entry.get("event", "")
|
|
106
|
+
delta = entry.get("context_delta", {})
|
|
107
|
+
|
|
108
|
+
delta_str = " " + " ".join(
|
|
109
|
+
f"{k}={v}" for k, v in delta.items()
|
|
110
|
+
if k != "error_message" or v
|
|
111
|
+
) if delta else ""
|
|
112
|
+
|
|
113
|
+
event_str = f" ({event})" if event else ""
|
|
114
|
+
print(
|
|
115
|
+
f"[{time_part}] {machine:<14} {from_s} → {to_s}{event_str}{delta_str}",
|
|
116
|
+
file=self._file,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def close(self) -> None:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class MultiSink:
|
|
124
|
+
"""
|
|
125
|
+
Fan-out sink that writes each entry to multiple sinks.
|
|
126
|
+
|
|
127
|
+
Example:
|
|
128
|
+
sink = MultiSink(FileSink("audit.jsonl"), ConsoleSink())
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(self, *sinks: LogSink):
|
|
132
|
+
self._sinks = list(sinks)
|
|
133
|
+
|
|
134
|
+
def write(self, entry: dict[str, Any]) -> None:
|
|
135
|
+
for sink in self._sinks:
|
|
136
|
+
sink.write(entry)
|
|
137
|
+
|
|
138
|
+
def close(self) -> None:
|
|
139
|
+
for sink in self._sinks:
|
|
140
|
+
sink.close()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _make_entry(
|
|
144
|
+
*,
|
|
145
|
+
run_id: str,
|
|
146
|
+
machine: str,
|
|
147
|
+
event: str,
|
|
148
|
+
from_state: str,
|
|
149
|
+
to_state: str,
|
|
150
|
+
context_delta: dict[str, Any],
|
|
151
|
+
) -> dict[str, Any]:
|
|
152
|
+
"""Build a standard log entry dict."""
|
|
153
|
+
return {
|
|
154
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
155
|
+
"run_id": run_id,
|
|
156
|
+
"machine": machine,
|
|
157
|
+
"event": event,
|
|
158
|
+
"from": from_state,
|
|
159
|
+
"to": to_state,
|
|
160
|
+
"context_delta": context_delta,
|
|
161
|
+
}
|