fw-nodes-code 0.0.1a1__tar.gz
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.
- fw_nodes_code-0.0.1a1/.forgejo/workflows/publish.yml +20 -0
- fw_nodes_code-0.0.1a1/.gitignore +41 -0
- fw_nodes_code-0.0.1a1/LICENSE +7 -0
- fw_nodes_code-0.0.1a1/PKG-INFO +98 -0
- fw_nodes_code-0.0.1a1/README.md +83 -0
- fw_nodes_code-0.0.1a1/fw_nodes_code/__init__.py +3 -0
- fw_nodes_code-0.0.1a1/fw_nodes_code/nodes/__init__.py +4 -0
- fw_nodes_code-0.0.1a1/fw_nodes_code/nodes/code.py +296 -0
- fw_nodes_code-0.0.1a1/js-sandbox/.gitignore +2 -0
- fw_nodes_code-0.0.1a1/js-sandbox/.nvmrc +1 -0
- fw_nodes_code-0.0.1a1/js-sandbox/Dockerfile +16 -0
- fw_nodes_code-0.0.1a1/js-sandbox/package-lock.json +2686 -0
- fw_nodes_code-0.0.1a1/js-sandbox/package.json +22 -0
- fw_nodes_code-0.0.1a1/js-sandbox/src/executor.ts +116 -0
- fw_nodes_code-0.0.1a1/js-sandbox/src/server.ts +26 -0
- fw_nodes_code-0.0.1a1/js-sandbox/tsconfig.json +15 -0
- fw_nodes_code-0.0.1a1/justfile +73 -0
- fw_nodes_code-0.0.1a1/py-sandbox/.gitignore +3 -0
- fw_nodes_code-0.0.1a1/py-sandbox/Dockerfile +12 -0
- fw_nodes_code-0.0.1a1/py-sandbox/py_sandbox/__init__.py +0 -0
- fw_nodes_code-0.0.1a1/py-sandbox/py_sandbox/executor.py +175 -0
- fw_nodes_code-0.0.1a1/py-sandbox/py_sandbox/server.py +44 -0
- fw_nodes_code-0.0.1a1/py-sandbox/pyproject.toml +20 -0
- fw_nodes_code-0.0.1a1/py-sandbox/tests/test_executor.py +33 -0
- fw_nodes_code-0.0.1a1/pyproject.toml +54 -0
- fw_nodes_code-0.0.1a1/tests/conftest.py +138 -0
- fw_nodes_code-0.0.1a1/tests/test_code.py +290 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: Public-Mirrors/actions_checkout@v6
|
|
13
|
+
|
|
14
|
+
- name: Install uv
|
|
15
|
+
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
16
|
+
|
|
17
|
+
- name: Build and publish
|
|
18
|
+
run: |
|
|
19
|
+
uv build
|
|
20
|
+
uv publish --token ${{ secrets.PYPI_API_TOKEN }}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
|
|
8
|
+
# Virtual environments
|
|
9
|
+
.venv/
|
|
10
|
+
venv/
|
|
11
|
+
env/
|
|
12
|
+
ENV/
|
|
13
|
+
|
|
14
|
+
# Environment files
|
|
15
|
+
.env
|
|
16
|
+
.env.local
|
|
17
|
+
|
|
18
|
+
# Testing
|
|
19
|
+
.pytest_cache/
|
|
20
|
+
.coverage
|
|
21
|
+
htmlcov/
|
|
22
|
+
coverage.xml
|
|
23
|
+
|
|
24
|
+
# Build artifacts
|
|
25
|
+
dist/
|
|
26
|
+
build/
|
|
27
|
+
*.egg-info/
|
|
28
|
+
.eggs/
|
|
29
|
+
|
|
30
|
+
# IDE
|
|
31
|
+
.idea/
|
|
32
|
+
.vscode/
|
|
33
|
+
*.swp
|
|
34
|
+
*.swo
|
|
35
|
+
|
|
36
|
+
# OS
|
|
37
|
+
.DS_Store
|
|
38
|
+
Thumbs.db
|
|
39
|
+
|
|
40
|
+
# uv
|
|
41
|
+
uv.lock
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2026 Eddy Hintze
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fw-nodes-code
|
|
3
|
+
Version: 0.0.1a1
|
|
4
|
+
Summary: Code execution nodes for Flowire workflow automation
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.13
|
|
8
|
+
Requires-Dist: flowire-sdk>=0.0.1a2
|
|
9
|
+
Requires-Dist: httpx>=0.26.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: ruff>=0.4.0; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# Flowire Code Nodes
|
|
17
|
+
|
|
18
|
+
Code execution node package for Flowire workflow automation.
|
|
19
|
+
|
|
20
|
+
This package is intentionally separate from `fw-nodes-core` so servers can opt into code execution support by installing this package.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
cd flowire-app/backend
|
|
26
|
+
uv pip install fw-nodes-code
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Enable it in your `.env` file:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
INSTALLED_NODE_PACKAGES=fw-nodes-core,fw-nodes-code
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Included Node
|
|
36
|
+
|
|
37
|
+
| Node | Description |
|
|
38
|
+
|------|-------------|
|
|
39
|
+
| `Code` | Execute JavaScript or Python code in an isolated sandbox |
|
|
40
|
+
|
|
41
|
+
The node supports a `runtime` input (default `latest`) so you can route
|
|
42
|
+
different versions to different sandbox services (for example `3.13`, `3.9`, `20`).
|
|
43
|
+
|
|
44
|
+
## Sandbox Services
|
|
45
|
+
|
|
46
|
+
This package includes the sandbox runtimes used by the Code node:
|
|
47
|
+
|
|
48
|
+
- `js-sandbox/` - JavaScript sandbox (isolated-vm + Fastify)
|
|
49
|
+
- `py-sandbox/` - Python sandbox (FastAPI + subprocess execution)
|
|
50
|
+
|
|
51
|
+
### Local sandbox commands
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
cd fw-nodes-code
|
|
55
|
+
just js-sandbox-install
|
|
56
|
+
just py-sandbox-install
|
|
57
|
+
just js-sandbox
|
|
58
|
+
just py-sandbox
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Environment Variables
|
|
62
|
+
|
|
63
|
+
- `CODE_NODE_SANDBOX_URL_MAP` - JSON object mapping runtime keys to sandbox URLs.
|
|
64
|
+
|
|
65
|
+
Example:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
CODE_NODE_SANDBOX_URL_MAP='{
|
|
69
|
+
"javascript@latest":"http://localhost:3100",
|
|
70
|
+
"javascript@20":"http://localhost:3110",
|
|
71
|
+
"python@latest":"http://localhost:3200",
|
|
72
|
+
"python@3.13":"http://localhost:3213",
|
|
73
|
+
"python@3.9":"http://localhost:3209"
|
|
74
|
+
}'
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Runtime keys are resolved as:
|
|
78
|
+
|
|
79
|
+
1. `{language}@{runtime}` (for example `python@3.13`)
|
|
80
|
+
2. `{language}@latest`
|
|
81
|
+
3. (no fallback route; configuration must exist in the map)
|
|
82
|
+
|
|
83
|
+
If `CODE_NODE_SANDBOX_URL_MAP` is not set, defaults are used:
|
|
84
|
+
|
|
85
|
+
- `javascript@latest -> http://localhost:3100`
|
|
86
|
+
- `python@latest -> http://localhost:3200`
|
|
87
|
+
|
|
88
|
+
## Development
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
just install
|
|
92
|
+
just js-sandbox-install
|
|
93
|
+
just py-sandbox-install
|
|
94
|
+
just js-sandbox
|
|
95
|
+
just py-sandbox
|
|
96
|
+
just test
|
|
97
|
+
just lint
|
|
98
|
+
```
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Flowire Code Nodes
|
|
2
|
+
|
|
3
|
+
Code execution node package for Flowire workflow automation.
|
|
4
|
+
|
|
5
|
+
This package is intentionally separate from `fw-nodes-core` so servers can opt into code execution support by installing this package.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
cd flowire-app/backend
|
|
11
|
+
uv pip install fw-nodes-code
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Enable it in your `.env` file:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
INSTALLED_NODE_PACKAGES=fw-nodes-core,fw-nodes-code
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Included Node
|
|
21
|
+
|
|
22
|
+
| Node | Description |
|
|
23
|
+
|------|-------------|
|
|
24
|
+
| `Code` | Execute JavaScript or Python code in an isolated sandbox |
|
|
25
|
+
|
|
26
|
+
The node supports a `runtime` input (default `latest`) so you can route
|
|
27
|
+
different versions to different sandbox services (for example `3.13`, `3.9`, `20`).
|
|
28
|
+
|
|
29
|
+
## Sandbox Services
|
|
30
|
+
|
|
31
|
+
This package includes the sandbox runtimes used by the Code node:
|
|
32
|
+
|
|
33
|
+
- `js-sandbox/` - JavaScript sandbox (isolated-vm + Fastify)
|
|
34
|
+
- `py-sandbox/` - Python sandbox (FastAPI + subprocess execution)
|
|
35
|
+
|
|
36
|
+
### Local sandbox commands
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
cd fw-nodes-code
|
|
40
|
+
just js-sandbox-install
|
|
41
|
+
just py-sandbox-install
|
|
42
|
+
just js-sandbox
|
|
43
|
+
just py-sandbox
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Environment Variables
|
|
47
|
+
|
|
48
|
+
- `CODE_NODE_SANDBOX_URL_MAP` - JSON object mapping runtime keys to sandbox URLs.
|
|
49
|
+
|
|
50
|
+
Example:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
CODE_NODE_SANDBOX_URL_MAP='{
|
|
54
|
+
"javascript@latest":"http://localhost:3100",
|
|
55
|
+
"javascript@20":"http://localhost:3110",
|
|
56
|
+
"python@latest":"http://localhost:3200",
|
|
57
|
+
"python@3.13":"http://localhost:3213",
|
|
58
|
+
"python@3.9":"http://localhost:3209"
|
|
59
|
+
}'
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Runtime keys are resolved as:
|
|
63
|
+
|
|
64
|
+
1. `{language}@{runtime}` (for example `python@3.13`)
|
|
65
|
+
2. `{language}@latest`
|
|
66
|
+
3. (no fallback route; configuration must exist in the map)
|
|
67
|
+
|
|
68
|
+
If `CODE_NODE_SANDBOX_URL_MAP` is not set, defaults are used:
|
|
69
|
+
|
|
70
|
+
- `javascript@latest -> http://localhost:3100`
|
|
71
|
+
- `python@latest -> http://localhost:3200`
|
|
72
|
+
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
just install
|
|
77
|
+
just js-sandbox-install
|
|
78
|
+
just py-sandbox-install
|
|
79
|
+
just js-sandbox
|
|
80
|
+
just py-sandbox
|
|
81
|
+
just test
|
|
82
|
+
just lint
|
|
83
|
+
```
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Code node for executing custom code in sandboxed environments."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from flowire_sdk import BaseNode, BaseNodeOutput, CodeEditorContract, NodeExecutionContext, NodeMetadata
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
JS_DEFAULT_CODE = (
|
|
16
|
+
'// Available: $node["node-id"], $flow\n// Return your output as an object\n\nreturn {\n result: "hello"\n}'
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
PYTHON_DEFAULT_CODE = (
|
|
20
|
+
'# Available: nodes["node-id"], flow\n'
|
|
21
|
+
"# Assign your output to 'result'\n"
|
|
22
|
+
"\n"
|
|
23
|
+
"result = {\n"
|
|
24
|
+
' "message": "Hello from Python",\n'
|
|
25
|
+
"}"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Language(str, Enum):
|
|
30
|
+
"""Supported code execution languages."""
|
|
31
|
+
|
|
32
|
+
JAVASCRIPT = "javascript"
|
|
33
|
+
PYTHON = "python"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
SUPPORTED_LANGUAGE_VALUES = {language.value for language in Language}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
DEFAULT_SANDBOX_URL_MAP: dict[str, str] = {
|
|
40
|
+
"javascript@latest": "http://localhost:3100",
|
|
41
|
+
"python@latest": "http://localhost:3200",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _normalize_runtime(runtime: str | None) -> str:
|
|
46
|
+
"""Normalize runtime hint to a stable lower-case token."""
|
|
47
|
+
normalized = (runtime or "latest").strip().lower()
|
|
48
|
+
return normalized or "latest"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_map_key(key: str) -> tuple[str, str] | None:
|
|
52
|
+
"""Parse map key into language/runtime tokens."""
|
|
53
|
+
token = key.strip().lower()
|
|
54
|
+
if not token:
|
|
55
|
+
return None
|
|
56
|
+
if "@" in token:
|
|
57
|
+
language_token, runtime_token = token.split("@", 1)
|
|
58
|
+
else:
|
|
59
|
+
language_token, runtime_token = token, "latest"
|
|
60
|
+
|
|
61
|
+
language_token = language_token.strip()
|
|
62
|
+
runtime_token = _normalize_runtime(runtime_token)
|
|
63
|
+
|
|
64
|
+
if language_token not in SUPPORTED_LANGUAGE_VALUES:
|
|
65
|
+
return None
|
|
66
|
+
return language_token, runtime_token
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _load_sandbox_url_map() -> dict[str, str]:
|
|
70
|
+
"""Load language/runtime -> sandbox URL mapping from environment."""
|
|
71
|
+
raw = os.environ.get("CODE_NODE_SANDBOX_URL_MAP", "").strip()
|
|
72
|
+
if not raw:
|
|
73
|
+
return DEFAULT_SANDBOX_URL_MAP.copy()
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
parsed = json.loads(raw)
|
|
77
|
+
except json.JSONDecodeError:
|
|
78
|
+
logger.warning("Invalid CODE_NODE_SANDBOX_URL_MAP JSON; using default sandbox routing")
|
|
79
|
+
return DEFAULT_SANDBOX_URL_MAP.copy()
|
|
80
|
+
|
|
81
|
+
if not isinstance(parsed, dict):
|
|
82
|
+
logger.warning("CODE_NODE_SANDBOX_URL_MAP must be a JSON object; using default sandbox routing")
|
|
83
|
+
return DEFAULT_SANDBOX_URL_MAP.copy()
|
|
84
|
+
|
|
85
|
+
result: dict[str, str] = {}
|
|
86
|
+
for key, value in parsed.items():
|
|
87
|
+
if not isinstance(key, str) or not isinstance(value, str):
|
|
88
|
+
continue
|
|
89
|
+
parsed_key = _parse_map_key(key)
|
|
90
|
+
if not parsed_key:
|
|
91
|
+
continue
|
|
92
|
+
language_token, runtime_token = parsed_key
|
|
93
|
+
normalized_key = f"{language_token}@{runtime_token}"
|
|
94
|
+
normalized_url = value.strip()
|
|
95
|
+
if normalized_key and normalized_url:
|
|
96
|
+
result[normalized_key] = normalized_url
|
|
97
|
+
|
|
98
|
+
if not result:
|
|
99
|
+
logger.warning("CODE_NODE_SANDBOX_URL_MAP had no valid entries; using default sandbox routing")
|
|
100
|
+
return DEFAULT_SANDBOX_URL_MAP.copy()
|
|
101
|
+
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _build_editor_options(url_map: dict[str, str]) -> tuple[list[str], dict[str, list[str]]]:
|
|
106
|
+
"""Derive language/runtime dropdown options from sandbox map keys."""
|
|
107
|
+
language_order: list[str] = []
|
|
108
|
+
runtime_options: dict[str, list[str]] = {}
|
|
109
|
+
|
|
110
|
+
for key in url_map:
|
|
111
|
+
parsed_key = _parse_map_key(key)
|
|
112
|
+
if not parsed_key:
|
|
113
|
+
continue
|
|
114
|
+
language_token, runtime_token = parsed_key
|
|
115
|
+
|
|
116
|
+
if language_token not in language_order:
|
|
117
|
+
language_order.append(language_token)
|
|
118
|
+
|
|
119
|
+
runtimes = runtime_options.setdefault(language_token, [])
|
|
120
|
+
if runtime_token not in runtimes:
|
|
121
|
+
if runtime_token == "latest":
|
|
122
|
+
runtimes.insert(0, runtime_token)
|
|
123
|
+
else:
|
|
124
|
+
runtimes.append(runtime_token)
|
|
125
|
+
|
|
126
|
+
if not language_order:
|
|
127
|
+
language_order = [Language.JAVASCRIPT.value, Language.PYTHON.value]
|
|
128
|
+
runtime_options = {
|
|
129
|
+
Language.JAVASCRIPT.value: ["latest"],
|
|
130
|
+
Language.PYTHON.value: ["latest"],
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return language_order, runtime_options
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _build_code_editor_contract() -> CodeEditorContract:
|
|
137
|
+
"""Build code editor metadata contract including language/runtime dropdown options."""
|
|
138
|
+
url_map = _load_sandbox_url_map()
|
|
139
|
+
language_options, runtime_options = _build_editor_options(url_map)
|
|
140
|
+
|
|
141
|
+
return CodeEditorContract(
|
|
142
|
+
code_field="code",
|
|
143
|
+
language_field="language",
|
|
144
|
+
runtime_field="runtime",
|
|
145
|
+
code_format="code",
|
|
146
|
+
language_defaults={
|
|
147
|
+
"javascript": JS_DEFAULT_CODE,
|
|
148
|
+
"python": PYTHON_DEFAULT_CODE,
|
|
149
|
+
},
|
|
150
|
+
language_options=language_options,
|
|
151
|
+
runtime_options=runtime_options,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _resolve_sandbox_url(language: Language, runtime: str | None) -> tuple[str, str]:
|
|
156
|
+
"""Resolve target sandbox URL from runtime mapping."""
|
|
157
|
+
runtime_token = _normalize_runtime(runtime)
|
|
158
|
+
candidates = [f"{language.value}@{runtime_token}"]
|
|
159
|
+
if runtime_token != "latest":
|
|
160
|
+
candidates.append(f"{language.value}@latest")
|
|
161
|
+
|
|
162
|
+
url_map = _load_sandbox_url_map()
|
|
163
|
+
for key in candidates:
|
|
164
|
+
url = url_map.get(key)
|
|
165
|
+
if url:
|
|
166
|
+
return url, key
|
|
167
|
+
|
|
168
|
+
available_routes = sorted(url_map.keys())
|
|
169
|
+
raise RuntimeError(
|
|
170
|
+
"No sandbox URL configured for "
|
|
171
|
+
f"{language.value}@{runtime_token}. "
|
|
172
|
+
"Set CODE_NODE_SANDBOX_URL_MAP with a matching route. "
|
|
173
|
+
f"Available routes: {available_routes}"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class CodeNodeInput(BaseModel):
|
|
178
|
+
"""Input schema for Code node."""
|
|
179
|
+
|
|
180
|
+
language: Language = Field(
|
|
181
|
+
default=Language.JAVASCRIPT,
|
|
182
|
+
description="Programming language for the code",
|
|
183
|
+
json_schema_extra={
|
|
184
|
+
"x-code-defaults": {
|
|
185
|
+
"javascript": JS_DEFAULT_CODE,
|
|
186
|
+
"python": PYTHON_DEFAULT_CODE,
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
)
|
|
190
|
+
code: str = Field(
|
|
191
|
+
default=JS_DEFAULT_CODE,
|
|
192
|
+
description='Code to execute. Use $node["id"]/$flow (JS) or nodes["id"]/flow (Python).',
|
|
193
|
+
json_schema_extra={"format": "code"},
|
|
194
|
+
)
|
|
195
|
+
runtime: str = Field(
|
|
196
|
+
default="latest",
|
|
197
|
+
description=("Runtime version hint for selecting the sandbox backend, e.g. 'latest', '3.13', '3.9', or '20'."),
|
|
198
|
+
)
|
|
199
|
+
timeout: int = Field(
|
|
200
|
+
default=10,
|
|
201
|
+
ge=1,
|
|
202
|
+
le=60,
|
|
203
|
+
description="Maximum execution time in seconds",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class CodeNodeOutput(BaseNodeOutput):
|
|
208
|
+
"""Output schema for Code node - allows arbitrary fields from user code."""
|
|
209
|
+
|
|
210
|
+
model_config = ConfigDict(extra="allow")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class CodeNode(BaseNode):
|
|
214
|
+
"""Execute custom code in a sandboxed environment."""
|
|
215
|
+
|
|
216
|
+
input_schema = CodeNodeInput
|
|
217
|
+
output_schema = CodeNodeOutput
|
|
218
|
+
|
|
219
|
+
metadata = NodeMetadata(
|
|
220
|
+
name="Code",
|
|
221
|
+
description="Execute custom code with access to workflow data",
|
|
222
|
+
category="developer",
|
|
223
|
+
icon="<>",
|
|
224
|
+
color="#1e1e1e",
|
|
225
|
+
display_component="code",
|
|
226
|
+
code_editor=_build_code_editor_contract(),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
async def execute_logic(
|
|
230
|
+
self,
|
|
231
|
+
validated_inputs: dict[str, Any],
|
|
232
|
+
context: NodeExecutionContext,
|
|
233
|
+
) -> CodeNodeOutput:
|
|
234
|
+
"""Execute code in the appropriate sandbox."""
|
|
235
|
+
language = Language(validated_inputs.get("language", Language.JAVASCRIPT))
|
|
236
|
+
code = validated_inputs["code"]
|
|
237
|
+
runtime = validated_inputs.get("runtime", "latest")
|
|
238
|
+
timeout = validated_inputs.get("timeout", 10)
|
|
239
|
+
|
|
240
|
+
sandbox_url, sandbox_route = _resolve_sandbox_url(language, runtime)
|
|
241
|
+
|
|
242
|
+
# Build the request payload
|
|
243
|
+
# js-sandbox expects timeout in milliseconds, py-sandbox in seconds
|
|
244
|
+
sandbox_timeout = timeout * 1000 if language == Language.JAVASCRIPT else timeout
|
|
245
|
+
payload = {
|
|
246
|
+
"language": language.value,
|
|
247
|
+
"runtime": runtime,
|
|
248
|
+
"code": code,
|
|
249
|
+
"input": context.node_results,
|
|
250
|
+
"nodeResults": context.node_results,
|
|
251
|
+
"flow": {
|
|
252
|
+
"execution_id": context.execution_id,
|
|
253
|
+
"flow_id": context.workflow_id,
|
|
254
|
+
"project_id": context.project_id,
|
|
255
|
+
"node_id": context.node_id,
|
|
256
|
+
},
|
|
257
|
+
"timeout": sandbox_timeout,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
# Call the sandbox
|
|
261
|
+
async with httpx.AsyncClient() as client:
|
|
262
|
+
try:
|
|
263
|
+
response = await client.post(
|
|
264
|
+
f"{sandbox_url}/execute",
|
|
265
|
+
json=payload,
|
|
266
|
+
timeout=timeout + 5, # Add buffer for network
|
|
267
|
+
)
|
|
268
|
+
response.raise_for_status()
|
|
269
|
+
result = response.json()
|
|
270
|
+
except httpx.ConnectError as e:
|
|
271
|
+
raise RuntimeError(
|
|
272
|
+
"Could not connect to code sandbox service for "
|
|
273
|
+
f"{language.value}@{_normalize_runtime(runtime)} "
|
|
274
|
+
f"(route '{sandbox_route}' -> {sandbox_url}). "
|
|
275
|
+
"Configure CODE_NODE_SANDBOX_URL_MAP."
|
|
276
|
+
) from e
|
|
277
|
+
except httpx.TimeoutException as e:
|
|
278
|
+
raise RuntimeError(f"Code execution timed out after {timeout} seconds") from e
|
|
279
|
+
|
|
280
|
+
# Check for execution errors
|
|
281
|
+
if not result.get("success"):
|
|
282
|
+
error = result.get("error", {})
|
|
283
|
+
error_msg = error.get("message", "Unknown error")
|
|
284
|
+
error_stack = error.get("stack", "")
|
|
285
|
+
raise RuntimeError(f"Code execution failed: {error_msg}\n{error_stack}")
|
|
286
|
+
|
|
287
|
+
# Build output from result
|
|
288
|
+
output_data = result.get("result", {})
|
|
289
|
+
if not isinstance(output_data, dict):
|
|
290
|
+
output_data = {"result": output_data}
|
|
291
|
+
|
|
292
|
+
# Include execution metadata
|
|
293
|
+
output_data["_logs"] = result.get("logs", [])
|
|
294
|
+
output_data["_duration"] = result.get("duration", 0)
|
|
295
|
+
|
|
296
|
+
return CodeNodeOutput(**output_data)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
22
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
FROM node:22-alpine
|
|
2
|
+
|
|
3
|
+
WORKDIR /app
|
|
4
|
+
|
|
5
|
+
# Install build dependencies for isolated-vm (needs python, make, g++)
|
|
6
|
+
RUN apk add --no-cache python3 make g++ curl
|
|
7
|
+
|
|
8
|
+
COPY package*.json ./
|
|
9
|
+
RUN npm ci
|
|
10
|
+
|
|
11
|
+
COPY . .
|
|
12
|
+
RUN npm run build
|
|
13
|
+
|
|
14
|
+
EXPOSE 3100
|
|
15
|
+
|
|
16
|
+
CMD ["npm", "start"]
|