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/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()