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/server.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Executor -- FastAPI server on Unix Domain Socket.
|
|
3
|
+
|
|
4
|
+
Wraps the ExecutorGateway with HTTP endpoints, consistent with
|
|
5
|
+
the other IntentFrame services. Replaces the raw length-prefixed
|
|
6
|
+
Unix socket transport with standard HTTP.
|
|
7
|
+
|
|
8
|
+
Endpoints:
|
|
9
|
+
POST /execute -- gateway.handle(request)
|
|
10
|
+
POST /rollback -- gateway.handle_rollback(rollback_id)
|
|
11
|
+
GET /health -- health check
|
|
12
|
+
|
|
13
|
+
Startup:
|
|
14
|
+
uvicorn executor.server:app --uds ~/.intentframe/run/executor.sock
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
from contextlib import asynccontextmanager
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from fastapi import FastAPI, HTTPException
|
|
25
|
+
from pydantic import BaseModel
|
|
26
|
+
|
|
27
|
+
from executor.config import load_config
|
|
28
|
+
from executor_sdk.exceptions import ConfigurationError
|
|
29
|
+
from executor_sdk.packs import ENTRY_POINT_GROUP
|
|
30
|
+
from executor.main import build_gateway
|
|
31
|
+
from executor_sdk.models import (
|
|
32
|
+
ExecutionRequest,
|
|
33
|
+
ExecutionResult,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
_gateway = None
|
|
39
|
+
_worker_pool = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _load_pack(ref: str) -> None:
|
|
43
|
+
"""Load one executor pack by entry-point name or importable module path.
|
|
44
|
+
|
|
45
|
+
A pack is anything exposing a module-level ``register_all()`` (see
|
|
46
|
+
``executor_sdk.packs``). There are no built-in or platform-default packs;
|
|
47
|
+
deployments wire packs purely through ``packs:`` in executor.yaml.
|
|
48
|
+
|
|
49
|
+
Resolution order for each ``ref``:
|
|
50
|
+
1. A distribution advertising ``ref`` under the
|
|
51
|
+
``intentframe.executor_packs`` entry-point group (third-party packs
|
|
52
|
+
referenced by their short name).
|
|
53
|
+
2. An importable module path exposing ``register_all()`` (first-party
|
|
54
|
+
packs and anything referenced by full dotted module path).
|
|
55
|
+
"""
|
|
56
|
+
from importlib import import_module
|
|
57
|
+
from importlib.metadata import entry_points
|
|
58
|
+
|
|
59
|
+
by_name = {ep.name: ep for ep in entry_points(group=ENTRY_POINT_GROUP)}
|
|
60
|
+
if ref in by_name:
|
|
61
|
+
register = by_name[ref].load()
|
|
62
|
+
else:
|
|
63
|
+
module = import_module(ref)
|
|
64
|
+
register = getattr(module, "register_all", None)
|
|
65
|
+
if register is None:
|
|
66
|
+
raise ConfigurationError(
|
|
67
|
+
f"Executor pack '{ref}' has no register_all() entry point and "
|
|
68
|
+
f"is not advertised under the '{ENTRY_POINT_GROUP}' group.",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
register()
|
|
72
|
+
logger.info("Executor pack registered: %s", ref)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _register_packs(config) -> None:
|
|
76
|
+
"""Load every configured executor pack once, in order (fail-closed).
|
|
77
|
+
|
|
78
|
+
Packs are entirely config-driven: ``executor.yaml`` must list the packs it
|
|
79
|
+
needs under ``packs:``. Nothing is loaded implicitly.
|
|
80
|
+
"""
|
|
81
|
+
refs = list(config.packs)
|
|
82
|
+
if not refs:
|
|
83
|
+
raise ConfigurationError(
|
|
84
|
+
"No executor packs configured. Set `packs:` in executor.yaml, e.g.\n"
|
|
85
|
+
" packs:\n"
|
|
86
|
+
" - intentframe_native_kit.intentframe_executor_pack_posix # portable base\n"
|
|
87
|
+
" - intentframe_native_kit.intentframe_executor_pack_console # console / simulated user_io\n"
|
|
88
|
+
"(or a pack advertised under the "
|
|
89
|
+
f"'{ENTRY_POINT_GROUP}' entry-point group).",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
for ref in refs:
|
|
93
|
+
try:
|
|
94
|
+
_load_pack(ref)
|
|
95
|
+
except ConfigurationError:
|
|
96
|
+
raise
|
|
97
|
+
except Exception as exc:
|
|
98
|
+
raise ConfigurationError(
|
|
99
|
+
f"Failed to load executor pack '{ref}': {exc}",
|
|
100
|
+
) from exc
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@asynccontextmanager
|
|
104
|
+
async def lifespan(app: FastAPI):
|
|
105
|
+
global _gateway, _worker_pool
|
|
106
|
+
config = load_config(config_path=os.environ.get("EXECUTOR_CONFIG"))
|
|
107
|
+
_register_packs(config)
|
|
108
|
+
|
|
109
|
+
# Platform-specific startup checks (e.g. macOS TCC permissions) are owned by
|
|
110
|
+
# the relevant executor pack and run when its adapters are constructed in
|
|
111
|
+
# build_gateway() -- core executor stays deployment-agnostic.
|
|
112
|
+
_gateway, _transport, _worker_pool = build_gateway(config)
|
|
113
|
+
logger.info("Executor gateway ready")
|
|
114
|
+
yield
|
|
115
|
+
if _worker_pool:
|
|
116
|
+
await _worker_pool.shutdown()
|
|
117
|
+
if _gateway:
|
|
118
|
+
_gateway.close()
|
|
119
|
+
logger.info("Executor gateway shut down")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
app = FastAPI(
|
|
123
|
+
title="IntentFrame Executor",
|
|
124
|
+
version="0.1.0",
|
|
125
|
+
lifespan=lifespan,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class HealthResponse(BaseModel):
|
|
130
|
+
status: str = "ok"
|
|
131
|
+
service: str = "executor"
|
|
132
|
+
uid: int = 0
|
|
133
|
+
euid: int = 0
|
|
134
|
+
running_as_root: bool = False
|
|
135
|
+
pid: int = 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class RollbackRequest(BaseModel):
|
|
139
|
+
rollback_id: str
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@app.get("/health", response_model=HealthResponse)
|
|
143
|
+
async def health() -> HealthResponse:
|
|
144
|
+
euid = os.geteuid()
|
|
145
|
+
# ``running_as_root`` reflects machine-level root capability for
|
|
146
|
+
# RUN_COMMAND, not the executor process's own privilege. Two paths
|
|
147
|
+
# to ``True``:
|
|
148
|
+
# 1. The executor actually runs as UID 0 (rare -- only if the
|
|
149
|
+
# whole stack was launched with sudo).
|
|
150
|
+
# 2. The gateway advertised ``INTENTFRAME_ESCALATION_ARMED=1`` in
|
|
151
|
+
# our env at spawn time, meaning ``/etc/sudoers.d/intentframe-run``
|
|
152
|
+
# is installed and the sandbox engine is allowed to wrap
|
|
153
|
+
# sandbox-exec with ``sudo -n``.
|
|
154
|
+
# No runtime probe, no YAML coupling -- the YAML only decides
|
|
155
|
+
# whether each individual command opts into the escalation.
|
|
156
|
+
env_armed = os.environ.get("INTENTFRAME_ESCALATION_ARMED") == "1"
|
|
157
|
+
return HealthResponse(
|
|
158
|
+
uid=os.getuid(),
|
|
159
|
+
euid=euid,
|
|
160
|
+
running_as_root=(euid == 0) or env_armed,
|
|
161
|
+
pid=os.getpid(),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
@app.post("/execute", response_model=ExecutionResult)
|
|
165
|
+
async def execute(request: ExecutionRequest) -> ExecutionResult:
|
|
166
|
+
if _gateway is None:
|
|
167
|
+
raise HTTPException(status_code=503, detail="Gateway not initialized")
|
|
168
|
+
return await _gateway.handle(request)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@app.post("/rollback", response_model=ExecutionResult)
|
|
172
|
+
async def rollback(req: RollbackRequest) -> ExecutionResult:
|
|
173
|
+
if _gateway is None:
|
|
174
|
+
raise HTTPException(status_code=503, detail="Gateway not initialized")
|
|
175
|
+
return await _gateway.handle_rollback(req.rollback_id)
|
executor/worker_pool.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Worker pool -- manages concurrent adapter execution with limits and timeouts.
|
|
3
|
+
|
|
4
|
+
The worker pool enforces:
|
|
5
|
+
- Concurrency limits (semaphore-based, configurable max_workers)
|
|
6
|
+
- Timeout enforcement (via adapter's safe_execute, which uses asyncio.wait_for)
|
|
7
|
+
- Clean shutdown (drain in-flight work before stopping)
|
|
8
|
+
|
|
9
|
+
Design:
|
|
10
|
+
The skeleton uses asyncio.Semaphore for concurrency control.
|
|
11
|
+
All adapters run in the gateway's event loop via async execution.
|
|
12
|
+
|
|
13
|
+
Future enhancement (platform-specific):
|
|
14
|
+
ProcessPoolExecutor for crash isolation -- if an adapter crashes
|
|
15
|
+
the worker process, the gateway survives. This requires running
|
|
16
|
+
a new event loop per worker process and marshalling results back.
|
|
17
|
+
The interface stays the same; only the internal execution changes.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import logging
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
from executor_sdk.adapters.base import CapabilityAdapter
|
|
27
|
+
from executor_sdk.constants import DEFAULT_ADAPTER_TIMEOUT, DEFAULT_MAX_WORKERS
|
|
28
|
+
from executor_sdk.models import ExecutionResult
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
__all__ = ["WorkerPool"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class WorkerPool:
|
|
36
|
+
"""Manages concurrent adapter execution with limits and timeouts.
|
|
37
|
+
|
|
38
|
+
Usage:
|
|
39
|
+
pool = WorkerPool(max_workers=4, default_timeout=30.0)
|
|
40
|
+
result = await pool.submit(adapter, "SEND_EMAIL", params, credentials)
|
|
41
|
+
# result is always an ExecutionResult (never raises)
|
|
42
|
+
|
|
43
|
+
await pool.shutdown() # drain and stop
|
|
44
|
+
|
|
45
|
+
Guarantees:
|
|
46
|
+
- At most max_workers adapters execute concurrently.
|
|
47
|
+
- Each execution respects its timeout (via safe_execute).
|
|
48
|
+
- submit() always returns ExecutionResult (never raises).
|
|
49
|
+
- shutdown() waits for in-flight work to complete.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
max_workers: int = DEFAULT_MAX_WORKERS,
|
|
55
|
+
default_timeout: float = DEFAULT_ADAPTER_TIMEOUT,
|
|
56
|
+
) -> None:
|
|
57
|
+
self._max_workers = max_workers
|
|
58
|
+
self._default_timeout = default_timeout
|
|
59
|
+
self._semaphore = asyncio.Semaphore(max_workers)
|
|
60
|
+
self._active_count = 0
|
|
61
|
+
self._shutdown_event = asyncio.Event()
|
|
62
|
+
self._is_shutdown = False
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def max_workers(self) -> int:
|
|
66
|
+
"""Maximum number of concurrent workers."""
|
|
67
|
+
return self._max_workers
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def active_count(self) -> int:
|
|
71
|
+
"""Number of currently executing workers."""
|
|
72
|
+
return self._active_count
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def is_shutdown(self) -> bool:
|
|
76
|
+
"""Whether the pool has been shut down."""
|
|
77
|
+
return self._is_shutdown
|
|
78
|
+
|
|
79
|
+
async def submit(
|
|
80
|
+
self,
|
|
81
|
+
adapter: CapabilityAdapter,
|
|
82
|
+
action: str,
|
|
83
|
+
params: dict,
|
|
84
|
+
credentials: dict | None = None,
|
|
85
|
+
timeout: float | None = None,
|
|
86
|
+
) -> ExecutionResult:
|
|
87
|
+
"""Submit an action for execution with concurrency control.
|
|
88
|
+
|
|
89
|
+
Blocks (via semaphore) if max_workers are already executing.
|
|
90
|
+
Delegates to adapter.safe_execute() which handles timeout and
|
|
91
|
+
exception catching.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
adapter: The capability adapter to execute.
|
|
95
|
+
action: The action type (e.g., "SEND_EMAIL").
|
|
96
|
+
params: Action-specific parameters.
|
|
97
|
+
credentials: Credentials from vault (if needed).
|
|
98
|
+
timeout: Override timeout for this execution. Uses default if None.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
ExecutionResult -- always, even on failure or pool shutdown.
|
|
102
|
+
"""
|
|
103
|
+
if self._is_shutdown:
|
|
104
|
+
return ExecutionResult(
|
|
105
|
+
success=False,
|
|
106
|
+
error="Worker pool is shut down",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
effective_timeout = timeout or self._default_timeout
|
|
110
|
+
adapter_id = adapter.manifest().adapter_id
|
|
111
|
+
|
|
112
|
+
async with self._semaphore:
|
|
113
|
+
self._active_count += 1
|
|
114
|
+
start_time = time.monotonic()
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
logger.debug(
|
|
118
|
+
"Worker starting: adapter=%s action=%s active=%d/%d",
|
|
119
|
+
adapter_id,
|
|
120
|
+
action,
|
|
121
|
+
self._active_count,
|
|
122
|
+
self._max_workers,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
result = await adapter.safe_execute(
|
|
126
|
+
action=action,
|
|
127
|
+
params=params,
|
|
128
|
+
credentials=credentials,
|
|
129
|
+
timeout=effective_timeout,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
elapsed_ms = int((time.monotonic() - start_time) * 1000)
|
|
133
|
+
result.duration_ms = elapsed_ms
|
|
134
|
+
|
|
135
|
+
logger.debug(
|
|
136
|
+
"Worker completed: adapter=%s action=%s success=%s duration=%dms",
|
|
137
|
+
adapter_id,
|
|
138
|
+
action,
|
|
139
|
+
result.success,
|
|
140
|
+
elapsed_ms,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
finally:
|
|
146
|
+
self._active_count -= 1
|
|
147
|
+
if self._active_count == 0 and self._is_shutdown:
|
|
148
|
+
self._shutdown_event.set()
|
|
149
|
+
|
|
150
|
+
async def shutdown(self, timeout: float = 10.0) -> None:
|
|
151
|
+
"""Gracefully shut down the worker pool.
|
|
152
|
+
|
|
153
|
+
Stops accepting new work and waits for in-flight executions
|
|
154
|
+
to complete (up to timeout seconds).
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
timeout: Maximum seconds to wait for in-flight work.
|
|
158
|
+
"""
|
|
159
|
+
self._is_shutdown = True
|
|
160
|
+
|
|
161
|
+
if self._active_count > 0:
|
|
162
|
+
logger.info(
|
|
163
|
+
"Worker pool shutting down: waiting for %d active workers",
|
|
164
|
+
self._active_count,
|
|
165
|
+
)
|
|
166
|
+
try:
|
|
167
|
+
await asyncio.wait_for(self._shutdown_event.wait(), timeout=timeout)
|
|
168
|
+
except asyncio.TimeoutError:
|
|
169
|
+
logger.warning(
|
|
170
|
+
"Worker pool shutdown timed out with %d active workers",
|
|
171
|
+
self._active_count,
|
|
172
|
+
)
|
|
173
|
+
else:
|
|
174
|
+
logger.info("Worker pool shut down (no active workers)")
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: intentframe-executor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: IntentFrame executor service — dispatches approved actions to capability adapters
|
|
5
|
+
Project-URL: Homepage, https://github.com/intentframe/intentframe
|
|
6
|
+
Project-URL: Repository, https://github.com/intentframe/intentframe
|
|
7
|
+
Project-URL: Issues, https://github.com/intentframe/intentframe/issues
|
|
8
|
+
Author: IntentFrame Contributors
|
|
9
|
+
License-Expression: AGPL-3.0-only
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,ai,executor,intentframe,security
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Security
|
|
16
|
+
Requires-Python: >=3.14
|
|
17
|
+
Requires-Dist: fastapi
|
|
18
|
+
Requires-Dist: intentframe-executor-sdk==0.1.0
|
|
19
|
+
Requires-Dist: pydantic>=2.12.5
|
|
20
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
21
|
+
Requires-Dist: uvicorn[standard]
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# intentframe-executor
|
|
25
|
+
|
|
26
|
+
The executor service for [IntentFrame](https://github.com/intentframe/intentframe).
|
|
27
|
+
It receives approved actions from the runtime, dispatches them to capability
|
|
28
|
+
adapters via a worker pool, and enforces the credential/audit boundary. It runs
|
|
29
|
+
as a FastAPI app on a Unix domain socket (`executor.server:app`).
|
|
30
|
+
|
|
31
|
+
Capability adapters are loaded as executor packs through the
|
|
32
|
+
`intentframe.executor_packs` entry-point group, so deployments select packs
|
|
33
|
+
without modifying this service. Built on `intentframe-executor-sdk`.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install intentframe-executor
|
|
37
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
executor/__init__.py,sha256=uAEz0NxFkmWHWps5I5uP5vwgh5w592vBX91pNTGJ9Hw,1030
|
|
2
|
+
executor/dispatch.py,sha256=M1qnrZUlKB1JdnNTzII0IZXLU4KZScmjJxg9VPAxWSw,4296
|
|
3
|
+
executor/gateway.py,sha256=ygNjpJBX0hZlt2NepIJ6o2W3MEPbNT-KKPrKwwg90Us,15534
|
|
4
|
+
executor/main.py,sha256=hhSZ-ShUofmrSO3XxGXEBpXnDJoGe18zxy0_SGKmy3s,11747
|
|
5
|
+
executor/server.py,sha256=8-MBQb4h6er-_msvZ5wGvys-hqmqdqpgwS36EHxpsCM,5936
|
|
6
|
+
executor/worker_pool.py,sha256=jkZRtOEeJykGeIUvTqE7woGkyaRWnYkm8iT5D_CIQL0,5887
|
|
7
|
+
executor/config/__init__.py,sha256=SHtnvvzfB9TJPURmjDdNR7APwZ473Y3U4_7_W7ZXBo0,3211
|
|
8
|
+
executor/config/executor.yaml,sha256=wjWN-Ary5fw3m8XdUF2U-gl_DZ65RlfYKp0gQPqN6H0,7411
|
|
9
|
+
executor/config/schema.py,sha256=M1Hzyn418qiFV4YbBAx_m7VzWhFX5tvzcv34soyfSlQ,7047
|
|
10
|
+
intentframe_executor-0.1.0.dist-info/METADATA,sha256=bjKwT3xdw--KPFUR9rkvq3185XnlJZg_RUcbTwdJHsg,1480
|
|
11
|
+
intentframe_executor-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
12
|
+
intentframe_executor-0.1.0.dist-info/licenses/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
|
|
13
|
+
intentframe_executor-0.1.0.dist-info/RECORD,,
|