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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ flyteplugins