flyteplugins-hitl 2.0.6__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.
- flyteplugins/hitl/__init__.py +53 -0
- flyteplugins/hitl/_app.py +268 -0
- flyteplugins/hitl/_event.py +430 -0
- flyteplugins/hitl/_helpers.py +71 -0
- flyteplugins/hitl/_html_templates.py +333 -0
- flyteplugins_hitl-2.0.6.dist-info/METADATA +127 -0
- flyteplugins_hitl-2.0.6.dist-info/RECORD +9 -0
- flyteplugins_hitl-2.0.6.dist-info/WHEEL +5 -0
- flyteplugins_hitl-2.0.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Human-in-the-Loop (HITL) plugin for Flyte.
|
|
2
|
+
|
|
3
|
+
This plugin provides an event-based API for pausing workflows and waiting for human input.
|
|
4
|
+
|
|
5
|
+
## Basic usage:
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
import flyte
|
|
9
|
+
import flyteplugins.hitl as hitl
|
|
10
|
+
|
|
11
|
+
task_env = flyte.TaskEnvironment(
|
|
12
|
+
name="my-hitl-workflow",
|
|
13
|
+
image=flyte.Image.from_debian_base(python_version=(3, 12)),
|
|
14
|
+
resources=flyte.Resources(cpu=1, memory="512Mi"),
|
|
15
|
+
depends_on=[hitl.env],
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@task_env.task(report=True)
|
|
20
|
+
async def main() -> int:
|
|
21
|
+
# Create an event (this serves the app if not already running)
|
|
22
|
+
event = await hitl.new_event.aio(
|
|
23
|
+
"integer_input_event",
|
|
24
|
+
data_type=int,
|
|
25
|
+
scope="run",
|
|
26
|
+
prompt="What should I add to x?",
|
|
27
|
+
)
|
|
28
|
+
y = await event.wait.aio()
|
|
29
|
+
return y
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Features:
|
|
33
|
+
|
|
34
|
+
- Event-based API for human-in-the-loop workflows
|
|
35
|
+
- Web form for human input
|
|
36
|
+
- Programmatic API for automated input
|
|
37
|
+
- Support for int, float, str, and bool data types
|
|
38
|
+
- Crash-resilient polling with object storage
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from ._event import Event, EventScope, event_task_env, new_event
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"Event",
|
|
45
|
+
"EventScope",
|
|
46
|
+
"env",
|
|
47
|
+
"new_event",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
__version__ = "0.1.0"
|
|
51
|
+
|
|
52
|
+
# Expose the task environment as `env` for user convenience
|
|
53
|
+
env = event_task_env
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI app for Human-in-the-Loop (HITL) events.
|
|
3
|
+
|
|
4
|
+
This module provides the web interface for humans to submit input to paused Flyte workflows.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import aiofiles
|
|
15
|
+
import flyte
|
|
16
|
+
import flyte.storage as storage
|
|
17
|
+
from fastapi import FastAPI, Form, HTTPException
|
|
18
|
+
from fastapi.responses import HTMLResponse
|
|
19
|
+
from pydantic import BaseModel
|
|
20
|
+
|
|
21
|
+
from ._helpers import _convert_value, _get_request_path, _get_response_path
|
|
22
|
+
from ._html_templates import get_bool_select_element, get_index_page, get_input_form_page, get_submission_success_page
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ============================================================================
|
|
28
|
+
# FastAPI App for Human Input
|
|
29
|
+
# ============================================================================
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@asynccontextmanager
|
|
33
|
+
async def lifespan(app: FastAPI):
|
|
34
|
+
"""Initialize Flyte on app startup."""
|
|
35
|
+
logger.info("HITL Event App starting up...")
|
|
36
|
+
await flyte.init_in_cluster.aio()
|
|
37
|
+
yield
|
|
38
|
+
logger.info("HITL Event App shutting down...")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
app = FastAPI(
|
|
42
|
+
title="Human-in-the-Loop Event Service",
|
|
43
|
+
description="Provides endpoints for humans to submit input to Flyte workflow events",
|
|
44
|
+
version="1.0.0",
|
|
45
|
+
lifespan=lifespan,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Pydantic models for submissions
|
|
50
|
+
class HITLSubmissionTyped(BaseModel):
|
|
51
|
+
"""Schema for HITL input submission with explicit type."""
|
|
52
|
+
|
|
53
|
+
request_id: str
|
|
54
|
+
value: Any
|
|
55
|
+
data_type: str = "str"
|
|
56
|
+
response_path: str = "" # Full storage path for the response (e.g., s3://bucket/path/response.json)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.get("/health")
|
|
60
|
+
async def health_check() -> dict[str, str]:
|
|
61
|
+
"""Health check endpoint."""
|
|
62
|
+
return {"status": "healthy"}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.get("/", response_class=HTMLResponse)
|
|
66
|
+
async def index() -> str:
|
|
67
|
+
"""Landing page with instructions."""
|
|
68
|
+
return get_index_page()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.get("/form/{request_id}", response_class=HTMLResponse)
|
|
72
|
+
async def input_form(request_id: str, request_path: str | None = None) -> str:
|
|
73
|
+
"""Render an HTML form for human input.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
request_id: The unique identifier for this HITL request
|
|
77
|
+
"""
|
|
78
|
+
# Use provided request_path or fall back to local path construction
|
|
79
|
+
if request_path is None:
|
|
80
|
+
request_path = _get_request_path(request_id)
|
|
81
|
+
|
|
82
|
+
prompt = "Please enter a value"
|
|
83
|
+
data_type_name = "str"
|
|
84
|
+
event_name = "Unknown"
|
|
85
|
+
response_path = ""
|
|
86
|
+
|
|
87
|
+
print(f"Request path: {request_path}")
|
|
88
|
+
try:
|
|
89
|
+
if await storage.exists(request_path):
|
|
90
|
+
request_data = await storage.get(request_path)
|
|
91
|
+
async with aiofiles.open(request_data, "r") as f:
|
|
92
|
+
request_data = json.loads(await f.read())
|
|
93
|
+
prompt = request_data.get("prompt", prompt)
|
|
94
|
+
data_type_name = request_data.get("data_type", "str")
|
|
95
|
+
event_name = request_data.get("event_name", "Unknown")
|
|
96
|
+
response_path = request_data.get("response_path", "")
|
|
97
|
+
except Exception as e:
|
|
98
|
+
print(f"Could not fetch request metadata: {e}")
|
|
99
|
+
|
|
100
|
+
# Determine input type based on data type
|
|
101
|
+
if data_type_name == "int":
|
|
102
|
+
input_type = "number"
|
|
103
|
+
placeholder = "Enter integer value"
|
|
104
|
+
input_attrs = 'step="1"'
|
|
105
|
+
elif data_type_name == "float":
|
|
106
|
+
input_type = "number"
|
|
107
|
+
placeholder = "Enter decimal value"
|
|
108
|
+
input_attrs = 'step="any"'
|
|
109
|
+
elif data_type_name == "bool":
|
|
110
|
+
input_type = "select"
|
|
111
|
+
placeholder = ""
|
|
112
|
+
input_attrs = ""
|
|
113
|
+
else:
|
|
114
|
+
input_type = "text"
|
|
115
|
+
placeholder = "Enter value"
|
|
116
|
+
input_attrs = ""
|
|
117
|
+
|
|
118
|
+
if input_type == "select":
|
|
119
|
+
input_element = get_bool_select_element()
|
|
120
|
+
else:
|
|
121
|
+
input_element = f'<input type="{input_type}" name="value" placeholder="{placeholder}" {input_attrs} required>'
|
|
122
|
+
|
|
123
|
+
return get_input_form_page(
|
|
124
|
+
event_name=event_name,
|
|
125
|
+
prompt=prompt,
|
|
126
|
+
data_type_name=data_type_name,
|
|
127
|
+
request_id=request_id,
|
|
128
|
+
response_path=response_path,
|
|
129
|
+
input_element=input_element,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@app.post("/submit", response_class=HTMLResponse)
|
|
134
|
+
async def submit_input(
|
|
135
|
+
request_id: str = Form(...),
|
|
136
|
+
value: str = Form(...),
|
|
137
|
+
data_type: str = Form("str"),
|
|
138
|
+
response_path: str = Form(""),
|
|
139
|
+
) -> str:
|
|
140
|
+
"""Submit human input for a pending event.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
request_id: The unique identifier for this HITL request
|
|
144
|
+
value: The value submitted by the user
|
|
145
|
+
data_type: The expected data type (int, float, bool, str)
|
|
146
|
+
response_path: Optional full storage path to write the response (e.g., s3://bucket/path/response.json).
|
|
147
|
+
If not provided, falls back to local path construction.
|
|
148
|
+
"""
|
|
149
|
+
try:
|
|
150
|
+
converted_value = _convert_value(value, data_type)
|
|
151
|
+
except (ValueError, TypeError) as e:
|
|
152
|
+
raise HTTPException(status_code=400, detail=f"Failed to convert value '{value}' to type '{data_type}': {e}")
|
|
153
|
+
|
|
154
|
+
logger.info(f"Received event submission: request_id={request_id}, value={converted_value} (type={data_type})")
|
|
155
|
+
|
|
156
|
+
# Use provided response_path or fall back to local path construction
|
|
157
|
+
if not response_path:
|
|
158
|
+
response_path = _get_response_path(request_id)
|
|
159
|
+
|
|
160
|
+
response_data = json.dumps(
|
|
161
|
+
{
|
|
162
|
+
"value": converted_value,
|
|
163
|
+
"status": "completed",
|
|
164
|
+
"request_id": request_id,
|
|
165
|
+
"data_type": data_type,
|
|
166
|
+
}
|
|
167
|
+
).encode()
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
await storage.put_stream(response_data, to_path=response_path)
|
|
171
|
+
logger.info(f"Wrote response to {response_path}")
|
|
172
|
+
message = "Input received successfully. The workflow will continue."
|
|
173
|
+
return get_submission_success_page(
|
|
174
|
+
request_id=request_id,
|
|
175
|
+
value=str(converted_value),
|
|
176
|
+
data_type=data_type,
|
|
177
|
+
message=message,
|
|
178
|
+
)
|
|
179
|
+
except Exception as e:
|
|
180
|
+
logger.error(f"Failed to write response: {e}")
|
|
181
|
+
raise HTTPException(status_code=500, detail=f"Failed to save response: {e}")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@app.post("/submit/json")
|
|
185
|
+
async def submit_input_json(submission: HITLSubmissionTyped) -> dict:
|
|
186
|
+
"""Submit human input via JSON."""
|
|
187
|
+
try:
|
|
188
|
+
converted_value = _convert_value(str(submission.value), submission.data_type)
|
|
189
|
+
except (ValueError, TypeError) as e:
|
|
190
|
+
raise HTTPException(
|
|
191
|
+
status_code=400,
|
|
192
|
+
detail=f"Failed to convert value '{submission.value}' to type '{submission.data_type}': {e}",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
logger.info(
|
|
196
|
+
f"Received event submission: request_id={submission.request_id}, "
|
|
197
|
+
f"value={converted_value} (type={submission.data_type})"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
response_path = submission.response_path
|
|
201
|
+
if not response_path:
|
|
202
|
+
response_path = _get_response_path(submission.request_id)
|
|
203
|
+
|
|
204
|
+
response_data = json.dumps(
|
|
205
|
+
{
|
|
206
|
+
"value": converted_value,
|
|
207
|
+
"status": "completed",
|
|
208
|
+
"request_id": submission.request_id,
|
|
209
|
+
"data_type": submission.data_type,
|
|
210
|
+
}
|
|
211
|
+
).encode()
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
await storage.put_stream(response_data, to_path=response_path)
|
|
215
|
+
logger.info(f"Wrote response to {response_path}")
|
|
216
|
+
return {
|
|
217
|
+
"status": "submitted",
|
|
218
|
+
"request_id": submission.request_id,
|
|
219
|
+
"value": converted_value,
|
|
220
|
+
"data_type": submission.data_type,
|
|
221
|
+
"message": "Input received successfully. The workflow will continue.",
|
|
222
|
+
}
|
|
223
|
+
except Exception as e:
|
|
224
|
+
logger.error(f"Failed to write response: {e}")
|
|
225
|
+
raise HTTPException(status_code=500, detail=f"Failed to save response: {e}")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@app.get("/status/{request_id}")
|
|
229
|
+
async def get_status(
|
|
230
|
+
request_id: str,
|
|
231
|
+
request_path: str | None = None,
|
|
232
|
+
response_path: str | None = None,
|
|
233
|
+
) -> dict:
|
|
234
|
+
"""Check the status of an event.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
request_id: The unique identifier for this HITL request
|
|
238
|
+
request_path: Optional full storage path to the request metadata
|
|
239
|
+
response_path: Optional full storage path to the response metadata
|
|
240
|
+
"""
|
|
241
|
+
# Use provided paths or fall back to local path construction
|
|
242
|
+
if not request_path:
|
|
243
|
+
request_path = _get_request_path(request_id)
|
|
244
|
+
if not response_path:
|
|
245
|
+
response_path = _get_response_path(request_id)
|
|
246
|
+
|
|
247
|
+
result = {"request_id": request_id}
|
|
248
|
+
|
|
249
|
+
if await storage.exists(request_path):
|
|
250
|
+
async for chunk in storage.get_stream(request_path):
|
|
251
|
+
result["request"] = json.loads(chunk.decode())
|
|
252
|
+
# If response_path wasn't provided, try to get it from request metadata
|
|
253
|
+
if not response_path:
|
|
254
|
+
response_path = result["request"].get("response_path", _get_response_path(request_id))
|
|
255
|
+
break
|
|
256
|
+
else:
|
|
257
|
+
result["request"] = None
|
|
258
|
+
|
|
259
|
+
if await storage.exists(response_path):
|
|
260
|
+
async for chunk in storage.get_stream(response_path):
|
|
261
|
+
result["response"] = json.loads(chunk.decode())
|
|
262
|
+
result["status"] = "completed"
|
|
263
|
+
break
|
|
264
|
+
else:
|
|
265
|
+
result["response"] = None
|
|
266
|
+
result["status"] = "pending" if result["request"] else "not_found"
|
|
267
|
+
|
|
268
|
+
return result
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Event class for Human-in-the-Loop (HITL) workflows.
|
|
3
|
+
|
|
4
|
+
This module provides the Event class that encapsulates the HITL functionality:
|
|
5
|
+
- Creates and serves a FastAPI app for receiving human input
|
|
6
|
+
- Provides endpoints for form-based and JSON-based submission
|
|
7
|
+
- Polls object storage for responses using durable sleep (crash-resilient)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import uuid
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any, ClassVar, Generic, Literal, Type, TypeVar
|
|
17
|
+
|
|
18
|
+
import flyte
|
|
19
|
+
import flyte.app
|
|
20
|
+
import flyte.durable
|
|
21
|
+
import flyte.report
|
|
22
|
+
import flyte.storage as storage
|
|
23
|
+
from flyte.app.extras import FastAPIAppEnvironment
|
|
24
|
+
from flyte.syncify import syncify
|
|
25
|
+
|
|
26
|
+
from ._app import app
|
|
27
|
+
from ._helpers import _get_request_path, _get_response_path, _get_type_name
|
|
28
|
+
from ._html_templates import get_event_report_html
|
|
29
|
+
|
|
30
|
+
# Type variable for generic Event
|
|
31
|
+
T = TypeVar("T")
|
|
32
|
+
|
|
33
|
+
# Scope type for events
|
|
34
|
+
EventScope = Literal["run"]
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ============================================================================
|
|
40
|
+
# App and Task Environment for HITL events (module-level)
|
|
41
|
+
# ============================================================================
|
|
42
|
+
|
|
43
|
+
event_image = (
|
|
44
|
+
flyte.Image.from_debian_base()
|
|
45
|
+
.with_pip_packages("fastapi", "uvicorn", "python-multipart", "aiofiles")
|
|
46
|
+
.with_pip_packages("flyte>=2.0.0", "flyteplugins-hitl>=2.0.0")
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
event_app_env = FastAPIAppEnvironment(
|
|
50
|
+
name="hitl-event-app",
|
|
51
|
+
app=app,
|
|
52
|
+
domain=flyte.app.Domain(subdomain="hitl-event-app"),
|
|
53
|
+
description="Human-in-the-loop event service for Flyte workflows",
|
|
54
|
+
image=event_image,
|
|
55
|
+
resources=flyte.Resources(cpu=1, memory="512Mi"),
|
|
56
|
+
requires_auth=True,
|
|
57
|
+
scaling=flyte.app.Scaling(replicas=(0, 1), scaledown_after=600),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
event_task_env = flyte.TaskEnvironment(
|
|
61
|
+
name="hitl-event-task-env",
|
|
62
|
+
image=event_image,
|
|
63
|
+
resources=flyte.Resources(cpu=1, memory="512Mi"),
|
|
64
|
+
depends_on=[event_app_env],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ============================================================================
|
|
69
|
+
# Event-based HITL API
|
|
70
|
+
# ============================================================================
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Event(Generic[T]):
|
|
74
|
+
"""
|
|
75
|
+
An event that waits for human input via an embedded FastAPI app.
|
|
76
|
+
|
|
77
|
+
This class encapsulates the entire HITL functionality:
|
|
78
|
+
- Creates and serves a FastAPI app for receiving human input
|
|
79
|
+
- Provides endpoints for form-based and JSON-based submission
|
|
80
|
+
- Polls object storage for responses using durable sleep (crash-resilient)
|
|
81
|
+
|
|
82
|
+
The app is automatically served when the Event is created via `Event.create()`.
|
|
83
|
+
All infrastructure details (AppEnvironment, deployment) are abstracted away.
|
|
84
|
+
|
|
85
|
+
Example:
|
|
86
|
+
# Create an event (serves the app) and wait for input
|
|
87
|
+
event = await Event.create.aio(
|
|
88
|
+
"proceed_event",
|
|
89
|
+
scope="run",
|
|
90
|
+
prompt="What should I add to x?",
|
|
91
|
+
data_type=int,
|
|
92
|
+
)
|
|
93
|
+
result = await event.wait.aio()
|
|
94
|
+
|
|
95
|
+
# Or synchronously
|
|
96
|
+
event = Event.create("my_event", scope="run", prompt="Enter value", data_type=str)
|
|
97
|
+
value = event.wait()
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
# Class-level app handle (shared across all events)
|
|
101
|
+
_app_handle: ClassVar[flyte.AppHandle | None] = None
|
|
102
|
+
_app_served: ClassVar[bool] = False
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
name: str,
|
|
107
|
+
scope: EventScope,
|
|
108
|
+
data_type: Type[T],
|
|
109
|
+
prompt: str,
|
|
110
|
+
request_id: str,
|
|
111
|
+
endpoint: str,
|
|
112
|
+
request_path: str,
|
|
113
|
+
response_path: str,
|
|
114
|
+
timeout_seconds: int = 3600,
|
|
115
|
+
poll_interval_seconds: int = 5,
|
|
116
|
+
):
|
|
117
|
+
self.name = name
|
|
118
|
+
self.scope = scope
|
|
119
|
+
self.data_type = data_type
|
|
120
|
+
self.prompt = prompt
|
|
121
|
+
self.request_id = request_id
|
|
122
|
+
self._endpoint = endpoint
|
|
123
|
+
self._request_path = request_path
|
|
124
|
+
self._response_path = response_path
|
|
125
|
+
self.timeout_seconds = timeout_seconds
|
|
126
|
+
self.poll_interval_seconds = poll_interval_seconds
|
|
127
|
+
self._type_name = _get_type_name(data_type)
|
|
128
|
+
self._report_flushed = False
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def form_url(self) -> str:
|
|
132
|
+
"""URL where humans can submit input for this event."""
|
|
133
|
+
from urllib.parse import urlencode
|
|
134
|
+
|
|
135
|
+
params = urlencode({"request_path": self._request_path})
|
|
136
|
+
return f"{self._endpoint}/form/{self.request_id}?{params}"
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def api_url(self) -> str:
|
|
140
|
+
"""API endpoint for programmatic submission."""
|
|
141
|
+
return f"{self._endpoint}/submit/json"
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def endpoint(self) -> str:
|
|
145
|
+
"""Base endpoint of the HITL app."""
|
|
146
|
+
return self._endpoint
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
async def _serve_app(cls) -> flyte.AppHandle:
|
|
150
|
+
"""Serve the app and return the app handle."""
|
|
151
|
+
from flyteplugins.hitl import __version__
|
|
152
|
+
|
|
153
|
+
await flyte.init_in_cluster.aio()
|
|
154
|
+
return await flyte.with_servecontext(
|
|
155
|
+
copy_style="none",
|
|
156
|
+
version=__version__,
|
|
157
|
+
interactive_mode=True,
|
|
158
|
+
).serve.aio(event_app_env)
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
@syncify
|
|
162
|
+
async def create(
|
|
163
|
+
cls,
|
|
164
|
+
name: str,
|
|
165
|
+
data_type: Type[T],
|
|
166
|
+
scope: EventScope = "run",
|
|
167
|
+
prompt: str = "Please provide a value",
|
|
168
|
+
timeout_seconds: int = 3600,
|
|
169
|
+
poll_interval_seconds: int = 5,
|
|
170
|
+
) -> "Event[T]":
|
|
171
|
+
"""
|
|
172
|
+
Create a new human-in-the-loop event and serve the app.
|
|
173
|
+
|
|
174
|
+
This method creates an event that waits for human input via the FastAPI app.
|
|
175
|
+
The app is automatically served if not already running. All infrastructure
|
|
176
|
+
details are abstracted away - you just get an event to wait on.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
name: A descriptive name for the event (used in logs and UI)
|
|
180
|
+
scope: The scope of the event. Currently only "run" is supported.
|
|
181
|
+
prompt: The prompt to display to the human
|
|
182
|
+
data_type: The expected type of the input (int, float, str, bool)
|
|
183
|
+
timeout_seconds: Maximum time to wait for human input (default: 1 hour)
|
|
184
|
+
poll_interval_seconds: How often to check for a response (default: 5 seconds)
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
An Event object that can be used to wait for the human input
|
|
188
|
+
|
|
189
|
+
Example:
|
|
190
|
+
# Async usage
|
|
191
|
+
event = await Event.create.aio(
|
|
192
|
+
"approval_event",
|
|
193
|
+
scope="run",
|
|
194
|
+
prompt="Do you approve this action?",
|
|
195
|
+
data_type=bool,
|
|
196
|
+
)
|
|
197
|
+
approved = await event.wait.aio()
|
|
198
|
+
|
|
199
|
+
# Sync usage
|
|
200
|
+
event = Event.create("value_event", scope="run", prompt="Enter a number", data_type=int)
|
|
201
|
+
value = event.wait()
|
|
202
|
+
"""
|
|
203
|
+
# Serve the app if not already served
|
|
204
|
+
if not cls._app_served or cls._app_handle is None:
|
|
205
|
+
logger.info("Serving HITL Event app...")
|
|
206
|
+
cls._app_handle = await cls._serve_app()
|
|
207
|
+
cls._app_served = True
|
|
208
|
+
logger.info(f"HITL Event app served at: {cls._app_handle.endpoint}")
|
|
209
|
+
|
|
210
|
+
# Get the endpoint from the app handle
|
|
211
|
+
endpoint = cls._app_handle.endpoint
|
|
212
|
+
|
|
213
|
+
# Generate request ID and create request metadata
|
|
214
|
+
request_id = str(uuid.uuid4())
|
|
215
|
+
type_name = _get_type_name(data_type)
|
|
216
|
+
|
|
217
|
+
# Get the full storage paths (these will be blob storage paths when running in cluster)
|
|
218
|
+
request_path = _get_request_path(request_id)
|
|
219
|
+
response_path = _get_response_path(request_id)
|
|
220
|
+
|
|
221
|
+
# Write the request metadata to storage, including the full paths
|
|
222
|
+
# so the app can read/write to blob storage
|
|
223
|
+
data = {
|
|
224
|
+
"request_id": request_id,
|
|
225
|
+
"event_name": name,
|
|
226
|
+
"scope": scope,
|
|
227
|
+
"prompt": prompt,
|
|
228
|
+
"data_type": type_name,
|
|
229
|
+
"status": "pending",
|
|
230
|
+
"app_endpoint": endpoint,
|
|
231
|
+
"request_path": request_path,
|
|
232
|
+
"response_path": response_path,
|
|
233
|
+
}
|
|
234
|
+
logger.info(f"Creating event with data: {data}")
|
|
235
|
+
request_data = json.dumps(data).encode()
|
|
236
|
+
|
|
237
|
+
await storage.put_stream(request_data, to_path=request_path)
|
|
238
|
+
logger.info(f"Created event '{name}' at {request_path}")
|
|
239
|
+
|
|
240
|
+
# Create the event object
|
|
241
|
+
event: Event[T] = cls(
|
|
242
|
+
name=name,
|
|
243
|
+
scope=scope,
|
|
244
|
+
data_type=data_type,
|
|
245
|
+
prompt=prompt,
|
|
246
|
+
request_id=request_id,
|
|
247
|
+
endpoint=endpoint,
|
|
248
|
+
request_path=request_path,
|
|
249
|
+
response_path=response_path,
|
|
250
|
+
timeout_seconds=timeout_seconds,
|
|
251
|
+
poll_interval_seconds=poll_interval_seconds,
|
|
252
|
+
)
|
|
253
|
+
return event
|
|
254
|
+
|
|
255
|
+
@syncify
|
|
256
|
+
async def wait(self) -> T:
|
|
257
|
+
"""
|
|
258
|
+
Wait for human input and return the result.
|
|
259
|
+
|
|
260
|
+
This method polls object storage for a response using durable sleep,
|
|
261
|
+
making it crash-resilient. If the task crashes and restarts, it will
|
|
262
|
+
resume polling from where it left off.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
The value provided by the human, converted to the event's data_type
|
|
266
|
+
|
|
267
|
+
Raises:
|
|
268
|
+
TimeoutError: If no response is received within the timeout
|
|
269
|
+
"""
|
|
270
|
+
curl_body = json.dumps(
|
|
271
|
+
{
|
|
272
|
+
"request_id": self.request_id,
|
|
273
|
+
"response_path": self._response_path,
|
|
274
|
+
"value": "{{your_value}}",
|
|
275
|
+
"data_type": self._type_name,
|
|
276
|
+
},
|
|
277
|
+
indent=2,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
report_html = get_event_report_html(
|
|
281
|
+
form_url=self.form_url,
|
|
282
|
+
api_url=self.api_url,
|
|
283
|
+
curl_body=curl_body,
|
|
284
|
+
type_name=self._type_name,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
await show_form.override(
|
|
288
|
+
short_name=self.name,
|
|
289
|
+
links=[
|
|
290
|
+
EventFormLink(
|
|
291
|
+
endpoint=self.endpoint,
|
|
292
|
+
request_id=self.request_id,
|
|
293
|
+
request_path=self._request_path,
|
|
294
|
+
)
|
|
295
|
+
],
|
|
296
|
+
)(report_html)
|
|
297
|
+
return await wait_for_input_event(
|
|
298
|
+
name=self.name,
|
|
299
|
+
request_id=self.request_id,
|
|
300
|
+
response_path=self._response_path,
|
|
301
|
+
timeout_seconds=self.timeout_seconds,
|
|
302
|
+
poll_interval_seconds=self.poll_interval_seconds,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def __repr__(self) -> str:
|
|
306
|
+
return (
|
|
307
|
+
f"Event(name={self.name!r}, scope={self.scope!r}, "
|
|
308
|
+
f"data_type={self._type_name}, request_id={self.request_id!r})"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@dataclass
|
|
313
|
+
class EventFormLink(flyte.Link):
|
|
314
|
+
"""
|
|
315
|
+
A link to the event form.
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
endpoint: str
|
|
319
|
+
request_id: str
|
|
320
|
+
request_path: str
|
|
321
|
+
name: str = "Event Form"
|
|
322
|
+
|
|
323
|
+
def get_link(
|
|
324
|
+
self,
|
|
325
|
+
run_name: str,
|
|
326
|
+
project: str,
|
|
327
|
+
domain: str,
|
|
328
|
+
context: dict[str, str],
|
|
329
|
+
parent_action_name: str,
|
|
330
|
+
action_name: str,
|
|
331
|
+
pod_name: str,
|
|
332
|
+
**kwargs,
|
|
333
|
+
) -> str:
|
|
334
|
+
from urllib.parse import urlencode
|
|
335
|
+
|
|
336
|
+
params = urlencode({"request_path": self.request_path})
|
|
337
|
+
return f"{self.endpoint}/form/{self.request_id}?{params}"
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@event_task_env.task(report=True)
|
|
341
|
+
async def show_form(html_report: str):
|
|
342
|
+
"""
|
|
343
|
+
Task that serves the event app.
|
|
344
|
+
"""
|
|
345
|
+
await flyte.report.replace.aio(html_report)
|
|
346
|
+
await flyte.report.flush.aio()
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@flyte.trace
|
|
350
|
+
async def wait_for_input_event(
|
|
351
|
+
name: str,
|
|
352
|
+
request_id: str,
|
|
353
|
+
response_path: str,
|
|
354
|
+
timeout_seconds: int,
|
|
355
|
+
poll_interval_seconds: int,
|
|
356
|
+
) -> Any:
|
|
357
|
+
"""
|
|
358
|
+
Task that waits for input from the event app.
|
|
359
|
+
"""
|
|
360
|
+
# Use the stored response path (which includes the full blob storage path)
|
|
361
|
+
elapsed = 0
|
|
362
|
+
|
|
363
|
+
while elapsed < timeout_seconds:
|
|
364
|
+
# Check if response exists
|
|
365
|
+
if await storage.exists(response_path):
|
|
366
|
+
async for chunk in storage.get_stream(response_path):
|
|
367
|
+
response = json.loads(chunk.decode())
|
|
368
|
+
if response.get("status") == "completed":
|
|
369
|
+
value = response["value"]
|
|
370
|
+
logger.info(f"Event '{name}' received human input: {value}")
|
|
371
|
+
print(f"\nReceived human input for '{name}': {value}")
|
|
372
|
+
return value
|
|
373
|
+
|
|
374
|
+
logger.info(f"Event '{name}' waiting for human input... ({elapsed}/{timeout_seconds}s elapsed)")
|
|
375
|
+
await flyte.durable.sleep.aio(poll_interval_seconds)
|
|
376
|
+
elapsed += poll_interval_seconds
|
|
377
|
+
|
|
378
|
+
raise TimeoutError(
|
|
379
|
+
f"Event '{name}' (request_id={request_id}) timed out after "
|
|
380
|
+
f"{timeout_seconds} seconds. No human input was received."
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@syncify
|
|
385
|
+
async def new_event(
|
|
386
|
+
name: str,
|
|
387
|
+
data_type: Type[T],
|
|
388
|
+
scope: EventScope = "run",
|
|
389
|
+
prompt: str = "Please provide a value",
|
|
390
|
+
timeout_seconds: int = 3600,
|
|
391
|
+
poll_interval_seconds: int = 5,
|
|
392
|
+
) -> Event[T]:
|
|
393
|
+
"""
|
|
394
|
+
Create a new human-in-the-loop event.
|
|
395
|
+
|
|
396
|
+
This is a convenience function that wraps Event.create().
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
name: A descriptive name for the event (used in logs and UI)
|
|
400
|
+
data_type: The expected type of the input (int, float, str, bool)
|
|
401
|
+
scope: The scope of the event. Currently only "run" is supported.
|
|
402
|
+
prompt: The prompt to display to the human
|
|
403
|
+
timeout_seconds: Maximum time to wait for human input (default: 1 hour)
|
|
404
|
+
poll_interval_seconds: How often to check for a response (default: 5 seconds)
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
An Event object that can be used to wait for the human input
|
|
408
|
+
|
|
409
|
+
Example:
|
|
410
|
+
# Async usage
|
|
411
|
+
event = await new_event.aio(
|
|
412
|
+
"approval_event",
|
|
413
|
+
data_type=bool,
|
|
414
|
+
scope="run",
|
|
415
|
+
prompt="Do you approve this action?",
|
|
416
|
+
)
|
|
417
|
+
approved = await event.wait.aio()
|
|
418
|
+
|
|
419
|
+
# Sync usage
|
|
420
|
+
event = new_event("value_event", data_type=int, scope="run", prompt="Enter a number")
|
|
421
|
+
value = event.wait()
|
|
422
|
+
"""
|
|
423
|
+
return await Event.create.aio(
|
|
424
|
+
name=name,
|
|
425
|
+
data_type=data_type,
|
|
426
|
+
scope=scope,
|
|
427
|
+
prompt=prompt,
|
|
428
|
+
timeout_seconds=timeout_seconds,
|
|
429
|
+
poll_interval_seconds=poll_interval_seconds,
|
|
430
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Helper functions for the HITL plugin.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from typing import Any, Type
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_type_name(data_type: Type) -> str:
|
|
12
|
+
"""Get a string name for a type that can be used in the form."""
|
|
13
|
+
if data_type is int:
|
|
14
|
+
return "int"
|
|
15
|
+
elif data_type is float:
|
|
16
|
+
return "float"
|
|
17
|
+
elif data_type is bool:
|
|
18
|
+
return "bool"
|
|
19
|
+
elif data_type is str:
|
|
20
|
+
return "str"
|
|
21
|
+
else:
|
|
22
|
+
# For complex types, default to string (JSON serialized)
|
|
23
|
+
return "str"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _convert_value(value: str, data_type: str) -> Any:
|
|
27
|
+
"""Convert a string value to the specified data type."""
|
|
28
|
+
if data_type == "int":
|
|
29
|
+
return int(value)
|
|
30
|
+
elif data_type == "float":
|
|
31
|
+
return float(value)
|
|
32
|
+
elif data_type == "bool":
|
|
33
|
+
return value.lower() in ("true", "1", "yes")
|
|
34
|
+
else:
|
|
35
|
+
# Default to string
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _get_hitl_base_path() -> str:
|
|
40
|
+
"""Get the base path for HITL requests in object storage."""
|
|
41
|
+
return "hitl-requests"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _get_request_path(request_id: str) -> str:
|
|
45
|
+
"""Get the storage path for a HITL request."""
|
|
46
|
+
from flyte._context import internal_ctx
|
|
47
|
+
|
|
48
|
+
ctx = internal_ctx()
|
|
49
|
+
if ctx.has_raw_data:
|
|
50
|
+
base = ctx.raw_data.path
|
|
51
|
+
elif raw_data_path_env_var := os.getenv("RAW_DATA_PATH"):
|
|
52
|
+
base = raw_data_path_env_var
|
|
53
|
+
else:
|
|
54
|
+
# Fallback for local development
|
|
55
|
+
base = "/tmp/flyte/hitl"
|
|
56
|
+
return f"{base}/{_get_hitl_base_path()}/{request_id}/request.json"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _get_response_path(request_id: str) -> str:
|
|
60
|
+
"""Get the storage path for a HITL response."""
|
|
61
|
+
from flyte._context import internal_ctx
|
|
62
|
+
|
|
63
|
+
ctx = internal_ctx()
|
|
64
|
+
if ctx.has_raw_data:
|
|
65
|
+
base = ctx.raw_data.path
|
|
66
|
+
elif raw_data_path_env_var := os.getenv("RAW_DATA_PATH"):
|
|
67
|
+
base = raw_data_path_env_var
|
|
68
|
+
else:
|
|
69
|
+
# Fallback for local development
|
|
70
|
+
base = "/tmp/flyte/hitl"
|
|
71
|
+
return f"{base}/{_get_hitl_base_path()}/{request_id}/response.json"
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTML templates for Human-in-the-Loop (HITL) events.
|
|
3
|
+
|
|
4
|
+
This module contains all HTML template strings used by the HITL plugin.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_index_page() -> str:
|
|
9
|
+
"""Landing page with instructions."""
|
|
10
|
+
return """
|
|
11
|
+
<!DOCTYPE html>
|
|
12
|
+
<html>
|
|
13
|
+
<head>
|
|
14
|
+
<title>HITL Event Service</title>
|
|
15
|
+
<style>
|
|
16
|
+
body { font-family: Arial, sans-serif; max-width: 800px; margin: 50px auto; padding: 20px; }
|
|
17
|
+
h1 { color: #333; }
|
|
18
|
+
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; }
|
|
19
|
+
.endpoint { margin: 20px 0; padding: 15px; background: #f9f9f9; border-radius: 5px; }
|
|
20
|
+
</style>
|
|
21
|
+
</head>
|
|
22
|
+
<body>
|
|
23
|
+
<h1>Human-in-the-Loop Event Service</h1>
|
|
24
|
+
<p>This service allows humans to provide input to paused Flyte workflow events.</p>
|
|
25
|
+
|
|
26
|
+
<div class="endpoint">
|
|
27
|
+
<h3>Submit Input</h3>
|
|
28
|
+
<p>To submit input for a pending event, visit:</p>
|
|
29
|
+
<code>GET /form/{request_id}</code>
|
|
30
|
+
<p>Or POST directly to:</p>
|
|
31
|
+
<code>POST /submit</code>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div class="endpoint">
|
|
35
|
+
<h3>Check Event Status</h3>
|
|
36
|
+
<code>GET /status/{request_id}</code>
|
|
37
|
+
</div>
|
|
38
|
+
</body>
|
|
39
|
+
</html>
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_bool_select_element() -> str:
|
|
44
|
+
"""HTML select element for boolean input."""
|
|
45
|
+
return """
|
|
46
|
+
<select
|
|
47
|
+
name="value"
|
|
48
|
+
required
|
|
49
|
+
style="
|
|
50
|
+
width: 100%;
|
|
51
|
+
padding: 12px;
|
|
52
|
+
font-size: 16px;
|
|
53
|
+
border: 1px solid #ddd;
|
|
54
|
+
border-radius: 4px;
|
|
55
|
+
box-sizing: border-box;
|
|
56
|
+
margin-bottom: 15px;"
|
|
57
|
+
onchange="this.form.submit()"
|
|
58
|
+
>
|
|
59
|
+
<option value="">-- Select --</option>
|
|
60
|
+
<option value="true">True</option>
|
|
61
|
+
<option value="false">False</option>
|
|
62
|
+
</select>
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_input_form_page(
|
|
67
|
+
event_name: str,
|
|
68
|
+
prompt: str,
|
|
69
|
+
data_type_name: str,
|
|
70
|
+
request_id: str,
|
|
71
|
+
response_path: str,
|
|
72
|
+
input_element: str,
|
|
73
|
+
) -> str:
|
|
74
|
+
"""HTML page for the input form."""
|
|
75
|
+
return f"""
|
|
76
|
+
<!DOCTYPE html>
|
|
77
|
+
<html>
|
|
78
|
+
<head>
|
|
79
|
+
<title>Event Input Required</title>
|
|
80
|
+
<style>
|
|
81
|
+
body {{
|
|
82
|
+
font-family: Arial, sans-serif;
|
|
83
|
+
max-width: 500px;
|
|
84
|
+
margin: 25px auto;
|
|
85
|
+
padding: 20px;
|
|
86
|
+
}}
|
|
87
|
+
.card {{
|
|
88
|
+
background: #fff;
|
|
89
|
+
border-radius: 8px;
|
|
90
|
+
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
91
|
+
padding: 30px;
|
|
92
|
+
}}
|
|
93
|
+
h1 {{ color: #333; margin-top: 0; }}
|
|
94
|
+
.event-name {{ color: #4CAF50; font-weight: bold; margin-bottom: 10px; }}
|
|
95
|
+
.prompt {{ color: #666; margin-bottom: 20px; }}
|
|
96
|
+
.request-id {{ font-size: 12px; color: #999; margin-bottom: 20px; }}
|
|
97
|
+
.data-type {{ font-size: 12px; color: #666; margin-bottom: 10px; }}
|
|
98
|
+
input[type="number"], input[type="text"] {{
|
|
99
|
+
width: 100%;
|
|
100
|
+
padding: 12px;
|
|
101
|
+
font-size: 16px;
|
|
102
|
+
border: 1px solid #ddd;
|
|
103
|
+
border-radius: 4px;
|
|
104
|
+
box-sizing: border-box;
|
|
105
|
+
margin-bottom: 15px;
|
|
106
|
+
}}
|
|
107
|
+
button {{
|
|
108
|
+
width: 100%;
|
|
109
|
+
padding: 12px;
|
|
110
|
+
font-size: 16px;
|
|
111
|
+
background: #4CAF50;
|
|
112
|
+
color: white;
|
|
113
|
+
border: none;
|
|
114
|
+
border-radius: 4px;
|
|
115
|
+
cursor: pointer;
|
|
116
|
+
}}
|
|
117
|
+
button:hover {{ background: #45a049; }}
|
|
118
|
+
</style>
|
|
119
|
+
</head>
|
|
120
|
+
<body>
|
|
121
|
+
<div class="card">
|
|
122
|
+
<h1>Event Input Required</h1>
|
|
123
|
+
<p class="event-name">Event: {event_name}</p>
|
|
124
|
+
<p class="prompt">{prompt}</p>
|
|
125
|
+
<p class="data-type">Expected type: <code>{data_type_name}</code></p>
|
|
126
|
+
<p class="request-id">Request ID: {request_id}</p>
|
|
127
|
+
<form action="/submit" method="post">
|
|
128
|
+
<input type="hidden" name="request_id" value="{request_id}">
|
|
129
|
+
<input type="hidden" name="data_type" value="{data_type_name}">
|
|
130
|
+
<input type="hidden" name="response_path" value="{response_path}">
|
|
131
|
+
{input_element}
|
|
132
|
+
<button type="submit">Submit</button>
|
|
133
|
+
</form>
|
|
134
|
+
</div>
|
|
135
|
+
</body>
|
|
136
|
+
</html>
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_submission_success_page(
|
|
141
|
+
request_id: str,
|
|
142
|
+
value: str,
|
|
143
|
+
data_type: str,
|
|
144
|
+
message: str,
|
|
145
|
+
) -> str:
|
|
146
|
+
"""HTML page displayed after successful submission."""
|
|
147
|
+
import html as html_module
|
|
148
|
+
|
|
149
|
+
return f"""
|
|
150
|
+
<!DOCTYPE html>
|
|
151
|
+
<html>
|
|
152
|
+
<head>
|
|
153
|
+
<title>Submission Successful</title>
|
|
154
|
+
<style>
|
|
155
|
+
body {{
|
|
156
|
+
font-family: Arial, sans-serif;
|
|
157
|
+
max-width: 500px;
|
|
158
|
+
margin: 25px auto;
|
|
159
|
+
padding: 20px;
|
|
160
|
+
}}
|
|
161
|
+
.card {{
|
|
162
|
+
background: #fff;
|
|
163
|
+
border-radius: 8px;
|
|
164
|
+
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
165
|
+
padding: 30px;
|
|
166
|
+
}}
|
|
167
|
+
h1 {{ color: #4CAF50; margin-top: 0; }}
|
|
168
|
+
.message {{ color: #666; margin-bottom: 20px; font-size: 16px; }}
|
|
169
|
+
.details {{
|
|
170
|
+
background: #f9f9f9;
|
|
171
|
+
border-radius: 6px;
|
|
172
|
+
padding: 15px;
|
|
173
|
+
margin-top: 20px;
|
|
174
|
+
}}
|
|
175
|
+
.detail-row {{
|
|
176
|
+
display: flex;
|
|
177
|
+
justify-content: space-between;
|
|
178
|
+
padding: 8px 0;
|
|
179
|
+
border-bottom: 1px solid #eee;
|
|
180
|
+
}}
|
|
181
|
+
.detail-row:last-child {{ border-bottom: none; }}
|
|
182
|
+
.detail-label {{ font-weight: bold; color: #333; }}
|
|
183
|
+
.detail-value {{ color: #666; font-family: monospace; }}
|
|
184
|
+
.status-badge {{
|
|
185
|
+
display: inline-block;
|
|
186
|
+
background: #4CAF50;
|
|
187
|
+
color: white;
|
|
188
|
+
padding: 4px 12px;
|
|
189
|
+
border-radius: 12px;
|
|
190
|
+
font-size: 14px;
|
|
191
|
+
}}
|
|
192
|
+
</style>
|
|
193
|
+
</head>
|
|
194
|
+
<body>
|
|
195
|
+
<div class="card">
|
|
196
|
+
<h1>Submission Successful</h1>
|
|
197
|
+
<p class="message">{html_module.escape(message)}</p>
|
|
198
|
+
<div class="details">
|
|
199
|
+
<div class="detail-row">
|
|
200
|
+
<span class="detail-label">Status</span>
|
|
201
|
+
<span class="status-badge">Submitted</span>
|
|
202
|
+
</div>
|
|
203
|
+
<div class="detail-row">
|
|
204
|
+
<span class="detail-label">Request ID</span>
|
|
205
|
+
<span class="detail-value">{html_module.escape(request_id)}</span>
|
|
206
|
+
</div>
|
|
207
|
+
<div class="detail-row">
|
|
208
|
+
<span class="detail-label">Value</span>
|
|
209
|
+
<span class="detail-value">{html_module.escape(str(value))}</span>
|
|
210
|
+
</div>
|
|
211
|
+
<div class="detail-row">
|
|
212
|
+
<span class="detail-label">Data Type</span>
|
|
213
|
+
<span class="detail-value">{html_module.escape(data_type)}</span>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
</body>
|
|
218
|
+
</html>
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def get_event_report_html(
|
|
223
|
+
form_url: str,
|
|
224
|
+
api_url: str,
|
|
225
|
+
curl_body: str,
|
|
226
|
+
type_name: str,
|
|
227
|
+
) -> str:
|
|
228
|
+
"""HTML report for the event input form shown in the Flyte UI."""
|
|
229
|
+
import html as html_module
|
|
230
|
+
|
|
231
|
+
return f"""
|
|
232
|
+
<style>
|
|
233
|
+
.hitl-container {{
|
|
234
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
235
|
+
max-width: 800px;
|
|
236
|
+
margin: 20px auto;
|
|
237
|
+
padding: 20px;
|
|
238
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
239
|
+
border-radius: 12px;
|
|
240
|
+
color: white;
|
|
241
|
+
}}
|
|
242
|
+
.hitl-header {{
|
|
243
|
+
text-align: center;
|
|
244
|
+
margin-bottom: 20px;
|
|
245
|
+
}}
|
|
246
|
+
.hitl-header h1 {{
|
|
247
|
+
margin: 0;
|
|
248
|
+
font-size: 1.8em;
|
|
249
|
+
}}
|
|
250
|
+
.hitl-section {{
|
|
251
|
+
background: rgba(255, 255, 255, 0.95);
|
|
252
|
+
border-radius: 8px;
|
|
253
|
+
padding: 20px;
|
|
254
|
+
margin-bottom: 15px;
|
|
255
|
+
color: #333;
|
|
256
|
+
}}
|
|
257
|
+
.hitl-section h2 {{
|
|
258
|
+
margin-top: 0;
|
|
259
|
+
color: #667eea;
|
|
260
|
+
font-size: 1.2em;
|
|
261
|
+
border-bottom: 2px solid #667eea;
|
|
262
|
+
padding-bottom: 8px;
|
|
263
|
+
}}
|
|
264
|
+
.hitl-url {{
|
|
265
|
+
background: #f5f5f5;
|
|
266
|
+
padding: 12px;
|
|
267
|
+
border-radius: 6px;
|
|
268
|
+
font-family: 'Monaco', 'Menlo', monospace;
|
|
269
|
+
font-size: 0.9em;
|
|
270
|
+
word-break: break-all;
|
|
271
|
+
border-left: 4px solid #667eea;
|
|
272
|
+
}}
|
|
273
|
+
.hitl-url a {{
|
|
274
|
+
color: #667eea;
|
|
275
|
+
text-decoration: none;
|
|
276
|
+
}}
|
|
277
|
+
.hitl-url a:hover {{
|
|
278
|
+
text-decoration: underline;
|
|
279
|
+
}}
|
|
280
|
+
.hitl-code {{
|
|
281
|
+
background: #1e1e1e;
|
|
282
|
+
color: #d4d4d4;
|
|
283
|
+
padding: 15px;
|
|
284
|
+
border-radius: 6px;
|
|
285
|
+
font-family: 'Monaco', 'Menlo', monospace;
|
|
286
|
+
font-size: 0.85em;
|
|
287
|
+
overflow-x: auto;
|
|
288
|
+
white-space: pre-wrap;
|
|
289
|
+
word-break: break-all;
|
|
290
|
+
}}
|
|
291
|
+
.hitl-info {{
|
|
292
|
+
display: grid;
|
|
293
|
+
grid-template-columns: 120px 1fr;
|
|
294
|
+
gap: 8px;
|
|
295
|
+
margin-bottom: 15px;
|
|
296
|
+
}}
|
|
297
|
+
.hitl-info-label {{
|
|
298
|
+
font-weight: bold;
|
|
299
|
+
color: #667eea;
|
|
300
|
+
}}
|
|
301
|
+
.hitl-info-value {{
|
|
302
|
+
font-family: 'Monaco', 'Menlo', monospace;
|
|
303
|
+
font-size: 0.9em;
|
|
304
|
+
}}
|
|
305
|
+
</style>
|
|
306
|
+
<div class="hitl-container">
|
|
307
|
+
<div class="hitl-header">
|
|
308
|
+
<h1>Event Input Required</h1>
|
|
309
|
+
</div>
|
|
310
|
+
|
|
311
|
+
<div class="hitl-section">
|
|
312
|
+
<h2>Option 1: Web Form</h2>
|
|
313
|
+
<p>
|
|
314
|
+
Submit your input using <a href="{html_module.escape(form_url)}" target="_blank">this form</a>:
|
|
315
|
+
</p>
|
|
316
|
+
</div>
|
|
317
|
+
|
|
318
|
+
<div class="hitl-section">
|
|
319
|
+
<h2>Option 2: Programmatic API (curl)</h2>
|
|
320
|
+
<p>Use the following curl command to submit input programmatically:</p>
|
|
321
|
+
<div class="hitl-code">curl -X POST "{html_module.escape(api_url)}" \\
|
|
322
|
+
-H "Content-Type: application/json" \\
|
|
323
|
+
-H "Authorization: Bearer {{FLYTE_API_KEY}}" \\
|
|
324
|
+
-d '{html_module.escape(curl_body)}'</div>
|
|
325
|
+
<p style="margin-top: 15px; font-size: 0.9em; color: #666;">
|
|
326
|
+
<strong>Note:</strong> Replace <code>{{your_value}}</code> with the actual value you want to
|
|
327
|
+
submit. The value should match the expected type: <code>{html_module.escape(type_name)}</code>
|
|
328
|
+
|
|
329
|
+
<p>Replace <code>{{FLYTE_API_KEY}}</code> with your Flyte API key.</p>
|
|
330
|
+
</p>
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
"""
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flyteplugins-hitl
|
|
3
|
+
Version: 2.0.6
|
|
4
|
+
Summary: Human-in-the-Loop (HITL) plugin for Flyte
|
|
5
|
+
Author: Flyte Contributors
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: fastapi
|
|
9
|
+
Requires-Dist: uvicorn
|
|
10
|
+
Requires-Dist: python-multipart
|
|
11
|
+
Requires-Dist: aiofiles
|
|
12
|
+
Requires-Dist: flyte
|
|
13
|
+
|
|
14
|
+
# Flyte HITL Plugin
|
|
15
|
+
|
|
16
|
+
Human-in-the-Loop (HITL) plugin for Flyte. This plugin provides an event-based API for pausing workflows and waiting for human input.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install flyteplugins-hitl
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import flyte
|
|
28
|
+
import flyteplugins.hitl as hitl
|
|
29
|
+
|
|
30
|
+
task_env = flyte.TaskEnvironment(
|
|
31
|
+
name="my-hitl-workflow",
|
|
32
|
+
image=flyte.Image.from_debian_base(python_version=(3, 12)),
|
|
33
|
+
resources=flyte.Resources(cpu=1, memory="512Mi"),
|
|
34
|
+
depends_on=[hitl.env],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@task_env.task
|
|
39
|
+
async def task1() -> int:
|
|
40
|
+
"""First task - returns an automated value."""
|
|
41
|
+
return 42
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@task_env.task
|
|
45
|
+
async def task2(x: int, y: int) -> int:
|
|
46
|
+
"""Second task - combines automated and human input."""
|
|
47
|
+
return x + y
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@task_env.task(report=True)
|
|
51
|
+
async def main() -> int:
|
|
52
|
+
"""
|
|
53
|
+
Main workflow that orchestrates automated and human-in-the-loop tasks.
|
|
54
|
+
|
|
55
|
+
Flow:
|
|
56
|
+
1. task1() runs and returns an automated value (x)
|
|
57
|
+
2. Create an Event (serves the app) and wait for human input (y)
|
|
58
|
+
3. task2(x, y) combines both values and returns the result
|
|
59
|
+
"""
|
|
60
|
+
print("Starting HITL workflow...")
|
|
61
|
+
|
|
62
|
+
# Step 1: Automated task
|
|
63
|
+
x = await task1()
|
|
64
|
+
print(f"task1 completed: x = {x}")
|
|
65
|
+
|
|
66
|
+
# Step 2: Human-in-the-loop using the Event-based API
|
|
67
|
+
# Create an event (this serves the app if not already running)
|
|
68
|
+
event = await hitl.new_event.aio(
|
|
69
|
+
"integer_input_event",
|
|
70
|
+
data_type=int,
|
|
71
|
+
scope="run",
|
|
72
|
+
prompt="What should I add to x?",
|
|
73
|
+
)
|
|
74
|
+
y = await event.wait.aio()
|
|
75
|
+
print(f"Event completed: y = {y}")
|
|
76
|
+
|
|
77
|
+
# Step 3: Combine results
|
|
78
|
+
result = await task2(x, y)
|
|
79
|
+
print(f"task2 completed: result = {result}")
|
|
80
|
+
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
flyte.init_from_config()
|
|
86
|
+
run = flyte.run(main)
|
|
87
|
+
print(f"Run URL: {run.url}")
|
|
88
|
+
run.wait()
|
|
89
|
+
print(f"Result: {run.outputs()}")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Features
|
|
93
|
+
|
|
94
|
+
- **Event-based API**: Create events that pause workflow execution until human input is received
|
|
95
|
+
- **Web Form**: Automatically generates a web form for human input
|
|
96
|
+
- **Programmatic API**: Submit input via curl or any HTTP client
|
|
97
|
+
- **Type Safety**: Supports int, float, str, and bool data types
|
|
98
|
+
|
|
99
|
+
## API Reference
|
|
100
|
+
|
|
101
|
+
### `hitl.new_event(name, data_type, scope, prompt, ...)`
|
|
102
|
+
|
|
103
|
+
Create a new human-in-the-loop event.
|
|
104
|
+
|
|
105
|
+
**Parameters:**
|
|
106
|
+
- `name` (str): A descriptive name for the event
|
|
107
|
+
- `data_type` (Type): The expected type of the input (int, float, str, bool)
|
|
108
|
+
- `scope` (str): The scope of the event. Currently only "run" is supported.
|
|
109
|
+
- `prompt` (str): The prompt to display to the human
|
|
110
|
+
- `timeout_seconds` (int): Maximum time to wait for human input (default: 3600)
|
|
111
|
+
- `poll_interval_seconds` (int): How often to check for a response (default: 5)
|
|
112
|
+
|
|
113
|
+
**Returns:** An `Event` object
|
|
114
|
+
|
|
115
|
+
### `event.wait()` / `await event.wait.aio()`
|
|
116
|
+
|
|
117
|
+
Wait for human input and return the result.
|
|
118
|
+
|
|
119
|
+
**Returns:** The value provided by the human, converted to the event's data_type
|
|
120
|
+
|
|
121
|
+
### `hitl.env`
|
|
122
|
+
|
|
123
|
+
The `TaskEnvironment` that provides the HITL app infrastructure. Add this to your task environment's `depends_on` list.
|
|
124
|
+
|
|
125
|
+
### `hitl.Event`
|
|
126
|
+
|
|
127
|
+
The Event class for type annotations and advanced usage.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
flyteplugins/hitl/__init__.py,sha256=ZZPgdpAslvolvyRShRpg2Cl74sOLZBeVg58iRX5MRcU,1231
|
|
2
|
+
flyteplugins/hitl/_app.py,sha256=7FXofQSs0uTsk8y7_FO1fl5BeY1LRUsaCjq3XimP0WY,9091
|
|
3
|
+
flyteplugins/hitl/_event.py,sha256=V7HmLShKMprQ9LbWPJ-7FXqDnw1HsxNstSdfO8YU6z4,13978
|
|
4
|
+
flyteplugins/hitl/_helpers.py,sha256=LJZnViLf3691ErpECEWt0-voOdF5I50-slgRmxv2iJ4,1996
|
|
5
|
+
flyteplugins/hitl/_html_templates.py,sha256=2ShgzZ3R5SHmHOj5DVz09ihan5ysLbPtbKjVh8u4eLE,11238
|
|
6
|
+
flyteplugins_hitl-2.0.6.dist-info/METADATA,sha256=mtnJzVI2N097u0suJVJNnC2QM0AWKzY-Q9FLmTX_WgU,3472
|
|
7
|
+
flyteplugins_hitl-2.0.6.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
flyteplugins_hitl-2.0.6.dist-info/top_level.txt,sha256=cgd779rPu9EsvdtuYgUxNHHgElaQvPn74KhB5XSeMBE,13
|
|
9
|
+
flyteplugins_hitl-2.0.6.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flyteplugins
|