hof-engine 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.
- hof/__init__.py +35 -0
- hof/api/__init__.py +1 -0
- hof/api/auth.py +228 -0
- hof/api/routes/__init__.py +1 -0
- hof/api/routes/admin.py +89 -0
- hof/api/routes/flows.py +100 -0
- hof/api/routes/functions.py +98 -0
- hof/api/routes/tables.py +151 -0
- hof/api/routes/ws.py +77 -0
- hof/api/server.py +280 -0
- hof/app.py +180 -0
- hof/cli/__init__.py +1 -0
- hof/cli/api_client.py +86 -0
- hof/cli/commands/__init__.py +27 -0
- hof/cli/commands/add.py +456 -0
- hof/cli/commands/build.py +31 -0
- hof/cli/commands/cron_cmd.py +87 -0
- hof/cli/commands/db.py +119 -0
- hof/cli/commands/dev.py +287 -0
- hof/cli/commands/flow.py +171 -0
- hof/cli/commands/fn.py +136 -0
- hof/cli/commands/new.py +383 -0
- hof/cli/commands/table.py +140 -0
- hof/cli/main.py +44 -0
- hof/config.py +191 -0
- hof/core/__init__.py +1 -0
- hof/core/discovery.py +56 -0
- hof/core/registry.py +102 -0
- hof/core/types.py +45 -0
- hof/cron/__init__.py +1 -0
- hof/cron/scheduler.py +103 -0
- hof/db/__init__.py +1 -0
- hof/db/engine.py +148 -0
- hof/db/migrations.py +154 -0
- hof/db/schemas.py +114 -0
- hof/db/table.py +359 -0
- hof/errors.py +47 -0
- hof/files/__init__.py +1 -0
- hof/files/processors.py +59 -0
- hof/files/storage.py +50 -0
- hof/flows/__init__.py +1 -0
- hof/flows/executor.py +418 -0
- hof/flows/flow.py +211 -0
- hof/flows/human.py +60 -0
- hof/flows/models.py +61 -0
- hof/flows/node.py +140 -0
- hof/flows/state.py +297 -0
- hof/functions.py +166 -0
- hof/llm/__init__.py +6 -0
- hof/llm/decorators.py +57 -0
- hof/llm/provider.py +67 -0
- hof/logging_config.py +120 -0
- hof/scaffold.py +12 -0
- hof/tasks/__init__.py +1 -0
- hof/tasks/celery_app.py +137 -0
- hof/tasks/worker.py +48 -0
- hof/ui/__init__.py +1 -0
- hof/ui/admin/.gitignore +2 -0
- hof/ui/admin/dist/assets/index-C1dqIj3T.css +1 -0
- hof/ui/admin/dist/assets/index-D3orQUqG.js +82 -0
- hof/ui/admin/dist/index.html +13 -0
- hof/ui/admin/index.html +12 -0
- hof/ui/admin/package-lock.json +2108 -0
- hof/ui/admin/package.json +24 -0
- hof/ui/admin/src/App.tsx +156 -0
- hof/ui/admin/src/api.ts +162 -0
- hof/ui/admin/src/components/FlowGraph.tsx +116 -0
- hof/ui/admin/src/components/Sidebar.tsx +53 -0
- hof/ui/admin/src/components/UserComponent.tsx +74 -0
- hof/ui/admin/src/main.tsx +13 -0
- hof/ui/admin/src/pages/Dashboard.tsx +95 -0
- hof/ui/admin/src/pages/FlowList.tsx +54 -0
- hof/ui/admin/src/pages/FlowViewer.tsx +131 -0
- hof/ui/admin/src/pages/FunctionList.tsx +61 -0
- hof/ui/admin/src/pages/PendingActions.tsx +154 -0
- hof/ui/admin/src/pages/TableBrowser.tsx +102 -0
- hof/ui/admin/src/pages/TaskList.tsx +91 -0
- hof/ui/admin/src/styles.css +367 -0
- hof/ui/admin/tsconfig.json +17 -0
- hof/ui/admin/vite.config.ts +17 -0
- hof/ui/vite.py +461 -0
- hof-react/dist/index.cjs +231 -0
- hof-react/dist/index.d.cts +71 -0
- hof-react/dist/index.d.ts +71 -0
- hof-react/dist/index.js +201 -0
- hof-react/package.json +33 -0
- hof_engine-0.1.0.dist-info/METADATA +200 -0
- hof_engine-0.1.0.dist-info/RECORD +90 -0
- hof_engine-0.1.0.dist-info/WHEEL +4 -0
- hof_engine-0.1.0.dist-info/entry_points.txt +2 -0
hof/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""hof-engine: Full-stack Python + React framework."""
|
|
2
|
+
|
|
3
|
+
from hof.app import HofApp
|
|
4
|
+
from hof.config import Config
|
|
5
|
+
from hof.core.registry import registry
|
|
6
|
+
from hof.core.types import types
|
|
7
|
+
from hof.cron.scheduler import cron
|
|
8
|
+
from hof.db.table import Column, ForeignKey, Table
|
|
9
|
+
from hof.errors import HofError
|
|
10
|
+
from hof.flows.flow import Flow
|
|
11
|
+
from hof.flows.human import human_node
|
|
12
|
+
from hof.flows.node import node
|
|
13
|
+
from hof.functions import function
|
|
14
|
+
from hof.logging_config import configure_logging
|
|
15
|
+
from hof.scaffold import get_project_files
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"HofApp",
|
|
21
|
+
"Config",
|
|
22
|
+
"configure_logging",
|
|
23
|
+
"Table",
|
|
24
|
+
"Column",
|
|
25
|
+
"ForeignKey",
|
|
26
|
+
"Flow",
|
|
27
|
+
"node",
|
|
28
|
+
"human_node",
|
|
29
|
+
"function",
|
|
30
|
+
"cron",
|
|
31
|
+
"types",
|
|
32
|
+
"registry",
|
|
33
|
+
"HofError",
|
|
34
|
+
"get_project_files",
|
|
35
|
+
]
|
hof/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""API layer: FastAPI server and auto-generated routes."""
|
hof/api/auth.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Authentication and role-based access control for the hof API.
|
|
2
|
+
|
|
3
|
+
Supported authentication methods (tried in order):
|
|
4
|
+
1. Bearer JWT token — ``Authorization: Bearer <token>``
|
|
5
|
+
2. API key header — ``X-API-Key: <key>``
|
|
6
|
+
3. HTTP Basic auth — username / password
|
|
7
|
+
|
|
8
|
+
JWT tokens are issued via ``POST /api/auth/token`` and carry the user's roles
|
|
9
|
+
as a claim. When no credentials are configured, all requests are allowed as
|
|
10
|
+
``anonymous``.
|
|
11
|
+
|
|
12
|
+
Roles
|
|
13
|
+
-----
|
|
14
|
+
Roles are configured in ``hof.config.py``:
|
|
15
|
+
|
|
16
|
+
config = Config(
|
|
17
|
+
admin_username="admin",
|
|
18
|
+
admin_password="secret",
|
|
19
|
+
user_roles={
|
|
20
|
+
"admin": ["admin", "viewer"],
|
|
21
|
+
"readonly": ["viewer"],
|
|
22
|
+
},
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
Built-in roles:
|
|
26
|
+
- ``admin`` — full access to all endpoints
|
|
27
|
+
- ``viewer`` — read-only access (GET requests only)
|
|
28
|
+
|
|
29
|
+
Custom roles can be defined and checked with ``require_role()``.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import secrets
|
|
35
|
+
from datetime import UTC, datetime, timedelta
|
|
36
|
+
from typing import TYPE_CHECKING, Any
|
|
37
|
+
|
|
38
|
+
from fastapi import Depends, HTTPException, Request, Security, status
|
|
39
|
+
from fastapi.security import (
|
|
40
|
+
APIKeyHeader,
|
|
41
|
+
HTTPBasic,
|
|
42
|
+
HTTPBasicCredentials,
|
|
43
|
+
OAuth2PasswordBearer,
|
|
44
|
+
OAuth2PasswordRequestForm,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING:
|
|
48
|
+
from fastapi import FastAPI
|
|
49
|
+
|
|
50
|
+
from hof.config import Config
|
|
51
|
+
|
|
52
|
+
_config: Config | None = None
|
|
53
|
+
|
|
54
|
+
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
|
55
|
+
basic_auth = HTTPBasic(auto_error=False)
|
|
56
|
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token", auto_error=False)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Setup
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def setup_auth(app: FastAPI, config: Config) -> None:
|
|
65
|
+
"""Configure authentication on the FastAPI app and register the token endpoint."""
|
|
66
|
+
global _config
|
|
67
|
+
_config = config
|
|
68
|
+
|
|
69
|
+
from fastapi import APIRouter
|
|
70
|
+
|
|
71
|
+
auth_router = APIRouter()
|
|
72
|
+
|
|
73
|
+
@auth_router.post("/token")
|
|
74
|
+
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
|
75
|
+
"""Issue a JWT access token for valid credentials."""
|
|
76
|
+
cfg = _config
|
|
77
|
+
if cfg is None:
|
|
78
|
+
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "Auth not configured")
|
|
79
|
+
|
|
80
|
+
username_ok = secrets.compare_digest(form_data.username, cfg.admin_username)
|
|
81
|
+
password_ok = secrets.compare_digest(form_data.password, cfg.admin_password)
|
|
82
|
+
|
|
83
|
+
if not (username_ok and password_ok):
|
|
84
|
+
raise HTTPException(
|
|
85
|
+
status.HTTP_401_UNAUTHORIZED,
|
|
86
|
+
detail="Incorrect username or password",
|
|
87
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
roles = cfg.user_roles.get(form_data.username, ["admin"])
|
|
91
|
+
token = _create_access_token(
|
|
92
|
+
subject=form_data.username,
|
|
93
|
+
roles=roles,
|
|
94
|
+
secret=cfg.jwt_secret_key or cfg.admin_password,
|
|
95
|
+
algorithm=cfg.jwt_algorithm,
|
|
96
|
+
expires_minutes=cfg.jwt_access_token_expire_minutes,
|
|
97
|
+
)
|
|
98
|
+
return {"access_token": token, "token_type": "bearer"}
|
|
99
|
+
|
|
100
|
+
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# JWT helpers
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _create_access_token(
|
|
109
|
+
*,
|
|
110
|
+
subject: str,
|
|
111
|
+
roles: list[str],
|
|
112
|
+
secret: str,
|
|
113
|
+
algorithm: str,
|
|
114
|
+
expires_minutes: int,
|
|
115
|
+
) -> str:
|
|
116
|
+
try:
|
|
117
|
+
from jose import jwt
|
|
118
|
+
except ImportError:
|
|
119
|
+
raise ImportError("python-jose[cryptography] is required for JWT auth.")
|
|
120
|
+
|
|
121
|
+
expire = datetime.now(UTC) + timedelta(minutes=expires_minutes)
|
|
122
|
+
payload = {
|
|
123
|
+
"sub": subject,
|
|
124
|
+
"roles": roles,
|
|
125
|
+
"exp": expire,
|
|
126
|
+
}
|
|
127
|
+
return jwt.encode(payload, secret, algorithm=algorithm)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _decode_jwt(token: str) -> dict[str, Any] | None:
|
|
131
|
+
if _config is None:
|
|
132
|
+
return None
|
|
133
|
+
secret = _config.jwt_secret_key or _config.admin_password
|
|
134
|
+
if not secret:
|
|
135
|
+
return None
|
|
136
|
+
try:
|
|
137
|
+
from jose import jwt
|
|
138
|
+
|
|
139
|
+
return jwt.decode(token, secret, algorithms=[_config.jwt_algorithm])
|
|
140
|
+
except Exception:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Dependency: verify authentication
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def verify_auth(
|
|
150
|
+
request: Request,
|
|
151
|
+
bearer_token: str | None = Depends(oauth2_scheme),
|
|
152
|
+
api_key: str | None = Security(api_key_header),
|
|
153
|
+
credentials: HTTPBasicCredentials | None = Depends(basic_auth),
|
|
154
|
+
) -> str:
|
|
155
|
+
"""Verify authentication via JWT Bearer token, API key, or Basic auth.
|
|
156
|
+
|
|
157
|
+
Returns the authenticated identity (username or ``"api-key"``).
|
|
158
|
+
When no credentials are configured, returns ``"anonymous"``.
|
|
159
|
+
"""
|
|
160
|
+
if _config is None:
|
|
161
|
+
return "anonymous"
|
|
162
|
+
|
|
163
|
+
if not _config.admin_password and not _config.api_key and not _config.jwt_secret_key:
|
|
164
|
+
return "anonymous"
|
|
165
|
+
|
|
166
|
+
# 1. JWT Bearer token
|
|
167
|
+
if bearer_token:
|
|
168
|
+
payload = _decode_jwt(bearer_token)
|
|
169
|
+
if payload and "sub" in payload:
|
|
170
|
+
return payload["sub"]
|
|
171
|
+
|
|
172
|
+
# 2. API key
|
|
173
|
+
if api_key and _config.api_key:
|
|
174
|
+
if secrets.compare_digest(api_key, _config.api_key):
|
|
175
|
+
return "api-key"
|
|
176
|
+
|
|
177
|
+
# 3. HTTP Basic auth
|
|
178
|
+
if credentials and _config.admin_password:
|
|
179
|
+
username_ok = secrets.compare_digest(credentials.username, _config.admin_username)
|
|
180
|
+
password_ok = secrets.compare_digest(credentials.password, _config.admin_password)
|
|
181
|
+
if username_ok and password_ok:
|
|
182
|
+
return credentials.username
|
|
183
|
+
|
|
184
|
+
raise HTTPException(
|
|
185
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
186
|
+
detail="Invalid or missing credentials",
|
|
187
|
+
headers={"WWW-Authenticate": 'Bearer, Basic realm="hof"'},
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
# Dependency: require specific role(s)
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def require_role(*required_roles: str):
|
|
197
|
+
"""FastAPI dependency factory that enforces role-based access.
|
|
198
|
+
|
|
199
|
+
Usage:
|
|
200
|
+
@router.delete("/{id}")
|
|
201
|
+
async def delete_item(user: str = Depends(require_role("admin"))):
|
|
202
|
+
...
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
async def _check_role(user: str = Depends(verify_auth)) -> str:
|
|
206
|
+
if _config is None or not required_roles:
|
|
207
|
+
return user
|
|
208
|
+
|
|
209
|
+
# API key and anonymous get admin-level access when no RBAC is configured
|
|
210
|
+
if user in ("api-key", "anonymous"):
|
|
211
|
+
return user
|
|
212
|
+
|
|
213
|
+
user_role_list = _config.user_roles.get(user, [])
|
|
214
|
+
if not any(r in user_role_list for r in required_roles):
|
|
215
|
+
raise HTTPException(
|
|
216
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
217
|
+
detail=f"Role(s) required: {', '.join(required_roles)}",
|
|
218
|
+
)
|
|
219
|
+
return user
|
|
220
|
+
|
|
221
|
+
return _check_role
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def get_user_roles(username: str) -> list[str]:
|
|
225
|
+
"""Return the roles assigned to a user."""
|
|
226
|
+
if _config is None:
|
|
227
|
+
return []
|
|
228
|
+
return _config.user_roles.get(username, [])
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""API route modules."""
|
hof/api/routes/admin.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Admin API routes for the dashboard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends
|
|
6
|
+
|
|
7
|
+
from hof.api.auth import verify_auth
|
|
8
|
+
from hof.core.registry import registry
|
|
9
|
+
from hof.flows.state import execution_store
|
|
10
|
+
|
|
11
|
+
router = APIRouter()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@router.get("/overview")
|
|
15
|
+
async def admin_overview(user: str = Depends(verify_auth)) -> dict:
|
|
16
|
+
"""Dashboard overview: counts of all registered components and recent activity."""
|
|
17
|
+
recent_executions = execution_store.list_executions(limit=10)
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
"registry": registry.summary(),
|
|
21
|
+
"tables": list(registry.tables.keys()),
|
|
22
|
+
"functions": list(registry.functions.keys()),
|
|
23
|
+
"flows": list(registry.flows.keys()),
|
|
24
|
+
"cron_jobs": list(registry.cron_jobs.keys()),
|
|
25
|
+
"recent_executions": [e.to_dict() for e in recent_executions],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@router.get("/flows/{flow_name}/dag")
|
|
30
|
+
async def flow_dag(
|
|
31
|
+
flow_name: str,
|
|
32
|
+
user: str = Depends(verify_auth),
|
|
33
|
+
) -> dict:
|
|
34
|
+
"""Get the DAG structure for rendering in the flow viewer."""
|
|
35
|
+
flow = registry.get_flow(flow_name)
|
|
36
|
+
if flow is None:
|
|
37
|
+
return {"error": f"Flow '{flow_name}' not found"}
|
|
38
|
+
|
|
39
|
+
nodes = []
|
|
40
|
+
edges = []
|
|
41
|
+
|
|
42
|
+
for name, meta in flow.nodes.items():
|
|
43
|
+
nodes.append(
|
|
44
|
+
{
|
|
45
|
+
"id": name,
|
|
46
|
+
"label": name,
|
|
47
|
+
"description": meta.fn.__doc__ or "",
|
|
48
|
+
"is_human": meta.is_human,
|
|
49
|
+
"human_ui": meta.human_ui,
|
|
50
|
+
"tags": meta.tags,
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
for dep in meta.depends_on:
|
|
54
|
+
edges.append({"source": dep, "target": name})
|
|
55
|
+
|
|
56
|
+
execution_order = flow.get_execution_order()
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
"name": flow_name,
|
|
60
|
+
"nodes": nodes,
|
|
61
|
+
"edges": edges,
|
|
62
|
+
"execution_order": execution_order,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@router.get("/pending-actions")
|
|
67
|
+
async def pending_actions(user: str = Depends(verify_auth)) -> list[dict]:
|
|
68
|
+
"""List all pending human-in-the-loop actions."""
|
|
69
|
+
executions = execution_store.list_executions(status="waiting_for_human", limit=50)
|
|
70
|
+
actions = []
|
|
71
|
+
|
|
72
|
+
for execution in executions:
|
|
73
|
+
for ns in execution.node_states:
|
|
74
|
+
if ns.status == "waiting_for_human":
|
|
75
|
+
flow = registry.get_flow(execution.flow_name)
|
|
76
|
+
node_meta = flow.nodes.get(ns.node_name) if flow else None
|
|
77
|
+
|
|
78
|
+
actions.append(
|
|
79
|
+
{
|
|
80
|
+
"execution_id": execution.id,
|
|
81
|
+
"flow_name": execution.flow_name,
|
|
82
|
+
"node_name": ns.node_name,
|
|
83
|
+
"ui_component": node_meta.human_ui if node_meta else None,
|
|
84
|
+
"input_data": ns.input_data,
|
|
85
|
+
"started_at": ns.started_at.isoformat() if ns.started_at else None,
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return actions
|
hof/api/routes/flows.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Flow management and execution routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
8
|
+
|
|
9
|
+
from hof.api.auth import verify_auth
|
|
10
|
+
from hof.core.registry import registry
|
|
11
|
+
from hof.flows.state import execution_store
|
|
12
|
+
|
|
13
|
+
router = APIRouter()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.get("")
|
|
17
|
+
async def list_flows(user: str = Depends(verify_auth)) -> list[dict]:
|
|
18
|
+
"""List all registered flow definitions."""
|
|
19
|
+
return [flow.to_dict() for flow in registry.flows.values()]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.post("/{flow_name}/run")
|
|
23
|
+
async def run_flow(
|
|
24
|
+
flow_name: str,
|
|
25
|
+
body: dict[str, Any] | None = None,
|
|
26
|
+
user: str = Depends(verify_auth),
|
|
27
|
+
) -> dict:
|
|
28
|
+
"""Trigger a new flow execution."""
|
|
29
|
+
flow = registry.get_flow(flow_name)
|
|
30
|
+
if flow is None:
|
|
31
|
+
raise HTTPException(404, f"Flow '{flow_name}' not found")
|
|
32
|
+
|
|
33
|
+
input_data = body or {}
|
|
34
|
+
execution = flow.run(**input_data)
|
|
35
|
+
return execution.to_dict()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.get("/{flow_name}/executions")
|
|
39
|
+
async def list_executions(
|
|
40
|
+
flow_name: str,
|
|
41
|
+
status: str | None = None,
|
|
42
|
+
limit: int = 20,
|
|
43
|
+
user: str = Depends(verify_auth),
|
|
44
|
+
) -> list[dict]:
|
|
45
|
+
"""List executions for a flow."""
|
|
46
|
+
flow = registry.get_flow(flow_name)
|
|
47
|
+
if flow is None:
|
|
48
|
+
raise HTTPException(404, f"Flow '{flow_name}' not found")
|
|
49
|
+
|
|
50
|
+
executions = execution_store.list_executions(flow_name=flow_name, status=status, limit=limit)
|
|
51
|
+
return [e.to_dict() for e in executions]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@router.get("/executions/{execution_id}")
|
|
55
|
+
async def get_execution(
|
|
56
|
+
execution_id: str,
|
|
57
|
+
user: str = Depends(verify_auth),
|
|
58
|
+
) -> dict:
|
|
59
|
+
"""Get details of a flow execution."""
|
|
60
|
+
execution = execution_store.get_execution(execution_id)
|
|
61
|
+
if execution is None:
|
|
62
|
+
raise HTTPException(404, f"Execution '{execution_id}' not found")
|
|
63
|
+
return execution.to_dict()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@router.post("/executions/{execution_id}/cancel")
|
|
67
|
+
async def cancel_execution(
|
|
68
|
+
execution_id: str,
|
|
69
|
+
user: str = Depends(verify_auth),
|
|
70
|
+
) -> dict:
|
|
71
|
+
"""Cancel a running execution."""
|
|
72
|
+
execution_store.update_status(execution_id, "cancelled")
|
|
73
|
+
return {"cancelled": True, "id": execution_id}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@router.post("/executions/{execution_id}/nodes/{node_name}/submit")
|
|
77
|
+
async def submit_human_input(
|
|
78
|
+
execution_id: str,
|
|
79
|
+
node_name: str,
|
|
80
|
+
body: dict[str, Any],
|
|
81
|
+
user: str = Depends(verify_auth),
|
|
82
|
+
) -> dict:
|
|
83
|
+
"""Submit human input for a waiting node."""
|
|
84
|
+
execution = execution_store.get_execution(execution_id)
|
|
85
|
+
if execution is None:
|
|
86
|
+
raise HTTPException(404, f"Execution '{execution_id}' not found")
|
|
87
|
+
|
|
88
|
+
flow = registry.get_flow(execution.flow_name)
|
|
89
|
+
if flow is None:
|
|
90
|
+
raise HTTPException(404, f"Flow '{execution.flow_name}' not found")
|
|
91
|
+
|
|
92
|
+
from hof.flows.executor import FlowExecutor
|
|
93
|
+
|
|
94
|
+
executor = FlowExecutor(flow)
|
|
95
|
+
result = executor.resume_after_human(execution_id, node_name, body)
|
|
96
|
+
|
|
97
|
+
if result is None:
|
|
98
|
+
raise HTTPException(400, "Could not submit input (node may not be waiting)")
|
|
99
|
+
|
|
100
|
+
return result.to_dict()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Auto-generated routes for registered functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, Security
|
|
9
|
+
from fastapi.security import HTTPBasicCredentials
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from hof.api.auth import api_key_header, basic_auth, oauth2_scheme, verify_auth
|
|
13
|
+
from hof.core.registry import registry
|
|
14
|
+
from hof.db.schemas import build_function_input_schema
|
|
15
|
+
|
|
16
|
+
router = APIRouter()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def _optional_auth(
|
|
20
|
+
request: Request,
|
|
21
|
+
bearer_token: str | None = Depends(oauth2_scheme),
|
|
22
|
+
api_key: str | None = Security(api_key_header),
|
|
23
|
+
credentials: HTTPBasicCredentials | None = Depends(basic_auth),
|
|
24
|
+
) -> str:
|
|
25
|
+
"""Resolve auth only when the target function is not public.
|
|
26
|
+
|
|
27
|
+
Public functions (``@function(public=True)``) skip authentication entirely.
|
|
28
|
+
"""
|
|
29
|
+
function_name = request.path_params.get("function_name")
|
|
30
|
+
if function_name:
|
|
31
|
+
meta = registry.get_function(function_name)
|
|
32
|
+
if meta and meta.public:
|
|
33
|
+
return "anonymous"
|
|
34
|
+
return await verify_auth(
|
|
35
|
+
request,
|
|
36
|
+
bearer_token=bearer_token,
|
|
37
|
+
api_key=api_key,
|
|
38
|
+
credentials=credentials,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@router.get("")
|
|
43
|
+
async def list_functions(user: str = Depends(verify_auth)) -> list[dict]:
|
|
44
|
+
"""List all registered functions."""
|
|
45
|
+
return [meta.to_dict() for meta in registry.functions.values()]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@router.post("/{function_name}")
|
|
49
|
+
async def call_function(
|
|
50
|
+
function_name: str,
|
|
51
|
+
body: dict[str, Any] | None = None,
|
|
52
|
+
user: str = Depends(_optional_auth),
|
|
53
|
+
) -> dict:
|
|
54
|
+
"""Execute a registered function. Input is validated against the function signature."""
|
|
55
|
+
meta = registry.get_function(function_name)
|
|
56
|
+
if meta is None:
|
|
57
|
+
raise HTTPException(404, f"Function '{function_name}' not found")
|
|
58
|
+
|
|
59
|
+
kwargs = body or {}
|
|
60
|
+
|
|
61
|
+
if meta.parameters:
|
|
62
|
+
schema = build_function_input_schema(meta)
|
|
63
|
+
try:
|
|
64
|
+
validated = schema(**kwargs)
|
|
65
|
+
except ValidationError as exc:
|
|
66
|
+
raise HTTPException(422, detail=exc.errors())
|
|
67
|
+
kwargs = validated.model_dump(exclude_none=False)
|
|
68
|
+
|
|
69
|
+
start = time.monotonic()
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
if meta.is_async:
|
|
73
|
+
result = await meta.fn(**kwargs)
|
|
74
|
+
else:
|
|
75
|
+
result = meta.fn(**kwargs)
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
raise HTTPException(500, f"Function error: {exc}")
|
|
78
|
+
|
|
79
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
"result": result,
|
|
83
|
+
"duration_ms": duration_ms,
|
|
84
|
+
"function": function_name,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@router.get("/{function_name}/schema")
|
|
89
|
+
async def get_function_schema(
|
|
90
|
+
function_name: str,
|
|
91
|
+
user: str = Depends(verify_auth),
|
|
92
|
+
) -> dict:
|
|
93
|
+
"""Get the input/output schema of a function."""
|
|
94
|
+
meta = registry.get_function(function_name)
|
|
95
|
+
if meta is None:
|
|
96
|
+
raise HTTPException(404, f"Function '{function_name}' not found")
|
|
97
|
+
|
|
98
|
+
return meta.to_dict()
|