agent_hypervisor 3.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.
- agent_hypervisor-3.1.0.dist-info/METADATA +824 -0
- agent_hypervisor-3.1.0.dist-info/RECORD +60 -0
- agent_hypervisor-3.1.0.dist-info/WHEEL +4 -0
- agent_hypervisor-3.1.0.dist-info/entry_points.txt +2 -0
- agent_hypervisor-3.1.0.dist-info/licenses/LICENSE +21 -0
- hypervisor/__init__.py +160 -0
- hypervisor/api/__init__.py +7 -0
- hypervisor/api/models.py +285 -0
- hypervisor/api/server.py +742 -0
- hypervisor/audit/__init__.py +4 -0
- hypervisor/audit/commitment.py +76 -0
- hypervisor/audit/delta.py +135 -0
- hypervisor/audit/gc.py +99 -0
- hypervisor/cli/__init__.py +3 -0
- hypervisor/cli/formatters.py +99 -0
- hypervisor/cli/session_commands.py +200 -0
- hypervisor/constants.py +106 -0
- hypervisor/core.py +352 -0
- hypervisor/integrations/__init__.py +10 -0
- hypervisor/integrations/iatp_adapter.py +142 -0
- hypervisor/integrations/nexus_adapter.py +108 -0
- hypervisor/integrations/verification_adapter.py +122 -0
- hypervisor/liability/__init__.py +142 -0
- hypervisor/liability/attribution.py +86 -0
- hypervisor/liability/ledger.py +121 -0
- hypervisor/liability/quarantine.py +119 -0
- hypervisor/liability/slashing.py +80 -0
- hypervisor/liability/vouching.py +134 -0
- hypervisor/models.py +277 -0
- hypervisor/observability/__init__.py +27 -0
- hypervisor/observability/causal_trace.py +70 -0
- hypervisor/observability/event_bus.py +222 -0
- hypervisor/observability/prometheus_collector.py +248 -0
- hypervisor/observability/saga_span_exporter.py +341 -0
- hypervisor/providers.py +121 -0
- hypervisor/py.typed +0 -0
- hypervisor/reversibility/__init__.py +3 -0
- hypervisor/reversibility/registry.py +108 -0
- hypervisor/rings/__init__.py +21 -0
- hypervisor/rings/breach_detector.py +200 -0
- hypervisor/rings/classifier.py +78 -0
- hypervisor/rings/elevation.py +219 -0
- hypervisor/rings/enforcer.py +97 -0
- hypervisor/saga/__init__.py +22 -0
- hypervisor/saga/checkpoint.py +110 -0
- hypervisor/saga/dsl.py +190 -0
- hypervisor/saga/fan_out.py +126 -0
- hypervisor/saga/orchestrator.py +229 -0
- hypervisor/saga/schema.py +244 -0
- hypervisor/saga/state_machine.py +157 -0
- hypervisor/security/__init__.py +13 -0
- hypervisor/security/kill_switch.py +200 -0
- hypervisor/security/rate_limiter.py +190 -0
- hypervisor/session/__init__.py +194 -0
- hypervisor/session/intent_locks.py +118 -0
- hypervisor/session/isolation.py +37 -0
- hypervisor/session/sso.py +169 -0
- hypervisor/session/vector_clock.py +118 -0
- hypervisor/verification/__init__.py +3 -0
- hypervisor/verification/history.py +173 -0
hypervisor/api/server.py
ADDED
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
"""
|
|
4
|
+
FastAPI REST API server for the Agent Hypervisor.
|
|
5
|
+
|
|
6
|
+
Exposes the hypervisor's core capabilities — sessions, rings, sagas,
|
|
7
|
+
liability, events, and health — as a RESTful API with OpenAPI docs.
|
|
8
|
+
|
|
9
|
+
Run with: uvicorn hypervisor.api.server:app
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
from contextlib import asynccontextmanager
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from fastapi import FastAPI, HTTPException, Query
|
|
20
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
21
|
+
|
|
22
|
+
from hypervisor import __version__
|
|
23
|
+
from hypervisor.api.models import (
|
|
24
|
+
AddStepRequest,
|
|
25
|
+
AddStepResponse,
|
|
26
|
+
AgentRingResponse,
|
|
27
|
+
CommitmentResponse,
|
|
28
|
+
CreateSagaResponse,
|
|
29
|
+
CreateSessionRequest,
|
|
30
|
+
CreateSessionResponse,
|
|
31
|
+
CreateVouchRequest,
|
|
32
|
+
EventResponse,
|
|
33
|
+
EventStatsResponse,
|
|
34
|
+
ExecuteStepResponse,
|
|
35
|
+
JoinSessionRequest,
|
|
36
|
+
JoinSessionResponse,
|
|
37
|
+
LiabilityExposureResponse,
|
|
38
|
+
ParticipantInfo,
|
|
39
|
+
RingCheckRequest,
|
|
40
|
+
RingCheckResponse,
|
|
41
|
+
RingDistributionResponse,
|
|
42
|
+
SagaDetailResponse,
|
|
43
|
+
SessionDetailResponse,
|
|
44
|
+
SessionListItem,
|
|
45
|
+
StatsResponse,
|
|
46
|
+
VerifyCommitmentResponse,
|
|
47
|
+
VerifyHistoryRequest,
|
|
48
|
+
VerifyHistoryResponse,
|
|
49
|
+
VouchResponse,
|
|
50
|
+
)
|
|
51
|
+
from hypervisor.core import Hypervisor, ManagedSession
|
|
52
|
+
from hypervisor.models import (
|
|
53
|
+
ActionDescriptor,
|
|
54
|
+
ExecutionRing,
|
|
55
|
+
SessionConfig,
|
|
56
|
+
)
|
|
57
|
+
from hypervisor.observability.event_bus import EventType, HypervisorEventBus
|
|
58
|
+
|
|
59
|
+
logger = logging.getLogger(__name__)
|
|
60
|
+
|
|
61
|
+
# ── Global state ────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
_hypervisor: Hypervisor | None = None
|
|
64
|
+
_event_bus: HypervisorEventBus | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _hv() -> Hypervisor:
|
|
68
|
+
"""Get the global Hypervisor instance."""
|
|
69
|
+
if _hypervisor is None:
|
|
70
|
+
raise HTTPException(status_code=503, detail="Hypervisor not initialized")
|
|
71
|
+
return _hypervisor
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _bus() -> HypervisorEventBus:
|
|
75
|
+
"""Get the global event bus."""
|
|
76
|
+
if _event_bus is None:
|
|
77
|
+
raise HTTPException(status_code=503, detail="Event bus not initialized")
|
|
78
|
+
return _event_bus
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _get_managed(session_id: str) -> ManagedSession:
|
|
82
|
+
"""Get a managed session or raise 404."""
|
|
83
|
+
managed = _hv().get_session(session_id)
|
|
84
|
+
if not managed:
|
|
85
|
+
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
|
86
|
+
return managed
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _participant_info(p: Any) -> ParticipantInfo:
|
|
90
|
+
return ParticipantInfo(
|
|
91
|
+
agent_did=p.agent_did,
|
|
92
|
+
ring=p.ring.value,
|
|
93
|
+
sigma_raw=p.sigma_raw,
|
|
94
|
+
eff_score=p.eff_score,
|
|
95
|
+
joined_at=p.joined_at.isoformat(),
|
|
96
|
+
is_active=p.is_active,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ── Lifespan ────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
@asynccontextmanager
|
|
103
|
+
async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
|
|
104
|
+
"""Initialize hypervisor on startup, clean up on shutdown."""
|
|
105
|
+
global _hypervisor, _event_bus
|
|
106
|
+
_hypervisor = Hypervisor()
|
|
107
|
+
_event_bus = HypervisorEventBus()
|
|
108
|
+
yield
|
|
109
|
+
_hypervisor = None
|
|
110
|
+
_event_bus = None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ── App factory ─────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
def create_app() -> FastAPI:
|
|
116
|
+
"""Create and configure the FastAPI application."""
|
|
117
|
+
application = FastAPI(
|
|
118
|
+
title="Agent Hypervisor API",
|
|
119
|
+
description=(
|
|
120
|
+
"REST API for the Agent Hypervisor — runtime supervisor for "
|
|
121
|
+
"multi-agent Shared Sessions with Execution Rings, Joint Liability, "
|
|
122
|
+
"Saga Orchestration, and Audit log audit trails."
|
|
123
|
+
),
|
|
124
|
+
version=__version__,
|
|
125
|
+
lifespan=lifespan,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
application.add_middleware(
|
|
129
|
+
CORSMiddleware,
|
|
130
|
+
allow_origins=os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:8080").split(","),
|
|
131
|
+
allow_credentials=True,
|
|
132
|
+
allow_methods=["*"],
|
|
133
|
+
allow_headers=["*"],
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return application
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
app = create_app()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ── Health ──────────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
@app.get("/health", tags=["Health"])
|
|
145
|
+
async def health_check() -> dict[str, str]:
|
|
146
|
+
"""Health check endpoint."""
|
|
147
|
+
return {"status": "ok", "version": __version__}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@app.get("/api/v1/stats", response_model=StatsResponse, tags=["Health"])
|
|
151
|
+
async def get_stats() -> StatsResponse:
|
|
152
|
+
"""Get overall hypervisor statistics."""
|
|
153
|
+
hv = _hv()
|
|
154
|
+
bus = _bus()
|
|
155
|
+
total_participants = sum(
|
|
156
|
+
m.sso.participant_count for m in hv._sessions.values()
|
|
157
|
+
)
|
|
158
|
+
active_sagas = sum(
|
|
159
|
+
len(m.saga.active_sagas) for m in hv._sessions.values()
|
|
160
|
+
)
|
|
161
|
+
return StatsResponse(
|
|
162
|
+
version=__version__,
|
|
163
|
+
total_sessions=len(hv._sessions),
|
|
164
|
+
active_sessions=len(hv.active_sessions),
|
|
165
|
+
total_participants=total_participants,
|
|
166
|
+
active_sagas=active_sagas,
|
|
167
|
+
total_vouches=len(hv.vouching._vouches),
|
|
168
|
+
event_count=bus.event_count,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ── Sessions ────────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
@app.post(
|
|
175
|
+
"/api/v1/sessions",
|
|
176
|
+
response_model=CreateSessionResponse,
|
|
177
|
+
status_code=201,
|
|
178
|
+
tags=["Sessions"],
|
|
179
|
+
)
|
|
180
|
+
async def create_session(req: CreateSessionRequest) -> CreateSessionResponse:
|
|
181
|
+
"""Create a new Shared Session."""
|
|
182
|
+
config = SessionConfig(
|
|
183
|
+
consistency_mode=req.consistency_mode,
|
|
184
|
+
max_participants=req.max_participants,
|
|
185
|
+
max_duration_seconds=req.max_duration_seconds,
|
|
186
|
+
min_eff_score=req.min_eff_score,
|
|
187
|
+
enable_audit=req.enable_audit,
|
|
188
|
+
enable_blockchain_commitment=req.enable_blockchain_commitment,
|
|
189
|
+
)
|
|
190
|
+
managed = await _hv().create_session(config=config, creator_did=req.creator_did)
|
|
191
|
+
return CreateSessionResponse(
|
|
192
|
+
session_id=managed.sso.session_id,
|
|
193
|
+
state=managed.sso.state.value,
|
|
194
|
+
consistency_mode=managed.sso.consistency_mode.value,
|
|
195
|
+
created_at=managed.sso.created_at.isoformat(),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@app.get("/api/v1/sessions", response_model=list[SessionListItem], tags=["Sessions"])
|
|
200
|
+
async def list_sessions(
|
|
201
|
+
state: str | None = Query(None, description="Filter by session state"),
|
|
202
|
+
) -> list[SessionListItem]:
|
|
203
|
+
"""List all sessions, optionally filtered by state."""
|
|
204
|
+
sessions = _hv()._sessions.values()
|
|
205
|
+
if state:
|
|
206
|
+
sessions = [m for m in sessions if m.sso.state.value == state]
|
|
207
|
+
return [
|
|
208
|
+
SessionListItem(
|
|
209
|
+
session_id=m.sso.session_id,
|
|
210
|
+
state=m.sso.state.value,
|
|
211
|
+
consistency_mode=m.sso.consistency_mode.value,
|
|
212
|
+
participant_count=m.sso.participant_count,
|
|
213
|
+
created_at=m.sso.created_at.isoformat(),
|
|
214
|
+
)
|
|
215
|
+
for m in sessions
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@app.get(
|
|
220
|
+
"/api/v1/sessions/{session_id}",
|
|
221
|
+
response_model=SessionDetailResponse,
|
|
222
|
+
tags=["Sessions"],
|
|
223
|
+
)
|
|
224
|
+
async def get_session(session_id: str) -> SessionDetailResponse:
|
|
225
|
+
"""Get detailed session information including participants and sagas."""
|
|
226
|
+
managed = _get_managed(session_id)
|
|
227
|
+
sso = managed.sso
|
|
228
|
+
sagas = [
|
|
229
|
+
s.to_dict()
|
|
230
|
+
for s in managed.saga._sagas.values()
|
|
231
|
+
]
|
|
232
|
+
return SessionDetailResponse(
|
|
233
|
+
session_id=sso.session_id,
|
|
234
|
+
state=sso.state.value,
|
|
235
|
+
consistency_mode=sso.consistency_mode.value,
|
|
236
|
+
creator_did=sso.creator_did,
|
|
237
|
+
participant_count=sso.participant_count,
|
|
238
|
+
participants=[_participant_info(p) for p in sso.participants],
|
|
239
|
+
created_at=sso.created_at.isoformat(),
|
|
240
|
+
terminated_at=sso.terminated_at.isoformat() if sso.terminated_at else None,
|
|
241
|
+
sagas=sagas,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@app.post(
|
|
246
|
+
"/api/v1/sessions/{session_id}/join",
|
|
247
|
+
response_model=JoinSessionResponse,
|
|
248
|
+
tags=["Sessions"],
|
|
249
|
+
)
|
|
250
|
+
async def join_session(session_id: str, req: JoinSessionRequest) -> JoinSessionResponse:
|
|
251
|
+
"""Join an agent to a session via the IATP handshake."""
|
|
252
|
+
hv = _hv()
|
|
253
|
+
actions = None
|
|
254
|
+
if req.actions:
|
|
255
|
+
actions = [ActionDescriptor(**a) for a in req.actions]
|
|
256
|
+
try:
|
|
257
|
+
ring = await hv.join_session(
|
|
258
|
+
session_id=session_id,
|
|
259
|
+
agent_did=req.agent_did,
|
|
260
|
+
actions=actions,
|
|
261
|
+
sigma_raw=req.sigma_raw,
|
|
262
|
+
)
|
|
263
|
+
except ValueError as e:
|
|
264
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
265
|
+
except Exception as e:
|
|
266
|
+
logger.debug("join_session failed for %s: %s", session_id, e, exc_info=True)
|
|
267
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
268
|
+
return JoinSessionResponse(
|
|
269
|
+
agent_did=req.agent_did,
|
|
270
|
+
session_id=session_id,
|
|
271
|
+
assigned_ring=ring.value,
|
|
272
|
+
ring_name=ring.name,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@app.post("/api/v1/sessions/{session_id}/activate", tags=["Sessions"])
|
|
277
|
+
async def activate_session(session_id: str) -> dict[str, str]:
|
|
278
|
+
"""Activate a session after handshaking is complete."""
|
|
279
|
+
try:
|
|
280
|
+
await _hv().activate_session(session_id)
|
|
281
|
+
except ValueError as e:
|
|
282
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
283
|
+
except Exception as e:
|
|
284
|
+
logger.debug("activate_session failed for %s: %s", session_id, e, exc_info=True)
|
|
285
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
286
|
+
return {"session_id": session_id, "state": "active"}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@app.post("/api/v1/sessions/{session_id}/terminate", tags=["Sessions"])
|
|
290
|
+
async def terminate_session(session_id: str) -> dict[str, Any]:
|
|
291
|
+
"""Terminate a session and commit audit trail."""
|
|
292
|
+
try:
|
|
293
|
+
hash_chain_root = await _hv().terminate_session(session_id)
|
|
294
|
+
except ValueError as e:
|
|
295
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
296
|
+
except Exception as e:
|
|
297
|
+
logger.debug("terminate_session failed for %s: %s", session_id, e, exc_info=True)
|
|
298
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
299
|
+
return {
|
|
300
|
+
"session_id": session_id,
|
|
301
|
+
"state": "archived",
|
|
302
|
+
"hash_chain_root": hash_chain_root,
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ── Rings ───────────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
@app.get(
|
|
309
|
+
"/api/v1/sessions/{session_id}/rings",
|
|
310
|
+
response_model=RingDistributionResponse,
|
|
311
|
+
tags=["Rings"],
|
|
312
|
+
)
|
|
313
|
+
async def get_ring_distribution(session_id: str) -> RingDistributionResponse:
|
|
314
|
+
"""Get the ring distribution for all participants in a session."""
|
|
315
|
+
managed = _get_managed(session_id)
|
|
316
|
+
distribution: dict[str, list[str]] = {}
|
|
317
|
+
for p in managed.sso.participants:
|
|
318
|
+
ring_name = p.ring.name
|
|
319
|
+
distribution.setdefault(ring_name, []).append(p.agent_did)
|
|
320
|
+
return RingDistributionResponse(session_id=session_id, distribution=distribution)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@app.get(
|
|
324
|
+
"/api/v1/agents/{agent_did}/ring",
|
|
325
|
+
response_model=AgentRingResponse,
|
|
326
|
+
tags=["Rings"],
|
|
327
|
+
)
|
|
328
|
+
async def get_agent_ring(agent_did: str) -> AgentRingResponse:
|
|
329
|
+
"""Get an agent's current ring level across all active sessions."""
|
|
330
|
+
hv = _hv()
|
|
331
|
+
for managed in hv._sessions.values():
|
|
332
|
+
for p in managed.sso.participants:
|
|
333
|
+
if p.agent_did == agent_did and p.is_active:
|
|
334
|
+
return AgentRingResponse(
|
|
335
|
+
agent_did=agent_did,
|
|
336
|
+
ring=p.ring.value,
|
|
337
|
+
ring_name=p.ring.name,
|
|
338
|
+
session_id=managed.sso.session_id,
|
|
339
|
+
)
|
|
340
|
+
raise HTTPException(status_code=404, detail=f"Agent {agent_did} not found in any session")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@app.post(
|
|
344
|
+
"/api/v1/rings/check",
|
|
345
|
+
response_model=RingCheckResponse,
|
|
346
|
+
tags=["Rings"],
|
|
347
|
+
)
|
|
348
|
+
async def check_ring_access(req: RingCheckRequest) -> RingCheckResponse:
|
|
349
|
+
"""Check if an action is allowed for the given ring level and sigma."""
|
|
350
|
+
hv = _hv()
|
|
351
|
+
action = ActionDescriptor(**req.action)
|
|
352
|
+
agent_ring = ExecutionRing(req.agent_ring)
|
|
353
|
+
result = hv.ring_enforcer.check(
|
|
354
|
+
agent_ring=agent_ring,
|
|
355
|
+
action=action,
|
|
356
|
+
eff_score=req.eff_score,
|
|
357
|
+
has_consensus=req.has_consensus,
|
|
358
|
+
has_sre_witness=req.has_sre_witness,
|
|
359
|
+
)
|
|
360
|
+
return RingCheckResponse(
|
|
361
|
+
allowed=result.allowed,
|
|
362
|
+
required_ring=result.required_ring.value,
|
|
363
|
+
agent_ring=result.agent_ring.value,
|
|
364
|
+
eff_score=result.eff_score,
|
|
365
|
+
reason=result.reason,
|
|
366
|
+
requires_consensus=result.requires_consensus,
|
|
367
|
+
requires_sre_witness=result.requires_sre_witness,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# ── Sagas ───────────────────────────────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
@app.post(
|
|
374
|
+
"/api/v1/sessions/{session_id}/sagas",
|
|
375
|
+
response_model=CreateSagaResponse,
|
|
376
|
+
status_code=201,
|
|
377
|
+
tags=["Sagas"],
|
|
378
|
+
)
|
|
379
|
+
async def create_saga(session_id: str) -> CreateSagaResponse:
|
|
380
|
+
"""Create a new saga for a session."""
|
|
381
|
+
managed = _get_managed(session_id)
|
|
382
|
+
saga = managed.saga.create_saga(session_id)
|
|
383
|
+
return CreateSagaResponse(
|
|
384
|
+
saga_id=saga.saga_id,
|
|
385
|
+
session_id=saga.session_id,
|
|
386
|
+
state=saga.state.value,
|
|
387
|
+
created_at=saga.created_at.isoformat(),
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@app.get(
|
|
392
|
+
"/api/v1/sessions/{session_id}/sagas",
|
|
393
|
+
response_model=list[SagaDetailResponse],
|
|
394
|
+
tags=["Sagas"],
|
|
395
|
+
)
|
|
396
|
+
async def list_sagas(session_id: str) -> list[SagaDetailResponse]:
|
|
397
|
+
"""List all sagas in a session."""
|
|
398
|
+
managed = _get_managed(session_id)
|
|
399
|
+
return [
|
|
400
|
+
SagaDetailResponse(
|
|
401
|
+
saga_id=s.saga_id,
|
|
402
|
+
session_id=s.session_id,
|
|
403
|
+
state=s.state.value,
|
|
404
|
+
created_at=s.created_at.isoformat(),
|
|
405
|
+
completed_at=s.completed_at.isoformat() if s.completed_at else None,
|
|
406
|
+
error=s.error,
|
|
407
|
+
steps=[
|
|
408
|
+
{
|
|
409
|
+
"step_id": st.step_id,
|
|
410
|
+
"action_id": st.action_id,
|
|
411
|
+
"agent_did": st.agent_did,
|
|
412
|
+
"state": st.state.value,
|
|
413
|
+
"error": st.error,
|
|
414
|
+
}
|
|
415
|
+
for st in s.steps
|
|
416
|
+
],
|
|
417
|
+
)
|
|
418
|
+
for s in managed.saga._sagas.values()
|
|
419
|
+
]
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@app.get(
|
|
423
|
+
"/api/v1/sagas/{saga_id}",
|
|
424
|
+
response_model=SagaDetailResponse,
|
|
425
|
+
tags=["Sagas"],
|
|
426
|
+
)
|
|
427
|
+
async def get_saga(saga_id: str) -> SagaDetailResponse:
|
|
428
|
+
"""Get detailed saga information including steps and state."""
|
|
429
|
+
hv = _hv()
|
|
430
|
+
for managed in hv._sessions.values():
|
|
431
|
+
saga = managed.saga.get_saga(saga_id)
|
|
432
|
+
if saga:
|
|
433
|
+
return SagaDetailResponse(
|
|
434
|
+
saga_id=saga.saga_id,
|
|
435
|
+
session_id=saga.session_id,
|
|
436
|
+
state=saga.state.value,
|
|
437
|
+
created_at=saga.created_at.isoformat(),
|
|
438
|
+
completed_at=saga.completed_at.isoformat() if saga.completed_at else None,
|
|
439
|
+
error=saga.error,
|
|
440
|
+
steps=[
|
|
441
|
+
{
|
|
442
|
+
"step_id": st.step_id,
|
|
443
|
+
"action_id": st.action_id,
|
|
444
|
+
"agent_did": st.agent_did,
|
|
445
|
+
"state": st.state.value,
|
|
446
|
+
"error": st.error,
|
|
447
|
+
}
|
|
448
|
+
for st in saga.steps
|
|
449
|
+
],
|
|
450
|
+
)
|
|
451
|
+
raise HTTPException(status_code=404, detail=f"Saga {saga_id} not found")
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@app.post(
|
|
455
|
+
"/api/v1/sagas/{saga_id}/steps",
|
|
456
|
+
response_model=AddStepResponse,
|
|
457
|
+
status_code=201,
|
|
458
|
+
tags=["Sagas"],
|
|
459
|
+
)
|
|
460
|
+
async def add_saga_step(saga_id: str, req: AddStepRequest) -> AddStepResponse:
|
|
461
|
+
"""Add a step to an existing saga."""
|
|
462
|
+
hv = _hv()
|
|
463
|
+
for managed in hv._sessions.values():
|
|
464
|
+
saga = managed.saga.get_saga(saga_id)
|
|
465
|
+
if saga:
|
|
466
|
+
try:
|
|
467
|
+
step = managed.saga.add_step(
|
|
468
|
+
saga_id=saga_id,
|
|
469
|
+
action_id=req.action_id,
|
|
470
|
+
agent_did=req.agent_did,
|
|
471
|
+
execute_api=req.execute_api,
|
|
472
|
+
undo_api=req.undo_api,
|
|
473
|
+
timeout_seconds=req.timeout_seconds,
|
|
474
|
+
max_retries=req.max_retries,
|
|
475
|
+
)
|
|
476
|
+
except Exception as e:
|
|
477
|
+
logger.debug("add_step failed for saga %s: %s", saga_id, e, exc_info=True)
|
|
478
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
479
|
+
return AddStepResponse(
|
|
480
|
+
step_id=step.step_id,
|
|
481
|
+
saga_id=saga_id,
|
|
482
|
+
action_id=step.action_id,
|
|
483
|
+
state=step.state.value,
|
|
484
|
+
)
|
|
485
|
+
raise HTTPException(status_code=404, detail=f"Saga {saga_id} not found")
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@app.post(
|
|
489
|
+
"/api/v1/sagas/{saga_id}/steps/{step_id}/execute",
|
|
490
|
+
response_model=ExecuteStepResponse,
|
|
491
|
+
tags=["Sagas"],
|
|
492
|
+
)
|
|
493
|
+
async def execute_saga_step(saga_id: str, step_id: str) -> ExecuteStepResponse:
|
|
494
|
+
"""Execute a pending saga step (using a no-op executor for API-driven flow)."""
|
|
495
|
+
hv = _hv()
|
|
496
|
+
for managed in hv._sessions.values():
|
|
497
|
+
saga = managed.saga.get_saga(saga_id)
|
|
498
|
+
if saga:
|
|
499
|
+
try:
|
|
500
|
+
async def _noop_executor() -> dict[str, str]:
|
|
501
|
+
return {"status": "executed_via_api"}
|
|
502
|
+
|
|
503
|
+
await managed.saga.execute_step(saga_id, step_id, _noop_executor)
|
|
504
|
+
except Exception as e:
|
|
505
|
+
logger.debug("execute_step failed for saga %s step %s: %s", saga_id, step_id, e, exc_info=True)
|
|
506
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
507
|
+
# Find the step to return its state
|
|
508
|
+
for st in saga.steps:
|
|
509
|
+
if st.step_id == step_id:
|
|
510
|
+
return ExecuteStepResponse(
|
|
511
|
+
step_id=step_id,
|
|
512
|
+
saga_id=saga_id,
|
|
513
|
+
state=st.state.value,
|
|
514
|
+
error=st.error,
|
|
515
|
+
)
|
|
516
|
+
raise HTTPException(status_code=404, detail=f"Saga {saga_id} or step {step_id} not found")
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
# ── Liability ───────────────────────────────────────────────────────────────
|
|
520
|
+
|
|
521
|
+
@app.post(
|
|
522
|
+
"/api/v1/sessions/{session_id}/sponsor",
|
|
523
|
+
response_model=VouchResponse,
|
|
524
|
+
status_code=201,
|
|
525
|
+
tags=["Liability"],
|
|
526
|
+
)
|
|
527
|
+
async def create_vouch(session_id: str, req: CreateVouchRequest) -> VouchResponse:
|
|
528
|
+
"""Create a sponsorship bond between agents in a session."""
|
|
529
|
+
hv = _hv()
|
|
530
|
+
_get_managed(session_id) # verify session exists
|
|
531
|
+
try:
|
|
532
|
+
record = hv.vouching.vouch(
|
|
533
|
+
voucher_did=req.voucher_did,
|
|
534
|
+
vouchee_did=req.vouchee_did,
|
|
535
|
+
session_id=session_id,
|
|
536
|
+
voucher_sigma=req.voucher_sigma,
|
|
537
|
+
bond_pct=req.bond_pct,
|
|
538
|
+
)
|
|
539
|
+
except Exception as e:
|
|
540
|
+
logger.debug("create_vouch failed for session %s: %s", session_id, e, exc_info=True)
|
|
541
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
542
|
+
return VouchResponse(
|
|
543
|
+
vouch_id=record.vouch_id,
|
|
544
|
+
voucher_did=record.voucher_did,
|
|
545
|
+
vouchee_did=record.vouchee_did,
|
|
546
|
+
session_id=record.session_id,
|
|
547
|
+
bonded_amount=record.bonded_amount,
|
|
548
|
+
bonded_sigma_pct=record.bonded_sigma_pct,
|
|
549
|
+
is_active=record.is_active,
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
@app.get(
|
|
554
|
+
"/api/v1/sessions/{session_id}/sponsors",
|
|
555
|
+
response_model=list[VouchResponse],
|
|
556
|
+
tags=["Liability"],
|
|
557
|
+
)
|
|
558
|
+
async def list_vouches(session_id: str) -> list[VouchResponse]:
|
|
559
|
+
"""List all sponsors in a session."""
|
|
560
|
+
_get_managed(session_id)
|
|
561
|
+
hv = _hv()
|
|
562
|
+
return [
|
|
563
|
+
VouchResponse(
|
|
564
|
+
vouch_id=v.vouch_id,
|
|
565
|
+
voucher_did=v.voucher_did,
|
|
566
|
+
vouchee_did=v.vouchee_did,
|
|
567
|
+
session_id=v.session_id,
|
|
568
|
+
bonded_amount=v.bonded_amount,
|
|
569
|
+
bonded_sigma_pct=v.bonded_sigma_pct,
|
|
570
|
+
is_active=v.is_active,
|
|
571
|
+
)
|
|
572
|
+
for v in hv.vouching._vouches.values()
|
|
573
|
+
if v.session_id == session_id
|
|
574
|
+
]
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
@app.get(
|
|
578
|
+
"/api/v1/agents/{agent_did}/liability",
|
|
579
|
+
response_model=LiabilityExposureResponse,
|
|
580
|
+
tags=["Liability"],
|
|
581
|
+
)
|
|
582
|
+
async def get_agent_liability(agent_did: str) -> LiabilityExposureResponse:
|
|
583
|
+
"""Get an agent's liability exposure across all sessions."""
|
|
584
|
+
hv = _hv()
|
|
585
|
+
vouches_given = []
|
|
586
|
+
vouches_received = []
|
|
587
|
+
total_exposure = 0.0
|
|
588
|
+
|
|
589
|
+
for v in hv.vouching._vouches.values():
|
|
590
|
+
vr = VouchResponse(
|
|
591
|
+
vouch_id=v.vouch_id,
|
|
592
|
+
voucher_did=v.voucher_did,
|
|
593
|
+
vouchee_did=v.vouchee_did,
|
|
594
|
+
session_id=v.session_id,
|
|
595
|
+
bonded_amount=v.bonded_amount,
|
|
596
|
+
bonded_sigma_pct=v.bonded_sigma_pct,
|
|
597
|
+
is_active=v.is_active,
|
|
598
|
+
)
|
|
599
|
+
if v.voucher_did == agent_did:
|
|
600
|
+
vouches_given.append(vr)
|
|
601
|
+
if v.is_active and not v.is_expired:
|
|
602
|
+
total_exposure += v.bonded_amount
|
|
603
|
+
if v.vouchee_did == agent_did:
|
|
604
|
+
vouches_received.append(vr)
|
|
605
|
+
|
|
606
|
+
return LiabilityExposureResponse(
|
|
607
|
+
agent_did=agent_did,
|
|
608
|
+
vouches_given=vouches_given,
|
|
609
|
+
vouches_received=vouches_received,
|
|
610
|
+
total_exposure=total_exposure,
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
# ── Events ──────────────────────────────────────────────────────────────────
|
|
615
|
+
|
|
616
|
+
@app.get("/api/v1/events", response_model=list[EventResponse], tags=["Events"])
|
|
617
|
+
async def query_events(
|
|
618
|
+
event_type: str | None = Query(None, description="Filter by event type"),
|
|
619
|
+
session_id: str | None = Query(None, description="Filter by session ID"),
|
|
620
|
+
agent_did: str | None = Query(None, description="Filter by agent DID"),
|
|
621
|
+
limit: int | None = Query(None, description="Max events to return"),
|
|
622
|
+
) -> list[EventResponse]:
|
|
623
|
+
"""Query events with optional filters."""
|
|
624
|
+
bus = _bus()
|
|
625
|
+
et = None
|
|
626
|
+
if event_type:
|
|
627
|
+
try:
|
|
628
|
+
et = EventType(event_type)
|
|
629
|
+
except ValueError:
|
|
630
|
+
raise HTTPException(status_code=400, detail=f"Unknown event type: {event_type}")
|
|
631
|
+
events = bus.query(event_type=et, session_id=session_id, agent_did=agent_did, limit=limit)
|
|
632
|
+
return [
|
|
633
|
+
EventResponse(
|
|
634
|
+
event_id=e.event_id,
|
|
635
|
+
event_type=e.event_type.value,
|
|
636
|
+
timestamp=e.timestamp.isoformat(),
|
|
637
|
+
session_id=e.session_id,
|
|
638
|
+
agent_did=e.agent_did,
|
|
639
|
+
causal_trace_id=e.causal_trace_id,
|
|
640
|
+
payload=e.payload,
|
|
641
|
+
)
|
|
642
|
+
for e in events
|
|
643
|
+
]
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
@app.get(
|
|
647
|
+
"/api/v1/events/stats",
|
|
648
|
+
response_model=EventStatsResponse,
|
|
649
|
+
tags=["Events"],
|
|
650
|
+
)
|
|
651
|
+
async def get_event_stats() -> EventStatsResponse:
|
|
652
|
+
"""Get event type counts."""
|
|
653
|
+
bus = _bus()
|
|
654
|
+
return EventStatsResponse(
|
|
655
|
+
total_events=bus.event_count,
|
|
656
|
+
by_type=bus.type_counts(),
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
# ── Audit endpoints ─────────────────────────────────────────────────────────
|
|
661
|
+
|
|
662
|
+
@app.get("/api/v1/audit/commitments", response_model=list[CommitmentResponse], tags=["Audit"])
|
|
663
|
+
async def list_commitments():
|
|
664
|
+
"""List all session commitments."""
|
|
665
|
+
engine = _hv().commitment_engine
|
|
666
|
+
return [
|
|
667
|
+
CommitmentResponse(
|
|
668
|
+
session_id=r.session_id,
|
|
669
|
+
hash_chain_root=r.hash_chain_root,
|
|
670
|
+
participant_dids=r.participant_dids,
|
|
671
|
+
delta_count=r.delta_count,
|
|
672
|
+
committed_at=r.committed_at.isoformat(),
|
|
673
|
+
committed_to=r.committed_to,
|
|
674
|
+
blockchain_tx_id=r.blockchain_tx_id,
|
|
675
|
+
)
|
|
676
|
+
for r in engine._commitments.values()
|
|
677
|
+
]
|
|
678
|
+
|
|
679
|
+
@app.get("/api/v1/audit/commitments/{session_id}", response_model=CommitmentResponse, tags=["Audit"])
|
|
680
|
+
async def get_commitment(session_id: str):
|
|
681
|
+
"""Get commitment for a specific session."""
|
|
682
|
+
engine = _hv().commitment_engine
|
|
683
|
+
record = engine.get_commitment(session_id)
|
|
684
|
+
if not record:
|
|
685
|
+
raise HTTPException(status_code=404, detail="Commitment not found")
|
|
686
|
+
return CommitmentResponse(
|
|
687
|
+
session_id=record.session_id,
|
|
688
|
+
hash_chain_root=record.hash_chain_root,
|
|
689
|
+
participant_dids=record.participant_dids,
|
|
690
|
+
delta_count=record.delta_count,
|
|
691
|
+
committed_at=record.committed_at.isoformat(),
|
|
692
|
+
committed_to=record.committed_to,
|
|
693
|
+
blockchain_tx_id=record.blockchain_tx_id,
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
@app.post("/api/v1/audit/verify/{session_id}", response_model=VerifyCommitmentResponse, tags=["Audit"])
|
|
697
|
+
async def verify_commitment(session_id: str, expected_root: str = Query(...)):
|
|
698
|
+
"""Verify a session's audit log root matches its commitment."""
|
|
699
|
+
engine = _hv().commitment_engine
|
|
700
|
+
record = engine.get_commitment(session_id)
|
|
701
|
+
if not record:
|
|
702
|
+
raise HTTPException(status_code=404, detail="Commitment not found")
|
|
703
|
+
valid = engine.verify(session_id, expected_root)
|
|
704
|
+
return VerifyCommitmentResponse(
|
|
705
|
+
session_id=session_id,
|
|
706
|
+
valid=valid,
|
|
707
|
+
committed_root=record.hash_chain_root,
|
|
708
|
+
expected_root=expected_root,
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
# ── Verification endpoints ──────────────────────────────────────────────────
|
|
712
|
+
|
|
713
|
+
@app.post("/api/v1/verify/history", response_model=VerifyHistoryResponse, tags=["Verification"])
|
|
714
|
+
async def verify_agent_history(request: VerifyHistoryRequest):
|
|
715
|
+
"""Verify an agent's transaction history."""
|
|
716
|
+
from hypervisor.verification.history import TransactionRecord
|
|
717
|
+
records = [
|
|
718
|
+
TransactionRecord(
|
|
719
|
+
session_id=r.session_id,
|
|
720
|
+
summary_hash=r.summary_hash,
|
|
721
|
+
timestamp=r.timestamp,
|
|
722
|
+
participant_count=r.participant_count,
|
|
723
|
+
)
|
|
724
|
+
for r in request.transactions
|
|
725
|
+
]
|
|
726
|
+
verifier = _hv().history_verifier
|
|
727
|
+
result = verifier.verify(request.agent_did, records)
|
|
728
|
+
return VerifyHistoryResponse(
|
|
729
|
+
agent_did=result.agent_did,
|
|
730
|
+
status=result.status.value,
|
|
731
|
+
transactions_checked=result.transactions_checked,
|
|
732
|
+
transactions_found=result.transactions_found,
|
|
733
|
+
inconsistencies=result.inconsistencies,
|
|
734
|
+
is_trustworthy=result.is_trustworthy,
|
|
735
|
+
cached=result.cached,
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
@app.delete("/api/v1/verify/cache/{agent_did}", tags=["Verification"])
|
|
739
|
+
async def clear_verification_cache(agent_did: str):
|
|
740
|
+
"""Clear cached verification result for an agent."""
|
|
741
|
+
_hv().history_verifier.clear_cache(agent_did)
|
|
742
|
+
return {"status": "cleared", "agent_did": agent_did}
|