griptape-nodes 0.44.0__py3-none-any.whl → 0.45.1__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.
- griptape_nodes/__init__.py +5 -1
- griptape_nodes/app/api.py +2 -35
- griptape_nodes/app/app.py +70 -3
- griptape_nodes/app/watch.py +5 -2
- griptape_nodes/drivers/storage/base_storage_driver.py +37 -0
- griptape_nodes/drivers/storage/griptape_cloud_storage_driver.py +2 -1
- griptape_nodes/exe_types/core_types.py +109 -9
- griptape_nodes/exe_types/node_types.py +19 -5
- griptape_nodes/node_library/workflow_registry.py +29 -0
- griptape_nodes/retained_mode/events/app_events.py +3 -2
- griptape_nodes/retained_mode/events/base_events.py +9 -0
- griptape_nodes/retained_mode/events/sync_events.py +60 -0
- griptape_nodes/retained_mode/events/workflow_events.py +231 -0
- griptape_nodes/retained_mode/griptape_nodes.py +8 -0
- griptape_nodes/retained_mode/managers/library_manager.py +6 -18
- griptape_nodes/retained_mode/managers/node_manager.py +2 -2
- griptape_nodes/retained_mode/managers/operation_manager.py +7 -0
- griptape_nodes/retained_mode/managers/settings.py +5 -0
- griptape_nodes/retained_mode/managers/sync_manager.py +498 -0
- griptape_nodes/retained_mode/managers/workflow_manager.py +682 -28
- griptape_nodes/retained_mode/retained_mode.py +23 -0
- griptape_nodes/updater/__init__.py +4 -2
- griptape_nodes/utils/uv_utils.py +18 -0
- {griptape_nodes-0.44.0.dist-info → griptape_nodes-0.45.1.dist-info}/METADATA +2 -1
- {griptape_nodes-0.44.0.dist-info → griptape_nodes-0.45.1.dist-info}/RECORD +27 -24
- {griptape_nodes-0.44.0.dist-info → griptape_nodes-0.45.1.dist-info}/WHEEL +1 -1
- {griptape_nodes-0.44.0.dist-info → griptape_nodes-0.45.1.dist-info}/entry_points.txt +0 -0
griptape_nodes/__init__.py
CHANGED
|
@@ -31,6 +31,7 @@ with console.status("Loading Griptape Nodes...") as status:
|
|
|
31
31
|
from griptape_nodes.retained_mode.managers.config_manager import ConfigManager
|
|
32
32
|
from griptape_nodes.retained_mode.managers.os_manager import OSManager
|
|
33
33
|
from griptape_nodes.retained_mode.managers.secrets_manager import SecretsManager
|
|
34
|
+
from griptape_nodes.utils.uv_utils import find_uv_bin
|
|
34
35
|
from griptape_nodes.utils.version_utils import get_complete_version_string, get_current_version, get_install_source
|
|
35
36
|
|
|
36
37
|
CONFIG_DIR = xdg_config_home() / "griptape_nodes"
|
|
@@ -808,7 +809,10 @@ def _uninstall_self() -> None:
|
|
|
808
809
|
# Remove the executable
|
|
809
810
|
console.print("[bold]Removing the executable...[/bold]")
|
|
810
811
|
console.print("[bold yellow]When done, press Enter to exit.[/bold yellow]")
|
|
811
|
-
|
|
812
|
+
|
|
813
|
+
# Remove the tool using UV
|
|
814
|
+
uv_path = find_uv_bin()
|
|
815
|
+
os_manager.replace_process([uv_path, "tool", "uninstall", "griptape-nodes"])
|
|
812
816
|
|
|
813
817
|
|
|
814
818
|
def _parse_key_value_pairs(pairs: list[str] | None) -> dict[str, Any] | None:
|
griptape_nodes/app/api.py
CHANGED
|
@@ -13,8 +13,6 @@ from fastapi.middleware.cors import CORSMiddleware
|
|
|
13
13
|
from fastapi.staticfiles import StaticFiles
|
|
14
14
|
from rich.logging import RichHandler
|
|
15
15
|
|
|
16
|
-
from griptape_nodes.retained_mode.events.base_events import EventRequest, deserialize_event
|
|
17
|
-
|
|
18
16
|
if TYPE_CHECKING:
|
|
19
17
|
from queue import Queue
|
|
20
18
|
|
|
@@ -159,6 +157,8 @@ async def _delete_static_file(file_path: str, static_directory: Annotated[Path,
|
|
|
159
157
|
|
|
160
158
|
@app.post("/engines/request")
|
|
161
159
|
async def _create_event(request: Request, queue: Annotated[Queue, Depends(get_event_queue)]) -> None:
|
|
160
|
+
from .app import _process_api_event
|
|
161
|
+
|
|
162
162
|
body = await request.json()
|
|
163
163
|
_process_api_event(body, queue)
|
|
164
164
|
|
|
@@ -193,36 +193,3 @@ def start_api(static_directory: Path, queue: Queue) -> None:
|
|
|
193
193
|
uvicorn.run(
|
|
194
194
|
app, host=STATIC_SERVER_HOST, port=STATIC_SERVER_PORT, log_level=STATIC_SERVER_LOG_LEVEL, log_config=None
|
|
195
195
|
)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
def _process_api_event(event: dict, event_queue: Queue) -> None:
|
|
199
|
-
"""Process API events and send them to the event queue."""
|
|
200
|
-
payload = event.get("payload", {})
|
|
201
|
-
|
|
202
|
-
try:
|
|
203
|
-
payload["request"]
|
|
204
|
-
except KeyError:
|
|
205
|
-
msg = "Error: 'request' was expected but not found."
|
|
206
|
-
raise RuntimeError(msg) from None
|
|
207
|
-
|
|
208
|
-
try:
|
|
209
|
-
event_type = payload["event_type"]
|
|
210
|
-
if event_type != "EventRequest":
|
|
211
|
-
msg = "Error: 'event_type' was found on request, but did not match 'EventRequest' as expected."
|
|
212
|
-
raise RuntimeError(msg) from None
|
|
213
|
-
except KeyError:
|
|
214
|
-
msg = "Error: 'event_type' not found in request."
|
|
215
|
-
raise RuntimeError(msg) from None
|
|
216
|
-
|
|
217
|
-
# Now attempt to convert it into an EventRequest.
|
|
218
|
-
try:
|
|
219
|
-
request_event = deserialize_event(json_data=payload)
|
|
220
|
-
if not isinstance(request_event, EventRequest):
|
|
221
|
-
msg = f"Deserialized event is not an EventRequest: {type(request_event)}"
|
|
222
|
-
raise TypeError(msg) # noqa: TRY301
|
|
223
|
-
except Exception as e:
|
|
224
|
-
msg = f"Unable to convert request JSON into a valid EventRequest object. Error Message: '{e}'"
|
|
225
|
-
raise RuntimeError(msg) from None
|
|
226
|
-
|
|
227
|
-
# Add the event to the queue
|
|
228
|
-
event_queue.put(request_event)
|
griptape_nodes/app/app.py
CHANGED
|
@@ -9,7 +9,7 @@ import sys
|
|
|
9
9
|
import threading
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from queue import Queue
|
|
12
|
-
from typing import Any
|
|
12
|
+
from typing import Any, cast
|
|
13
13
|
from urllib.parse import urljoin
|
|
14
14
|
|
|
15
15
|
from griptape.events import (
|
|
@@ -24,9 +24,9 @@ from websockets.asyncio.client import connect
|
|
|
24
24
|
from websockets.exceptions import ConnectionClosed, WebSocketException
|
|
25
25
|
|
|
26
26
|
from griptape_nodes.mcp_server.server import main as mcp_server
|
|
27
|
+
from griptape_nodes.retained_mode.events import app_events, execution_events
|
|
27
28
|
|
|
28
29
|
# This import is necessary to register all events, even if not technically used
|
|
29
|
-
from griptape_nodes.retained_mode.events import app_events, execution_events
|
|
30
30
|
from griptape_nodes.retained_mode.events.base_events import (
|
|
31
31
|
AppEvent,
|
|
32
32
|
EventRequest,
|
|
@@ -36,11 +36,14 @@ from griptape_nodes.retained_mode.events.base_events import (
|
|
|
36
36
|
ExecutionGriptapeNodeEvent,
|
|
37
37
|
GriptapeNodeEvent,
|
|
38
38
|
ProgressEvent,
|
|
39
|
+
RequestPayload,
|
|
40
|
+
SkipTheLineMixin,
|
|
41
|
+
deserialize_event,
|
|
39
42
|
)
|
|
40
43
|
from griptape_nodes.retained_mode.events.logger_events import LogHandlerEvent
|
|
41
44
|
from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
|
|
42
45
|
|
|
43
|
-
from .api import
|
|
46
|
+
from .api import start_api
|
|
44
47
|
|
|
45
48
|
# This is a global event queue that will be used to pass events between threads
|
|
46
49
|
event_queue = Queue()
|
|
@@ -395,3 +398,67 @@ def __schedule_async_task(coro: Any) -> None:
|
|
|
395
398
|
asyncio.run_coroutine_threadsafe(coro, event_loop)
|
|
396
399
|
else:
|
|
397
400
|
logger.warning("Event loop not available for scheduling async task")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _process_api_event(event: dict, event_queue: Queue) -> None:
|
|
404
|
+
"""Process API events and send them to the event queue."""
|
|
405
|
+
payload = event.get("payload", {})
|
|
406
|
+
|
|
407
|
+
try:
|
|
408
|
+
payload["request"]
|
|
409
|
+
except KeyError:
|
|
410
|
+
msg = "Error: 'request' was expected but not found."
|
|
411
|
+
raise RuntimeError(msg) from None
|
|
412
|
+
|
|
413
|
+
try:
|
|
414
|
+
event_type = payload["event_type"]
|
|
415
|
+
if event_type != "EventRequest":
|
|
416
|
+
msg = "Error: 'event_type' was found on request, but did not match 'EventRequest' as expected."
|
|
417
|
+
raise RuntimeError(msg) from None
|
|
418
|
+
except KeyError:
|
|
419
|
+
msg = "Error: 'event_type' not found in request."
|
|
420
|
+
raise RuntimeError(msg) from None
|
|
421
|
+
|
|
422
|
+
# Now attempt to convert it into an EventRequest.
|
|
423
|
+
try:
|
|
424
|
+
request_event = deserialize_event(json_data=payload)
|
|
425
|
+
if not isinstance(request_event, EventRequest):
|
|
426
|
+
msg = f"Deserialized event is not an EventRequest: {type(request_event)}"
|
|
427
|
+
raise TypeError(msg) # noqa: TRY301
|
|
428
|
+
except Exception as e:
|
|
429
|
+
msg = f"Unable to convert request JSON into a valid EventRequest object. Error Message: '{e}'"
|
|
430
|
+
raise RuntimeError(msg) from None
|
|
431
|
+
|
|
432
|
+
# Check if the event implements SkipTheLineMixin for priority processing
|
|
433
|
+
if isinstance(request_event.request, SkipTheLineMixin):
|
|
434
|
+
# Handle the event immediately without queuing
|
|
435
|
+
# The request is guaranteed to be a RequestPayload since it passed earlier validation
|
|
436
|
+
result_payload = GriptapeNodes.handle_request(
|
|
437
|
+
cast("RequestPayload", request_event.request),
|
|
438
|
+
response_topic=request_event.response_topic,
|
|
439
|
+
request_id=request_event.request_id,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
# Create the result event and emit response immediately
|
|
443
|
+
if result_payload.succeeded():
|
|
444
|
+
result_event = EventResultSuccess(
|
|
445
|
+
request=cast("RequestPayload", request_event.request),
|
|
446
|
+
request_id=request_event.request_id,
|
|
447
|
+
result=result_payload,
|
|
448
|
+
response_topic=request_event.response_topic,
|
|
449
|
+
)
|
|
450
|
+
dest_socket = "success_result"
|
|
451
|
+
else:
|
|
452
|
+
result_event = EventResultFailure(
|
|
453
|
+
request=cast("RequestPayload", request_event.request),
|
|
454
|
+
request_id=request_event.request_id,
|
|
455
|
+
result=result_payload,
|
|
456
|
+
response_topic=request_event.response_topic,
|
|
457
|
+
)
|
|
458
|
+
dest_socket = "failure_result"
|
|
459
|
+
|
|
460
|
+
# Emit the response immediately
|
|
461
|
+
__schedule_async_task(__emit_message(dest_socket, result_event.json(), topic=result_event.response_topic))
|
|
462
|
+
else:
|
|
463
|
+
# Add the event to the queue for normal processing
|
|
464
|
+
event_queue.put(request_event)
|
griptape_nodes/app/watch.py
CHANGED
|
@@ -8,6 +8,8 @@ from typing import Any
|
|
|
8
8
|
from watchdog.events import PatternMatchingEventHandler
|
|
9
9
|
from watchdog.observers import Observer
|
|
10
10
|
|
|
11
|
+
from griptape_nodes.utils.uv_utils import find_uv_bin
|
|
12
|
+
|
|
11
13
|
|
|
12
14
|
class ReloadHandler(PatternMatchingEventHandler):
|
|
13
15
|
def __init__(
|
|
@@ -30,8 +32,9 @@ class ReloadHandler(PatternMatchingEventHandler):
|
|
|
30
32
|
def start_process(self) -> None:
|
|
31
33
|
if self.process:
|
|
32
34
|
self.process.terminate()
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
uv_path = find_uv_bin()
|
|
36
|
+
self.process = subprocess.Popen( # noqa: S603
|
|
37
|
+
[uv_path, "run", "gtn"],
|
|
35
38
|
stdout=sys.stdout,
|
|
36
39
|
stderr=sys.stderr,
|
|
37
40
|
)
|
|
@@ -60,6 +60,43 @@ class BaseStorageDriver(ABC):
|
|
|
60
60
|
"""
|
|
61
61
|
...
|
|
62
62
|
|
|
63
|
+
def upload_file(self, file_name: str, file_content: bytes) -> str:
|
|
64
|
+
"""Upload a file to storage.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
file_name: The name of the file to upload.
|
|
68
|
+
file_content: The file content as bytes.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
The URL where the file can be accessed.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
RuntimeError: If file upload fails.
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
# Get signed upload URL
|
|
78
|
+
upload_response = self.create_signed_upload_url(file_name)
|
|
79
|
+
|
|
80
|
+
# Upload the file using the signed URL
|
|
81
|
+
response = httpx.request(
|
|
82
|
+
upload_response["method"],
|
|
83
|
+
upload_response["url"],
|
|
84
|
+
content=file_content,
|
|
85
|
+
headers=upload_response["headers"],
|
|
86
|
+
)
|
|
87
|
+
response.raise_for_status()
|
|
88
|
+
|
|
89
|
+
# Return the download URL
|
|
90
|
+
return self.create_signed_download_url(file_name)
|
|
91
|
+
except httpx.HTTPStatusError as e:
|
|
92
|
+
msg = f"Failed to upload file {file_name}: {e}"
|
|
93
|
+
logger.error(msg)
|
|
94
|
+
raise RuntimeError(msg) from e
|
|
95
|
+
except Exception as e:
|
|
96
|
+
msg = f"Unexpected error uploading file {file_name}: {e}"
|
|
97
|
+
logger.error(msg)
|
|
98
|
+
raise RuntimeError(msg) from e
|
|
99
|
+
|
|
63
100
|
def download_file(self, file_name: str) -> bytes:
|
|
64
101
|
"""Download a file from the bucket.
|
|
65
102
|
|
|
@@ -76,7 +76,8 @@ class GriptapeCloudStorageDriver(BaseStorageDriver):
|
|
|
76
76
|
return {"url": response_data["url"], "headers": response_data.get("headers", {}), "method": "PUT"}
|
|
77
77
|
|
|
78
78
|
def create_signed_download_url(self, file_name: str) -> str:
|
|
79
|
-
|
|
79
|
+
full_file_path = self._get_full_file_path(file_name)
|
|
80
|
+
url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/asset-urls/{full_file_path}")
|
|
80
81
|
try:
|
|
81
82
|
response = httpx.post(url, json={"method": "GET"}, headers=self.headers)
|
|
82
83
|
response.raise_for_status()
|
|
@@ -300,11 +300,28 @@ class BaseNodeElement:
|
|
|
300
300
|
self._node_context._emit_parameter_lifecycle_event(child)
|
|
301
301
|
|
|
302
302
|
def remove_child(self, child: BaseNodeElement | str) -> None:
|
|
303
|
+
"""Remove a child element from the hierarchy.
|
|
304
|
+
|
|
305
|
+
This method recursively searches through the element hierarchy to find and remove
|
|
306
|
+
the specified child. When the child is found in a descendant container (e.g., a
|
|
307
|
+
ParameterList), it delegates to that container's remove_child() method to ensure
|
|
308
|
+
proper cleanup and event handling (like marking parent nodes as unresolved).
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
child: The child element to remove, either as an object or by name string
|
|
312
|
+
"""
|
|
303
313
|
ui_elements: list[BaseNodeElement] = [self]
|
|
304
314
|
for ui_element in ui_elements:
|
|
305
315
|
if child in ui_element._children:
|
|
306
|
-
|
|
307
|
-
|
|
316
|
+
# Delegate to the actual parent container's remove_child method.
|
|
317
|
+
# This ensures specialized containers (like ParameterList) can perform
|
|
318
|
+
# their specific cleanup logic (e.g., marking parent nodes as unresolved).
|
|
319
|
+
if ui_element is not self:
|
|
320
|
+
ui_element.remove_child(child)
|
|
321
|
+
else:
|
|
322
|
+
# We are the direct parent, so handle removal directly
|
|
323
|
+
child._parent = None
|
|
324
|
+
ui_element._children.remove(child)
|
|
308
325
|
break
|
|
309
326
|
ui_elements.extend(ui_element._children)
|
|
310
327
|
if self._node_context is not None and isinstance(child, BaseNodeElement):
|
|
@@ -371,8 +388,23 @@ class BaseNodeElement:
|
|
|
371
388
|
return event_data
|
|
372
389
|
|
|
373
390
|
|
|
374
|
-
|
|
375
|
-
|
|
391
|
+
class UIOptionsMixin:
|
|
392
|
+
"""Mixin providing UI options update functionality for classes with ui_options."""
|
|
393
|
+
|
|
394
|
+
def update_ui_options_key(self, key: str, value: Any) -> None:
|
|
395
|
+
"""Update a single UI option key."""
|
|
396
|
+
ui_options = self.ui_options
|
|
397
|
+
ui_options[key] = value
|
|
398
|
+
self.ui_options = ui_options
|
|
399
|
+
|
|
400
|
+
def update_ui_options(self, updates: dict[str, Any]) -> None:
|
|
401
|
+
"""Update multiple UI options at once."""
|
|
402
|
+
ui_options = self.ui_options
|
|
403
|
+
ui_options.update(updates)
|
|
404
|
+
self.ui_options = ui_options
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class ParameterMessage(BaseNodeElement, UIOptionsMixin):
|
|
376
408
|
"""Represents a UI message element, such as a warning or informational text."""
|
|
377
409
|
|
|
378
410
|
# Define default titles as a class-level constant
|
|
@@ -518,11 +550,21 @@ class ParameterMessage(BaseNodeElement):
|
|
|
518
550
|
return event_data
|
|
519
551
|
|
|
520
552
|
|
|
521
|
-
|
|
522
|
-
class ParameterGroup(BaseNodeElement):
|
|
553
|
+
class ParameterGroup(BaseNodeElement, UIOptionsMixin):
|
|
523
554
|
"""UI element for a group of parameters."""
|
|
524
555
|
|
|
525
|
-
ui_options: dict =
|
|
556
|
+
def __init__(self, name: str, ui_options: dict | None = None, **kwargs):
|
|
557
|
+
super().__init__(name=name, **kwargs)
|
|
558
|
+
self._ui_options = ui_options or {}
|
|
559
|
+
|
|
560
|
+
@property
|
|
561
|
+
def ui_options(self) -> dict:
|
|
562
|
+
return self._ui_options
|
|
563
|
+
|
|
564
|
+
@ui_options.setter
|
|
565
|
+
@BaseNodeElement.emits_update_on_write
|
|
566
|
+
def ui_options(self, value: dict) -> None:
|
|
567
|
+
self._ui_options = value
|
|
526
568
|
|
|
527
569
|
def to_dict(self) -> dict[str, Any]:
|
|
528
570
|
"""Returns a nested dictionary representation of this node and its children.
|
|
@@ -630,7 +672,7 @@ class ParameterBase(BaseNodeElement, ABC):
|
|
|
630
672
|
pass
|
|
631
673
|
|
|
632
674
|
|
|
633
|
-
class Parameter(BaseNodeElement):
|
|
675
|
+
class Parameter(BaseNodeElement, UIOptionsMixin):
|
|
634
676
|
# This is the list of types that the Parameter can accept, either externally or when internally treated as a property.
|
|
635
677
|
# Today, we can accept multiple types for input, but only a single output type.
|
|
636
678
|
tooltip: str | list[dict] # Default tooltip, can be string or list of dicts
|
|
@@ -641,7 +683,11 @@ class Parameter(BaseNodeElement):
|
|
|
641
683
|
tooltip_as_input: str | list[dict] | None = None
|
|
642
684
|
tooltip_as_property: str | list[dict] | None = None
|
|
643
685
|
tooltip_as_output: str | list[dict] | None = None
|
|
686
|
+
|
|
687
|
+
# "settable" here means whether it can be assigned to during regular business operation.
|
|
688
|
+
# During save/load, this value IS still serialized to save its proper state.
|
|
644
689
|
settable: bool = True
|
|
690
|
+
|
|
645
691
|
user_defined: bool = False
|
|
646
692
|
_allowed_modes: set = field(
|
|
647
693
|
default_factory=lambda: {
|
|
@@ -837,7 +883,10 @@ class Parameter(BaseNodeElement):
|
|
|
837
883
|
ui_options = ui_options | trait.ui_options_for_trait()
|
|
838
884
|
ui_options = ui_options | self._ui_options
|
|
839
885
|
if self._parent is not None and isinstance(self._parent, ParameterGroup):
|
|
840
|
-
|
|
886
|
+
# Access the field value directly for ParameterGroup
|
|
887
|
+
parent_ui_options = getattr(self._parent, "ui_options", {})
|
|
888
|
+
if isinstance(parent_ui_options, dict):
|
|
889
|
+
ui_options = ui_options | parent_ui_options
|
|
841
890
|
return ui_options
|
|
842
891
|
|
|
843
892
|
@ui_options.setter
|
|
@@ -1198,6 +1247,23 @@ class ParameterContainer(Parameter, ABC):
|
|
|
1198
1247
|
element_type=element_type,
|
|
1199
1248
|
)
|
|
1200
1249
|
|
|
1250
|
+
def __bool__(self) -> bool:
|
|
1251
|
+
"""Parameter containers are always truthy, even when empty.
|
|
1252
|
+
|
|
1253
|
+
This overrides Python's default truthiness behavior for containers with __len__().
|
|
1254
|
+
By default, Python makes objects with __len__() falsy when len() == 0, which
|
|
1255
|
+
caused bugs where empty ParameterList/ParameterDictionary objects would fail
|
|
1256
|
+
'if param' checks and fall back to stale cached values instead of computing
|
|
1257
|
+
fresh empty results.
|
|
1258
|
+
|
|
1259
|
+
Unlike standard Python containers, ParameterContainer objects represent
|
|
1260
|
+
parameter structure/definitions rather than just data, so they remain
|
|
1261
|
+
meaningful even when empty.
|
|
1262
|
+
|
|
1263
|
+
See: https://github.com/griptape-ai/griptape-nodes/issues/1799
|
|
1264
|
+
"""
|
|
1265
|
+
return True
|
|
1266
|
+
|
|
1201
1267
|
@abstractmethod
|
|
1202
1268
|
def add_child_parameter(self) -> Parameter:
|
|
1203
1269
|
pass
|
|
@@ -1329,6 +1395,40 @@ class ParameterList(ParameterContainer):
|
|
|
1329
1395
|
|
|
1330
1396
|
return param
|
|
1331
1397
|
|
|
1398
|
+
def add_child(self, child: BaseNodeElement) -> None:
|
|
1399
|
+
"""Override to mark parent node as unresolved when children are added.
|
|
1400
|
+
|
|
1401
|
+
When a ParameterList gains a child parameter, the parent node needs to be
|
|
1402
|
+
marked as unresolved to trigger re-evaluation of the node's state and outputs.
|
|
1403
|
+
"""
|
|
1404
|
+
super().add_child(child)
|
|
1405
|
+
|
|
1406
|
+
# Mark the parent node as unresolved since the parameter structure changed
|
|
1407
|
+
if self._node_context is not None:
|
|
1408
|
+
# Import at runtime to avoid circular import
|
|
1409
|
+
from griptape_nodes.exe_types.node_types import NodeResolutionState
|
|
1410
|
+
|
|
1411
|
+
self._node_context.make_node_unresolved(
|
|
1412
|
+
current_states_to_trigger_change_event={NodeResolutionState.RESOLVED, NodeResolutionState.RESOLVING}
|
|
1413
|
+
)
|
|
1414
|
+
|
|
1415
|
+
def remove_child(self, child: BaseNodeElement | str) -> None:
|
|
1416
|
+
"""Override to mark parent node as unresolved when children are removed.
|
|
1417
|
+
|
|
1418
|
+
When a ParameterList loses a child parameter, the parent node needs to be
|
|
1419
|
+
marked as unresolved to trigger re-evaluation of the node's state and outputs.
|
|
1420
|
+
"""
|
|
1421
|
+
super().remove_child(child)
|
|
1422
|
+
|
|
1423
|
+
# Mark the parent node as unresolved since the parameter structure changed
|
|
1424
|
+
if self._node_context is not None:
|
|
1425
|
+
# Import at runtime to avoid circular import
|
|
1426
|
+
from griptape_nodes.exe_types.node_types import NodeResolutionState
|
|
1427
|
+
|
|
1428
|
+
self._node_context.make_node_unresolved(
|
|
1429
|
+
current_states_to_trigger_change_event={NodeResolutionState.RESOLVED, NodeResolutionState.RESOLVING}
|
|
1430
|
+
)
|
|
1431
|
+
|
|
1332
1432
|
|
|
1333
1433
|
class ParameterKeyValuePair(Parameter):
|
|
1334
1434
|
def __init__( # noqa: PLR0913
|
|
@@ -368,8 +368,10 @@ class BaseNode(ABC):
|
|
|
368
368
|
"""
|
|
369
369
|
parameter = self.get_parameter_by_name(param)
|
|
370
370
|
if parameter is not None:
|
|
371
|
-
trait
|
|
372
|
-
|
|
371
|
+
# Find the Options trait by type since element_id is a UUID
|
|
372
|
+
traits = parameter.find_elements_by_type(Options)
|
|
373
|
+
if traits:
|
|
374
|
+
trait = traits[0] # Take the first Options trait
|
|
373
375
|
trait.choices = choices
|
|
374
376
|
|
|
375
377
|
if default in choices:
|
|
@@ -378,6 +380,13 @@ class BaseNode(ABC):
|
|
|
378
380
|
else:
|
|
379
381
|
msg = f"Default model '{default}' is not in the provided choices."
|
|
380
382
|
raise ValueError(msg)
|
|
383
|
+
|
|
384
|
+
# Update the manually set UI options to include the new simple_dropdown
|
|
385
|
+
if hasattr(parameter, "_ui_options") and parameter._ui_options:
|
|
386
|
+
parameter._ui_options["simple_dropdown"] = choices
|
|
387
|
+
else:
|
|
388
|
+
msg = f"No Options trait found for parameter '{param}'."
|
|
389
|
+
raise ValueError(msg)
|
|
381
390
|
else:
|
|
382
391
|
msg = f"Parameter '{param}' not found for updating model choices."
|
|
383
392
|
raise ValueError(msg)
|
|
@@ -393,9 +402,14 @@ class BaseNode(ABC):
|
|
|
393
402
|
"""
|
|
394
403
|
parameter = self.get_parameter_by_name(param)
|
|
395
404
|
if parameter is not None:
|
|
396
|
-
trait
|
|
397
|
-
|
|
405
|
+
# Find the Options trait by type since element_id is a UUID
|
|
406
|
+
traits = parameter.find_elements_by_type(Options)
|
|
407
|
+
if traits:
|
|
408
|
+
trait = traits[0] # Take the first Options trait
|
|
398
409
|
parameter.remove_trait(trait)
|
|
410
|
+
else:
|
|
411
|
+
msg = f"No Options trait found for parameter '{param}'."
|
|
412
|
+
raise ValueError(msg)
|
|
399
413
|
else:
|
|
400
414
|
msg = f"Parameter '{param}' not found for removing options trait."
|
|
401
415
|
raise ValueError(msg)
|
|
@@ -576,7 +590,7 @@ class BaseNode(ABC):
|
|
|
576
590
|
param = self.get_parameter_by_name(param_name)
|
|
577
591
|
if param and isinstance(param, ParameterContainer):
|
|
578
592
|
value = handle_container_parameter(self, param)
|
|
579
|
-
if value:
|
|
593
|
+
if value is not None:
|
|
580
594
|
return value
|
|
581
595
|
if param_name in self.parameter_values:
|
|
582
596
|
return self.parameter_values[param_name]
|
|
@@ -26,6 +26,7 @@ class WorkflowMetadata(BaseModel):
|
|
|
26
26
|
is_template: bool | None = False
|
|
27
27
|
creation_date: datetime | None = Field(default=None)
|
|
28
28
|
last_modified_date: datetime | None = Field(default=None)
|
|
29
|
+
branched_from: str | None = Field(default=None)
|
|
29
30
|
|
|
30
31
|
|
|
31
32
|
class WorkflowRegistry(metaclass=SingletonMeta):
|
|
@@ -81,6 +82,16 @@ class WorkflowRegistry(metaclass=SingletonMeta):
|
|
|
81
82
|
raise KeyError(msg)
|
|
82
83
|
return instance._workflows.pop(name)
|
|
83
84
|
|
|
85
|
+
@classmethod
|
|
86
|
+
def get_branches_of_workflow(cls, workflow_name: str) -> list[str]:
|
|
87
|
+
"""Get all workflows that are branches of the specified workflow."""
|
|
88
|
+
instance = cls()
|
|
89
|
+
branches = []
|
|
90
|
+
for name, workflow in instance._workflows.items():
|
|
91
|
+
if workflow.metadata.branched_from == workflow_name:
|
|
92
|
+
branches.append(name)
|
|
93
|
+
return branches
|
|
94
|
+
|
|
84
95
|
|
|
85
96
|
class Workflow:
|
|
86
97
|
"""A workflow card to be ran."""
|
|
@@ -102,6 +113,23 @@ class Workflow:
|
|
|
102
113
|
msg = f"File path '{complete_path}' does not exist."
|
|
103
114
|
raise ValueError(msg)
|
|
104
115
|
|
|
116
|
+
@property
|
|
117
|
+
def is_synced(self) -> bool:
|
|
118
|
+
"""Check if this workflow is in the synced workflows directory."""
|
|
119
|
+
from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
|
|
120
|
+
|
|
121
|
+
config_mgr = GriptapeNodes.ConfigManager()
|
|
122
|
+
synced_directory = config_mgr.get_config_value("synced_workflows_directory")
|
|
123
|
+
|
|
124
|
+
# Get the full path to the synced workflows directory
|
|
125
|
+
synced_path = config_mgr.get_full_path(synced_directory)
|
|
126
|
+
|
|
127
|
+
# Get the complete file path for this workflow
|
|
128
|
+
complete_file_path = WorkflowRegistry.get_complete_file_path(self.file_path)
|
|
129
|
+
|
|
130
|
+
# Check if the workflow file is within the synced directory
|
|
131
|
+
return Path(complete_file_path).is_relative_to(synced_path)
|
|
132
|
+
|
|
105
133
|
def get_workflow_metadata(self) -> dict:
|
|
106
134
|
# Convert from the Pydantic schema.
|
|
107
135
|
ret_val = {**self.metadata.model_dump()}
|
|
@@ -109,4 +137,5 @@ class Workflow:
|
|
|
109
137
|
# The schema doesn't have the file path in it, because it is baked into the file itself.
|
|
110
138
|
# Customers of this function need that, so let's stuff it in.
|
|
111
139
|
ret_val["file_path"] = self.file_path
|
|
140
|
+
ret_val["is_synced"] = self.is_synced
|
|
112
141
|
return ret_val
|
|
@@ -5,6 +5,7 @@ from griptape_nodes.retained_mode.events.base_events import (
|
|
|
5
5
|
RequestPayload,
|
|
6
6
|
ResultPayloadFailure,
|
|
7
7
|
ResultPayloadSuccess,
|
|
8
|
+
SkipTheLineMixin,
|
|
8
9
|
WorkflowNotAlteredMixin,
|
|
9
10
|
)
|
|
10
11
|
from griptape_nodes.retained_mode.events.payload_registry import PayloadRegistry
|
|
@@ -148,7 +149,7 @@ class AppEndSessionResultFailure(ResultPayloadFailure):
|
|
|
148
149
|
|
|
149
150
|
@dataclass
|
|
150
151
|
@PayloadRegistry.register
|
|
151
|
-
class SessionHeartbeatRequest(RequestPayload):
|
|
152
|
+
class SessionHeartbeatRequest(RequestPayload, SkipTheLineMixin):
|
|
152
153
|
"""Request clients can use ensure the engine session is still active."""
|
|
153
154
|
|
|
154
155
|
|
|
@@ -166,7 +167,7 @@ class SessionHeartbeatResultFailure(ResultPayloadFailure):
|
|
|
166
167
|
|
|
167
168
|
@dataclass
|
|
168
169
|
@PayloadRegistry.register
|
|
169
|
-
class EngineHeartbeatRequest(RequestPayload):
|
|
170
|
+
class EngineHeartbeatRequest(RequestPayload, SkipTheLineMixin):
|
|
170
171
|
"""Request clients can use to discover active engines and their status.
|
|
171
172
|
|
|
172
173
|
Attributes:
|
|
@@ -62,6 +62,15 @@ class WorkflowNotAlteredMixin:
|
|
|
62
62
|
altered_workflow_state: bool = field(default=False, init=False)
|
|
63
63
|
|
|
64
64
|
|
|
65
|
+
class SkipTheLineMixin:
|
|
66
|
+
"""Mixin for events that should skip the event queue and be processed immediately.
|
|
67
|
+
|
|
68
|
+
Events that implement this mixin will be handled directly without being added
|
|
69
|
+
to the event queue, allowing for priority processing of critical events like
|
|
70
|
+
heartbeats or other time-sensitive operations.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
|
|
65
74
|
# Success result payload abstract base class
|
|
66
75
|
@dataclass(kw_only=True)
|
|
67
76
|
class ResultPayloadSuccess(ResultPayload, ABC):
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from griptape_nodes.retained_mode.events.base_events import (
|
|
4
|
+
AppPayload,
|
|
5
|
+
RequestPayload,
|
|
6
|
+
ResultPayloadFailure,
|
|
7
|
+
ResultPayloadSuccess,
|
|
8
|
+
WorkflowNotAlteredMixin,
|
|
9
|
+
)
|
|
10
|
+
from griptape_nodes.retained_mode.events.payload_registry import PayloadRegistry
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
@PayloadRegistry.register
|
|
15
|
+
class StartSyncAllCloudWorkflowsRequest(RequestPayload):
|
|
16
|
+
"""Start syncing all cloud workflows to local synced_workflows directory.
|
|
17
|
+
|
|
18
|
+
Use when: Initiating download of all workflow files from cloud storage, keeping local sync directory updated,
|
|
19
|
+
preparing for offline workflow development, backing up cloud workflows locally.
|
|
20
|
+
|
|
21
|
+
Results: StartSyncAllCloudWorkflowsResultSuccess (sync started) | StartSyncAllCloudWorkflowsResultFailure (failed to start)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
@PayloadRegistry.register
|
|
27
|
+
class StartSyncAllCloudWorkflowsResultSuccess(WorkflowNotAlteredMixin, ResultPayloadSuccess):
|
|
28
|
+
"""Cloud workflow sync started successfully.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
sync_directory: Path to the local sync directory where files will be saved
|
|
32
|
+
total_workflows: Number of workflows that will be synced
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
sync_directory: str
|
|
36
|
+
total_workflows: int
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
@PayloadRegistry.register
|
|
41
|
+
class StartSyncAllCloudWorkflowsResultFailure(WorkflowNotAlteredMixin, ResultPayloadFailure):
|
|
42
|
+
"""Cloud workflow sync failed to start. Common causes: cloud not configured, network error, storage error, permission denied."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
@PayloadRegistry.register
|
|
47
|
+
class SyncComplete(AppPayload):
|
|
48
|
+
"""Cloud workflow sync completed successfully.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
sync_directory: Path to the local sync directory where files were saved
|
|
52
|
+
synced_workflows: List of workflows that were successfully synced
|
|
53
|
+
failed_workflows: List of workflows that failed to sync
|
|
54
|
+
total_workflows: Total number of workflows that were processed
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
sync_directory: str
|
|
58
|
+
synced_workflows: list[str]
|
|
59
|
+
failed_workflows: list[str]
|
|
60
|
+
total_workflows: int
|