intentframe-executor 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.
- executor/__init__.py +24 -0
- executor/config/__init__.py +106 -0
- executor/config/executor.yaml +156 -0
- executor/config/schema.py +223 -0
- executor/dispatch.py +122 -0
- executor/gateway.py +399 -0
- executor/main.py +284 -0
- executor/server.py +175 -0
- executor/worker_pool.py +174 -0
- intentframe_executor-0.1.0.dist-info/METADATA +37 -0
- intentframe_executor-0.1.0.dist-info/RECORD +13 -0
- intentframe_executor-0.1.0.dist-info/WHEEL +4 -0
- intentframe_executor-0.1.0.dist-info/licenses/LICENSE +661 -0
executor/gateway.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Executor Gateway -- the orchestration brain.
|
|
3
|
+
|
|
4
|
+
The gateway is the central request handler. It receives ExecutionRequests
|
|
5
|
+
from the transport layer and orchestrates the full execution pipeline:
|
|
6
|
+
|
|
7
|
+
1. Verify authorization proof (via AuthVerifier)
|
|
8
|
+
2. Validate request schema (Pydantic already did this during deserialization)
|
|
9
|
+
3. Log execution start (via AuditLogger)
|
|
10
|
+
4. Route to capability adapter (via ActionDispatcher)
|
|
11
|
+
5. Fetch credentials if needed (via CredentialVault)
|
|
12
|
+
6. Execute in worker pool (via WorkerPool + adapter.safe_execute)
|
|
13
|
+
7. Store rollback data if available (via StateStore)
|
|
14
|
+
8. Log execution result (via AuditLogger)
|
|
15
|
+
9. Return ExecutionResult to transport
|
|
16
|
+
|
|
17
|
+
The gateway is transport-agnostic, auth-scheme-agnostic, and
|
|
18
|
+
adapter-agnostic. It only knows the contracts (ABCs and models).
|
|
19
|
+
|
|
20
|
+
This is CONCRETE code -- not an ABC. It's platform-agnostic because
|
|
21
|
+
it only depends on abstractions. Platform-specific behavior comes
|
|
22
|
+
from the injected dependencies.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
import time
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from executor_sdk.auth.base import AuthVerifier
|
|
32
|
+
from executor.dispatch import ActionDispatcher
|
|
33
|
+
from executor_sdk.exceptions import (
|
|
34
|
+
AdapterNotFoundError,
|
|
35
|
+
AuditError,
|
|
36
|
+
AuthenticationError,
|
|
37
|
+
CredentialError,
|
|
38
|
+
ExecutorError,
|
|
39
|
+
)
|
|
40
|
+
from executor_sdk.models import (
|
|
41
|
+
AuditEntry,
|
|
42
|
+
ExecutionRequest,
|
|
43
|
+
ExecutionResult,
|
|
44
|
+
ExecutionStatus,
|
|
45
|
+
RollbackEntry,
|
|
46
|
+
SecurityEvent,
|
|
47
|
+
SecurityEventType,
|
|
48
|
+
)
|
|
49
|
+
from executor_sdk.services.audit_logger import AuditLogger
|
|
50
|
+
from executor_sdk.services.credential_scrubber import CredentialScrubber
|
|
51
|
+
from executor_sdk.services.credential_vault import CredentialVault
|
|
52
|
+
from executor_sdk.services.hash_chain import HashChain
|
|
53
|
+
from executor_sdk.services.state_store import StateStore
|
|
54
|
+
from executor.worker_pool import WorkerPool
|
|
55
|
+
|
|
56
|
+
logger = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
__all__ = ["ExecutorGateway"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ExecutorGateway:
|
|
62
|
+
"""The executor's central request handler.
|
|
63
|
+
|
|
64
|
+
Orchestrates the full pipeline: auth -> validate -> route -> execute -> audit.
|
|
65
|
+
Injected with all dependencies at construction -- no globals, no singletons.
|
|
66
|
+
|
|
67
|
+
Usage:
|
|
68
|
+
gateway = ExecutorGateway(
|
|
69
|
+
auth_verifier=...,
|
|
70
|
+
dispatcher=...,
|
|
71
|
+
worker_pool=...,
|
|
72
|
+
audit_logger=...,
|
|
73
|
+
credential_vault=...,
|
|
74
|
+
state_store=...,
|
|
75
|
+
scrubber=...,
|
|
76
|
+
hash_chain=...,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Transport calls this for every inbound request:
|
|
80
|
+
result = await gateway.handle(execution_request)
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
auth_verifier: AuthVerifier,
|
|
86
|
+
dispatcher: ActionDispatcher,
|
|
87
|
+
worker_pool: WorkerPool,
|
|
88
|
+
audit_logger: AuditLogger,
|
|
89
|
+
credential_vault: CredentialVault,
|
|
90
|
+
state_store: StateStore,
|
|
91
|
+
scrubber: CredentialScrubber,
|
|
92
|
+
hash_chain: HashChain,
|
|
93
|
+
) -> None:
|
|
94
|
+
self._auth = auth_verifier
|
|
95
|
+
self._dispatcher = dispatcher
|
|
96
|
+
self._pool = worker_pool
|
|
97
|
+
self._audit = audit_logger
|
|
98
|
+
self._vault = credential_vault
|
|
99
|
+
self._state = state_store
|
|
100
|
+
self._scrubber = scrubber
|
|
101
|
+
self._chain = hash_chain
|
|
102
|
+
|
|
103
|
+
def close(self) -> None:
|
|
104
|
+
"""Close service resources (DB connections, etc.)."""
|
|
105
|
+
self._audit.close()
|
|
106
|
+
self._state.close()
|
|
107
|
+
|
|
108
|
+
async def handle(self, request: ExecutionRequest) -> ExecutionResult:
|
|
109
|
+
"""Process a single execution request through the full pipeline.
|
|
110
|
+
|
|
111
|
+
This is the method the transport layer calls for every inbound request.
|
|
112
|
+
It ALWAYS returns an ExecutionResult -- never raises.
|
|
113
|
+
On any failure at any stage, returns ExecutionResult(success=False).
|
|
114
|
+
|
|
115
|
+
Pipeline:
|
|
116
|
+
1. Verify auth -> reject if invalid
|
|
117
|
+
2. Route action -> reject if unknown
|
|
118
|
+
3. Log start -> reject if audit fails (fail-closed)
|
|
119
|
+
4. Fetch creds -> reject if unavailable
|
|
120
|
+
5. Execute -> always returns result (safe_execute)
|
|
121
|
+
6. Store rollback -> best-effort
|
|
122
|
+
7. Log complete -> best-effort (execution already done)
|
|
123
|
+
8. Return result
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
request: Validated ExecutionRequest from the transport layer.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
ExecutionResult with success=True/False.
|
|
130
|
+
"""
|
|
131
|
+
start_time = time.monotonic()
|
|
132
|
+
execution_id = request.request_id
|
|
133
|
+
|
|
134
|
+
# ─── Step 1: Verify Authorization ─────────────────────────────────
|
|
135
|
+
try:
|
|
136
|
+
auth_result = await self._auth.verify(request.authorization)
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
logger.error("Auth verification crashed: %s", exc, exc_info=True)
|
|
139
|
+
await self._log_security_event(
|
|
140
|
+
SecurityEventType.INVALID_AUTH,
|
|
141
|
+
source_info=request.metadata.agent_id,
|
|
142
|
+
details=f"Auth verifier crashed: {type(exc).__name__}",
|
|
143
|
+
)
|
|
144
|
+
return self._fail(execution_id, "Authorization verification failed")
|
|
145
|
+
|
|
146
|
+
if not auth_result.valid:
|
|
147
|
+
logger.warning(
|
|
148
|
+
"Auth rejected: request=%s error=%s",
|
|
149
|
+
execution_id,
|
|
150
|
+
auth_result.error,
|
|
151
|
+
)
|
|
152
|
+
await self._log_security_event(
|
|
153
|
+
SecurityEventType.INVALID_AUTH,
|
|
154
|
+
source_info=request.metadata.agent_id,
|
|
155
|
+
details=auth_result.error or "Invalid authorization proof",
|
|
156
|
+
)
|
|
157
|
+
return self._fail(execution_id, "Authorization rejected")
|
|
158
|
+
|
|
159
|
+
# ─── Step 2: Route to Adapter ─────────────────────────────────────
|
|
160
|
+
try:
|
|
161
|
+
adapter = self._dispatcher.resolve(request.action_type)
|
|
162
|
+
except AdapterNotFoundError:
|
|
163
|
+
logger.warning("Unknown action: %s", request.action_type)
|
|
164
|
+
await self._log_security_event(
|
|
165
|
+
SecurityEventType.UNKNOWN_ACTION,
|
|
166
|
+
source_info=request.metadata.agent_id,
|
|
167
|
+
details=f"Unknown action type: {request.action_type}",
|
|
168
|
+
)
|
|
169
|
+
return self._fail(
|
|
170
|
+
execution_id,
|
|
171
|
+
f"Action '{request.action_type}' is not available.",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
adapter_id = adapter.manifest().adapter_id
|
|
175
|
+
|
|
176
|
+
# ─── Step 3: Log STARTED Event ────────────────────────────────────
|
|
177
|
+
params_hash = self._scrubber.hash_params(request.params)
|
|
178
|
+
start_entry = AuditEntry(
|
|
179
|
+
execution_id=execution_id,
|
|
180
|
+
intent_frame_id=request.metadata.intent_frame_id,
|
|
181
|
+
action_type=request.action_type,
|
|
182
|
+
adapter_id=adapter_id,
|
|
183
|
+
status=ExecutionStatus.STARTED,
|
|
184
|
+
params_hash=params_hash,
|
|
185
|
+
)
|
|
186
|
+
start_entry = self._hash_and_chain(start_entry)
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
await self._audit.log_event(start_entry)
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
# Fail-closed: if we can't audit, we can't execute
|
|
192
|
+
logger.error("Audit log_event(STARTED) failed: %s", exc, exc_info=True)
|
|
193
|
+
return self._fail(execution_id, "Audit logging failed -- execution blocked")
|
|
194
|
+
|
|
195
|
+
# ─── Step 4: Fetch Credentials (if needed) ────────────────────────
|
|
196
|
+
credentials: dict[str, Any] | None = None
|
|
197
|
+
if adapter.manifest().requires_credentials:
|
|
198
|
+
try:
|
|
199
|
+
credentials = await self._fetch_credentials(adapter_id)
|
|
200
|
+
except CredentialError:
|
|
201
|
+
logger.debug(
|
|
202
|
+
"Vault has no credentials for adapter '%s'; "
|
|
203
|
+
"adapter will use its own fallback resolution",
|
|
204
|
+
adapter_id,
|
|
205
|
+
)
|
|
206
|
+
credentials = None
|
|
207
|
+
|
|
208
|
+
# ─── Step 5: Execute via Worker Pool ──────────────────────────────
|
|
209
|
+
result = await self._pool.submit(
|
|
210
|
+
adapter=adapter,
|
|
211
|
+
action=request.action_type,
|
|
212
|
+
params=request.params,
|
|
213
|
+
credentials=credentials,
|
|
214
|
+
)
|
|
215
|
+
result.execution_id = execution_id
|
|
216
|
+
|
|
217
|
+
elapsed_ms = int((time.monotonic() - start_time) * 1000)
|
|
218
|
+
result.duration_ms = elapsed_ms
|
|
219
|
+
|
|
220
|
+
# ─── Step 6: Store Rollback (if available) ────────────────────────
|
|
221
|
+
if result.success and result.rollback_available and result.rollback_id:
|
|
222
|
+
try:
|
|
223
|
+
rollback_entry = RollbackEntry(
|
|
224
|
+
execution_id=execution_id,
|
|
225
|
+
rollback_id=result.rollback_id,
|
|
226
|
+
adapter_id=adapter_id,
|
|
227
|
+
rollback_data=self._scrubber.scrub(request.params),
|
|
228
|
+
)
|
|
229
|
+
await self._state.save_rollback(rollback_entry)
|
|
230
|
+
except Exception as exc:
|
|
231
|
+
# Rollback storage is best-effort -- don't fail the execution
|
|
232
|
+
logger.warning("Failed to store rollback: %s", exc)
|
|
233
|
+
result.rollback_available = False
|
|
234
|
+
result.rollback_id = None
|
|
235
|
+
|
|
236
|
+
# ─── Step 7: Log COMPLETED/FAILED Event ─────────────────────────
|
|
237
|
+
final_status = ExecutionStatus.COMPLETED if result.success else ExecutionStatus.FAILED
|
|
238
|
+
await self._log_completion(
|
|
239
|
+
execution_id, adapter_id, request.action_type,
|
|
240
|
+
request.metadata.intent_frame_id, params_hash,
|
|
241
|
+
final_status, result, start_time,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# ─── Step 8: Return Result ────────────────────────────────────────
|
|
245
|
+
logger.info(
|
|
246
|
+
"Execution complete: id=%s action=%s adapter=%s success=%s duration=%dms",
|
|
247
|
+
execution_id,
|
|
248
|
+
request.action_type,
|
|
249
|
+
adapter_id,
|
|
250
|
+
result.success,
|
|
251
|
+
elapsed_ms,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return result
|
|
255
|
+
|
|
256
|
+
async def handle_rollback(self, rollback_id: str) -> ExecutionResult:
|
|
257
|
+
"""Execute a rollback for a previous execution.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
rollback_id: The rollback ID from the original ExecutionResult.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
ExecutionResult indicating rollback success or failure.
|
|
264
|
+
"""
|
|
265
|
+
# Look up the rollback entry
|
|
266
|
+
entry = await self._state.get_rollback(rollback_id)
|
|
267
|
+
if entry is None:
|
|
268
|
+
logger.warning("Rollback not found or expired: %s", rollback_id)
|
|
269
|
+
return ExecutionResult(
|
|
270
|
+
success=False,
|
|
271
|
+
error="Unable to undo — the action may have expired.",
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
# Find the adapter
|
|
275
|
+
adapter = self._dispatcher.get_adapter(entry.adapter_id)
|
|
276
|
+
if adapter is None:
|
|
277
|
+
logger.error("Adapter not found for rollback: %s", entry.adapter_id)
|
|
278
|
+
return ExecutionResult(
|
|
279
|
+
success=False,
|
|
280
|
+
error="Unable to undo this action right now.",
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# Execute rollback
|
|
284
|
+
result = await adapter.safe_rollback(rollback_id)
|
|
285
|
+
|
|
286
|
+
# Update rollback status
|
|
287
|
+
from executor_sdk.models import RollbackStatus
|
|
288
|
+
|
|
289
|
+
new_status = RollbackStatus.EXECUTED if result.success else RollbackStatus.FAILED
|
|
290
|
+
try:
|
|
291
|
+
await self._state.update_rollback_status(rollback_id, new_status)
|
|
292
|
+
except Exception as exc:
|
|
293
|
+
logger.warning("Failed to update rollback status: %s", exc)
|
|
294
|
+
|
|
295
|
+
return result
|
|
296
|
+
|
|
297
|
+
# ── Private helpers ───────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
@staticmethod
|
|
300
|
+
def _fail(execution_id: str, error: str) -> ExecutionResult:
|
|
301
|
+
"""Construct a failure ExecutionResult."""
|
|
302
|
+
return ExecutionResult(
|
|
303
|
+
success=False,
|
|
304
|
+
error=error,
|
|
305
|
+
execution_id=execution_id,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
async def _fetch_credentials(self, adapter_id: str) -> dict[str, str]:
|
|
309
|
+
"""Fetch credentials for an adapter from the vault.
|
|
310
|
+
|
|
311
|
+
Returns a dict of credential key -> value pairs.
|
|
312
|
+
Raises CredentialError if any required credential is missing.
|
|
313
|
+
"""
|
|
314
|
+
# Convention: credentials are stored under service=adapter_id
|
|
315
|
+
# This is a simplified lookup; adapters can override with
|
|
316
|
+
# more specific credential requirements in their manifest.
|
|
317
|
+
api_key = await self._vault.get(adapter_id, "api_key")
|
|
318
|
+
if api_key is not None:
|
|
319
|
+
return {"api_key": api_key}
|
|
320
|
+
|
|
321
|
+
# If no api_key, try generic credential
|
|
322
|
+
credential = await self._vault.get(adapter_id, "credential")
|
|
323
|
+
if credential is not None:
|
|
324
|
+
return {"credential": credential}
|
|
325
|
+
|
|
326
|
+
raise CredentialError(
|
|
327
|
+
f"No credentials found for adapter: {adapter_id}",
|
|
328
|
+
details={"adapter_id": adapter_id},
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
def _hash_and_chain(self, entry: AuditEntry) -> AuditEntry:
|
|
332
|
+
"""Compute hash chain for an audit entry (all fields hashed).
|
|
333
|
+
|
|
334
|
+
Append-only design: every field is immutable after insertion,
|
|
335
|
+
so every field is included in the hash. No exclusion lists.
|
|
336
|
+
"""
|
|
337
|
+
entry_data = entry.model_dump(exclude={"entry_hash", "prev_hash"})
|
|
338
|
+
entry_hash, prev_hash = self._chain.append(entry_data)
|
|
339
|
+
entry.entry_hash = entry_hash
|
|
340
|
+
entry.prev_hash = prev_hash
|
|
341
|
+
return entry
|
|
342
|
+
|
|
343
|
+
async def _log_completion(
|
|
344
|
+
self,
|
|
345
|
+
execution_id: str,
|
|
346
|
+
adapter_id: str,
|
|
347
|
+
action_type: str,
|
|
348
|
+
intent_frame_id: str | None,
|
|
349
|
+
params_hash: str,
|
|
350
|
+
status: ExecutionStatus,
|
|
351
|
+
result: ExecutionResult | None,
|
|
352
|
+
start_time: float,
|
|
353
|
+
) -> None:
|
|
354
|
+
"""Log a COMPLETED/FAILED event. Best-effort -- logs but doesn't raise.
|
|
355
|
+
|
|
356
|
+
This INSERTs a new row (not an UPDATE). The audit log is append-only.
|
|
357
|
+
"""
|
|
358
|
+
elapsed_ms = int((time.monotonic() - start_time) * 1000)
|
|
359
|
+
safe_result = result or ExecutionResult(success=False, error="No result")
|
|
360
|
+
summary = "success" if safe_result.success else (safe_result.error or "failed")
|
|
361
|
+
|
|
362
|
+
complete_entry = AuditEntry(
|
|
363
|
+
execution_id=execution_id,
|
|
364
|
+
intent_frame_id=intent_frame_id,
|
|
365
|
+
action_type=action_type,
|
|
366
|
+
adapter_id=adapter_id,
|
|
367
|
+
status=status,
|
|
368
|
+
params_hash=params_hash,
|
|
369
|
+
result_summary=summary[:500],
|
|
370
|
+
error=safe_result.error,
|
|
371
|
+
duration_ms=elapsed_ms,
|
|
372
|
+
)
|
|
373
|
+
complete_entry = self._hash_and_chain(complete_entry)
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
await self._audit.log_event(complete_entry)
|
|
377
|
+
except Exception as exc:
|
|
378
|
+
logger.error(
|
|
379
|
+
"Failed to log completion: execution=%s error=%s",
|
|
380
|
+
execution_id,
|
|
381
|
+
exc,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
async def _log_security_event(
|
|
385
|
+
self,
|
|
386
|
+
event_type: SecurityEventType,
|
|
387
|
+
source_info: str = "",
|
|
388
|
+
details: str = "",
|
|
389
|
+
) -> None:
|
|
390
|
+
"""Log a security event. Best-effort -- logs but doesn't raise."""
|
|
391
|
+
try:
|
|
392
|
+
event = SecurityEvent(
|
|
393
|
+
event_type=event_type,
|
|
394
|
+
source_info=source_info,
|
|
395
|
+
details=details,
|
|
396
|
+
)
|
|
397
|
+
await self._audit.log_security_event(event)
|
|
398
|
+
except Exception as exc:
|
|
399
|
+
logger.error("Failed to log security event: %s", exc)
|
executor/main.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""
|
|
2
|
+
IntentFrame Executor -- config-driven entry point.
|
|
3
|
+
|
|
4
|
+
This is the main entry point for the executor service. It:
|
|
5
|
+
1. Loads and validates configuration from executor.yaml
|
|
6
|
+
2. Instantiates all components from config (transport, auth, adapters, services)
|
|
7
|
+
3. Wires everything into the ExecutorGateway
|
|
8
|
+
4. Starts the transport server with the gateway as the request handler
|
|
9
|
+
5. Runs until interrupted (SIGINT/SIGTERM)
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
python -m executor.main
|
|
13
|
+
python -m executor.main --config /path/to/executor.yaml
|
|
14
|
+
python -m executor.main --config /path/to/executor.yaml --log-level DEBUG
|
|
15
|
+
|
|
16
|
+
The entry point is deliberately thin. All logic lives in the gateway,
|
|
17
|
+
services, and adapters. This file is pure wiring.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import asyncio
|
|
24
|
+
import logging
|
|
25
|
+
import signal
|
|
26
|
+
import sys
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from executor_sdk.adapters import create_adapter
|
|
30
|
+
from executor_sdk.auth import create_auth_verifier
|
|
31
|
+
from executor.config import ExecutorConfig, load_config
|
|
32
|
+
from executor.dispatch import ActionDispatcher
|
|
33
|
+
from executor_sdk.exceptions import ConfigurationError, ExecutorError
|
|
34
|
+
from executor.gateway import ExecutorGateway
|
|
35
|
+
from executor_sdk.services.audit_logger import create_audit_logger
|
|
36
|
+
from executor_sdk.services.credential_scrubber import CredentialScrubber
|
|
37
|
+
from executor_sdk.services.credential_vault import create_credential_vault
|
|
38
|
+
from executor_sdk.services.hash_chain import HashChain
|
|
39
|
+
from executor_sdk.services.state_store import create_state_store
|
|
40
|
+
from executor_sdk.transport import create_transport
|
|
41
|
+
from executor.worker_pool import WorkerPool
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger("executor")
|
|
44
|
+
|
|
45
|
+
__all__ = ["build_gateway", "run"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
49
|
+
# Component Assembly
|
|
50
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_gateway(
|
|
54
|
+
config: ExecutorConfig,
|
|
55
|
+
service_overrides: dict[str, Any] | None = None,
|
|
56
|
+
) -> tuple[ExecutorGateway, Any, Any]:
|
|
57
|
+
"""Assemble all executor components from configuration.
|
|
58
|
+
|
|
59
|
+
Creates and wires: auth verifier, dispatcher (with adapters),
|
|
60
|
+
worker pool, and all services into an ExecutorGateway.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
config: Validated executor configuration.
|
|
64
|
+
service_overrides: Optional dict of service instances to inject
|
|
65
|
+
instead of creating from config. Keys are service
|
|
66
|
+
names: "audit_logger", "credential_vault",
|
|
67
|
+
"state_store". Useful for testing.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Tuple of (gateway, transport_server, credential_vault) for the
|
|
71
|
+
caller to manage lifecycle.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
ConfigurationError: If any component cannot be instantiated.
|
|
75
|
+
"""
|
|
76
|
+
overrides = service_overrides or {}
|
|
77
|
+
|
|
78
|
+
# ── Auth Verifier ─────────────────────────────────────────────────────
|
|
79
|
+
auth_verifier = create_auth_verifier(config.auth)
|
|
80
|
+
logger.info("Auth verifier: type=%s", config.auth.type)
|
|
81
|
+
|
|
82
|
+
# ── Credential Vault ──────────────────────────────────────────────────
|
|
83
|
+
credential_vault = overrides.get("credential_vault") or create_credential_vault(
|
|
84
|
+
config.credentials
|
|
85
|
+
)
|
|
86
|
+
logger.info("Credential vault: backend=%s", config.credentials.backend)
|
|
87
|
+
|
|
88
|
+
# ── Audit Logger ──────────────────────────────────────────────────────
|
|
89
|
+
audit_logger = overrides.get("audit_logger") or create_audit_logger(config.storage)
|
|
90
|
+
logger.info("Audit logger: backend=%s", config.storage.audit_backend)
|
|
91
|
+
|
|
92
|
+
# ── State Store ───────────────────────────────────────────────────────
|
|
93
|
+
state_store = overrides.get("state_store") or create_state_store(config.storage)
|
|
94
|
+
logger.info("State store: backend=%s", config.storage.state_backend)
|
|
95
|
+
|
|
96
|
+
# ── Concrete Services (platform-agnostic) ─────────────────────────────
|
|
97
|
+
scrubber = CredentialScrubber()
|
|
98
|
+
hash_chain = HashChain()
|
|
99
|
+
|
|
100
|
+
# ── Worker Pool ───────────────────────────────────────────────────────
|
|
101
|
+
worker_pool = WorkerPool(
|
|
102
|
+
max_workers=config.worker_pool.max_workers,
|
|
103
|
+
default_timeout=config.worker_pool.default_timeout_seconds,
|
|
104
|
+
)
|
|
105
|
+
logger.info(
|
|
106
|
+
"Worker pool: max_workers=%d timeout=%.1fs",
|
|
107
|
+
config.worker_pool.max_workers,
|
|
108
|
+
config.worker_pool.default_timeout_seconds,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# ── Adapters ──────────────────────────────────────────────────────────
|
|
112
|
+
# All possible adapter dependencies -- each adapter takes what it needs.
|
|
113
|
+
# VFS mount resolution is owned by the files adapter via pack_options.files.
|
|
114
|
+
adapter_deps: dict[str, Any] = {
|
|
115
|
+
"credential_vault": credential_vault,
|
|
116
|
+
"pack_options": config.pack_options,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
dispatcher = ActionDispatcher()
|
|
120
|
+
for adapter_id in config.adapters.enabled:
|
|
121
|
+
try:
|
|
122
|
+
adapter = create_adapter(adapter_id, **adapter_deps)
|
|
123
|
+
dispatcher.register(adapter)
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
raise ConfigurationError(
|
|
126
|
+
f"Failed to create adapter '{adapter_id}': {exc}",
|
|
127
|
+
details={"adapter_id": adapter_id},
|
|
128
|
+
) from exc
|
|
129
|
+
|
|
130
|
+
logger.info(
|
|
131
|
+
"Adapters loaded: %d adapters, %d actions",
|
|
132
|
+
len(dispatcher.registered_adapters),
|
|
133
|
+
len(dispatcher.registered_actions),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# ── Transport ─────────────────────────────────────────────────────────
|
|
137
|
+
transport = create_transport(config.transport)
|
|
138
|
+
logger.info("Transport: type=%s", config.transport.type)
|
|
139
|
+
|
|
140
|
+
# ── Assemble Gateway ──────────────────────────────────────────────────
|
|
141
|
+
gateway = ExecutorGateway(
|
|
142
|
+
auth_verifier=auth_verifier,
|
|
143
|
+
dispatcher=dispatcher,
|
|
144
|
+
worker_pool=worker_pool,
|
|
145
|
+
audit_logger=audit_logger,
|
|
146
|
+
credential_vault=credential_vault,
|
|
147
|
+
state_store=state_store,
|
|
148
|
+
scrubber=scrubber,
|
|
149
|
+
hash_chain=hash_chain,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return gateway, transport, worker_pool
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
156
|
+
# Logging Setup
|
|
157
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _setup_logging(level: str, fmt: str) -> None:
|
|
161
|
+
"""Configure logging based on executor config."""
|
|
162
|
+
log_level = getattr(logging, level.upper(), logging.INFO)
|
|
163
|
+
|
|
164
|
+
if fmt == "json":
|
|
165
|
+
# Structured JSON logging (production)
|
|
166
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
167
|
+
formatter = logging.Formatter(
|
|
168
|
+
'{"timestamp":"%(asctime)s","level":"%(levelname)s",'
|
|
169
|
+
'"logger":"%(name)s","message":"%(message)s"}'
|
|
170
|
+
)
|
|
171
|
+
handler.setFormatter(formatter)
|
|
172
|
+
else:
|
|
173
|
+
# Human-readable console logging (development)
|
|
174
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
175
|
+
formatter = logging.Formatter(
|
|
176
|
+
"%(asctime)s [%(levelname)-8s] %(name)s: %(message)s",
|
|
177
|
+
datefmt="%H:%M:%S",
|
|
178
|
+
)
|
|
179
|
+
handler.setFormatter(formatter)
|
|
180
|
+
|
|
181
|
+
root = logging.getLogger("executor")
|
|
182
|
+
root.handlers.clear()
|
|
183
|
+
root.addHandler(handler)
|
|
184
|
+
root.setLevel(log_level)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
188
|
+
# Entry Point
|
|
189
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def run(config: ExecutorConfig) -> None:
|
|
193
|
+
"""Start the executor service and run until interrupted.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
config: Validated executor configuration.
|
|
197
|
+
"""
|
|
198
|
+
_setup_logging(config.logging.level, config.logging.format)
|
|
199
|
+
|
|
200
|
+
logger.info("IntentFrame Executor starting...")
|
|
201
|
+
logger.info("Config: transport=%s auth=%s", config.transport.type, config.auth.type)
|
|
202
|
+
|
|
203
|
+
# Platform-specific startup checks (e.g. macOS TCC permissions) are owned by
|
|
204
|
+
# the relevant executor pack and run when its adapters are constructed in
|
|
205
|
+
# build_gateway() -- core executor stays deployment-agnostic.
|
|
206
|
+
gateway, transport, worker_pool = build_gateway(config)
|
|
207
|
+
|
|
208
|
+
# Handle shutdown signals
|
|
209
|
+
shutdown_event = asyncio.Event()
|
|
210
|
+
|
|
211
|
+
def _signal_handler() -> None:
|
|
212
|
+
logger.info("Shutdown signal received")
|
|
213
|
+
shutdown_event.set()
|
|
214
|
+
|
|
215
|
+
loop = asyncio.get_running_loop()
|
|
216
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
217
|
+
loop.add_signal_handler(sig, _signal_handler)
|
|
218
|
+
|
|
219
|
+
# Start transport (runs until shutdown)
|
|
220
|
+
logger.info("Executor ready. Listening for requests...")
|
|
221
|
+
|
|
222
|
+
transport_task = asyncio.create_task(transport.start(gateway.handle))
|
|
223
|
+
shutdown_task = asyncio.create_task(shutdown_event.wait())
|
|
224
|
+
|
|
225
|
+
# Wait for either transport to finish or shutdown signal
|
|
226
|
+
done, pending = await asyncio.wait(
|
|
227
|
+
{transport_task, shutdown_task},
|
|
228
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# Graceful shutdown
|
|
232
|
+
logger.info("Shutting down...")
|
|
233
|
+
|
|
234
|
+
await transport.stop()
|
|
235
|
+
await worker_pool.shutdown()
|
|
236
|
+
gateway.close()
|
|
237
|
+
|
|
238
|
+
for task in pending:
|
|
239
|
+
task.cancel()
|
|
240
|
+
|
|
241
|
+
logger.info("IntentFrame Executor stopped.")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def main() -> None:
|
|
245
|
+
"""CLI entry point."""
|
|
246
|
+
parser = argparse.ArgumentParser(
|
|
247
|
+
description="IntentFrame Executor -- Protocol-Driven Capability Service",
|
|
248
|
+
)
|
|
249
|
+
parser.add_argument(
|
|
250
|
+
"--config",
|
|
251
|
+
type=str,
|
|
252
|
+
default=None,
|
|
253
|
+
help="Path to executor.yaml configuration file",
|
|
254
|
+
)
|
|
255
|
+
parser.add_argument(
|
|
256
|
+
"--log-level",
|
|
257
|
+
type=str,
|
|
258
|
+
default=None,
|
|
259
|
+
help="Override log level (DEBUG, INFO, WARNING, ERROR)",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
args = parser.parse_args()
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
config = load_config(config_path=args.config)
|
|
266
|
+
except ConfigurationError as exc:
|
|
267
|
+
print(f"Configuration error: {exc}", file=sys.stderr)
|
|
268
|
+
sys.exit(1)
|
|
269
|
+
|
|
270
|
+
# CLI overrides
|
|
271
|
+
if args.log_level:
|
|
272
|
+
config.logging.level = args.log_level
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
asyncio.run(run(config))
|
|
276
|
+
except ExecutorError as exc:
|
|
277
|
+
print(f"Executor error: {exc}", file=sys.stderr)
|
|
278
|
+
sys.exit(1)
|
|
279
|
+
except KeyboardInterrupt:
|
|
280
|
+
pass
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
if __name__ == "__main__":
|
|
284
|
+
main()
|