funmill-api 0.1.2__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.
- funmill/api/__init__.py +44 -0
- funmill/api/app.py +103 -0
- funmill/api/backends/__init__.py +42 -0
- funmill/api/backends/base.py +51 -0
- funmill/api/backends/dagu/README.md +83 -0
- funmill/api/backends/dagu/__init__.py +458 -0
- funmill/api/backends/dagu/service.py +149 -0
- funmill/api/backends/service.py +107 -0
- funmill/api/backends/windmill/README.md +112 -0
- funmill/api/backends/windmill/__init__.py +414 -0
- funmill/api/backends/windmill/service.py +151 -0
- funmill/api/cli.py +83 -0
- funmill/api/models.py +120 -0
- funmill/api/ports.py +3 -0
- funmill_api-0.1.2.dist-info/METADATA +211 -0
- funmill_api-0.1.2.dist-info/RECORD +18 -0
- funmill_api-0.1.2.dist-info/WHEEL +4 -0
- funmill_api-0.1.2.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import signal
|
|
3
|
+
import subprocess
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _pid_path(name: str, directory: Path) -> Path:
|
|
9
|
+
return directory / f"{name}.pid"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _read_pid(path: Path) -> int:
|
|
13
|
+
try:
|
|
14
|
+
pid = int(path.read_text(encoding="utf-8").strip())
|
|
15
|
+
if pid <= 1:
|
|
16
|
+
raise ValueError
|
|
17
|
+
return pid
|
|
18
|
+
except (OSError, ValueError) as exc:
|
|
19
|
+
raise RuntimeError(f"invalid service PID file: {path}") from exc
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _is_running(pid: int) -> bool:
|
|
23
|
+
try:
|
|
24
|
+
os.kill(pid, 0)
|
|
25
|
+
except ProcessLookupError:
|
|
26
|
+
return False
|
|
27
|
+
except PermissionError:
|
|
28
|
+
return True
|
|
29
|
+
return True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def start_background(
|
|
33
|
+
name: str, command: list[str], environment: dict[str, str], directory: Path
|
|
34
|
+
) -> None:
|
|
35
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
pid_path = _pid_path(name, directory)
|
|
37
|
+
if pid_path.exists():
|
|
38
|
+
pid = _read_pid(pid_path)
|
|
39
|
+
if _is_running(pid):
|
|
40
|
+
raise RuntimeError(f"{name} is already running with pid {pid}")
|
|
41
|
+
pid_path.unlink()
|
|
42
|
+
|
|
43
|
+
log_path = directory / f"{name}.log"
|
|
44
|
+
with log_path.open("ab") as output:
|
|
45
|
+
process = subprocess.Popen(
|
|
46
|
+
command,
|
|
47
|
+
env=environment,
|
|
48
|
+
stdin=subprocess.DEVNULL,
|
|
49
|
+
stdout=output,
|
|
50
|
+
stderr=subprocess.STDOUT,
|
|
51
|
+
start_new_session=True,
|
|
52
|
+
)
|
|
53
|
+
pid_path.write_text(f"{process.pid}\n", encoding="utf-8")
|
|
54
|
+
try:
|
|
55
|
+
return_code = process.wait(timeout=0.2)
|
|
56
|
+
except subprocess.TimeoutExpired:
|
|
57
|
+
print(f"started {name}: pid={process.pid}, log={log_path}")
|
|
58
|
+
return
|
|
59
|
+
pid_path.unlink(missing_ok=True)
|
|
60
|
+
raise RuntimeError(
|
|
61
|
+
f"{name} exited during startup with code {return_code}; see {log_path}"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def status_background(name: str, directory: Path) -> bool:
|
|
66
|
+
pid_path = _pid_path(name, directory)
|
|
67
|
+
if not pid_path.exists():
|
|
68
|
+
print(f"{name} is stopped")
|
|
69
|
+
return False
|
|
70
|
+
pid = _read_pid(pid_path)
|
|
71
|
+
if not _is_running(pid):
|
|
72
|
+
pid_path.unlink()
|
|
73
|
+
print(f"{name} is stopped")
|
|
74
|
+
return False
|
|
75
|
+
print(f"{name} is running: pid={pid}")
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def stop_background(name: str, directory: Path, timeout: float = 10) -> None:
|
|
80
|
+
pid_path = _pid_path(name, directory)
|
|
81
|
+
if not pid_path.exists():
|
|
82
|
+
print(f"{name} is already stopped")
|
|
83
|
+
return
|
|
84
|
+
pid = _read_pid(pid_path)
|
|
85
|
+
if not _is_running(pid):
|
|
86
|
+
pid_path.unlink()
|
|
87
|
+
print(f"{name} is already stopped")
|
|
88
|
+
return
|
|
89
|
+
try:
|
|
90
|
+
group_id = os.getpgid(pid)
|
|
91
|
+
except ProcessLookupError:
|
|
92
|
+
pid_path.unlink()
|
|
93
|
+
print(f"{name} is already stopped")
|
|
94
|
+
return
|
|
95
|
+
if group_id != pid:
|
|
96
|
+
raise RuntimeError(
|
|
97
|
+
f"refusing to stop {name}: pid {pid} is not its process group"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
os.killpg(group_id, signal.SIGTERM)
|
|
101
|
+
deadline = time.monotonic() + timeout
|
|
102
|
+
while _is_running(pid):
|
|
103
|
+
if time.monotonic() >= deadline:
|
|
104
|
+
raise RuntimeError(f"timed out waiting for {name} pid {pid} to stop")
|
|
105
|
+
time.sleep(0.1)
|
|
106
|
+
pid_path.unlink(missing_ok=True)
|
|
107
|
+
print(f"stopped {name}: pid={pid}")
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Windmill 简单部署
|
|
2
|
+
|
|
3
|
+
以下方式不使用 Docker,适用于 Linux x86_64。需要一套可用的 PostgreSQL,
|
|
4
|
+
以及 `python3`、`uv` 和 Bash;Windmill 会自动初始化数据库表。
|
|
5
|
+
|
|
6
|
+
## 1. 安装 Funmill 和 Windmill
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
uv sync
|
|
10
|
+
uv run funmill install windmill
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Windmill 的二进制和配置文件都会放在
|
|
14
|
+
`~/.farfarfun/funmill/services/windmill/`。安装器会校验官方发行包的
|
|
15
|
+
SHA-256;设置 `FUNMILL_HOME` 可以修改 Funmill 的数据根目录。其他第三方服务
|
|
16
|
+
同样使用 `~/.farfarfun/funmill/services/<service>/` 目录。
|
|
17
|
+
|
|
18
|
+
## 2. 准备数据库
|
|
19
|
+
|
|
20
|
+
在 PostgreSQL 中创建用户和数据库:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
sudo -u postgres createuser --pwprompt windmill
|
|
24
|
+
sudo -u postgres createdb --owner=windmill windmill
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
编辑 `~/.farfarfun/funmill/services/windmill/.env`,写入连接地址:
|
|
28
|
+
|
|
29
|
+
```dotenv
|
|
30
|
+
DATABASE_URL=postgresql://windmill:数据库密码@127.0.0.1:5432/windmill
|
|
31
|
+
MODE=standalone
|
|
32
|
+
SERVER_BIND_ADDR=0.0.0.0
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
安装器首次创建该文件时会设置 `0600` 权限,且不会覆盖已有配置。
|
|
36
|
+
Funmill 启动 Windmill 时会固定使用 `SERVER_BIND_ADDR=0.0.0.0`。
|
|
37
|
+
|
|
38
|
+
## 3. 启动 Windmill
|
|
39
|
+
|
|
40
|
+
`standalone` 模式会在一个进程中同时运行 Server 和一个 Worker:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
uv run funmill start windmill
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
该命令会在后台启动 Windmill,并打印 PID 和日志路径。PID 与日志分别保存在
|
|
47
|
+
`~/.farfarfun/funmill/services/windmill/windmill.pid` 和 `windmill.log`。
|
|
48
|
+
Windmill 的 Web 界面和原生 API 固定监听 `0.0.0.0:8813`。本机打开
|
|
49
|
+
<http://127.0.0.1:8813>,首次登录使用:
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
admin@windmill.dev / changeme
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
立即修改密码,并在 `admins` workspace 中创建 API Token。
|
|
56
|
+
|
|
57
|
+
## 4. 启动 Funmill API
|
|
58
|
+
|
|
59
|
+
回到 Funmill 仓库根目录执行:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
FUNMILL_API_KEY='自行设置的接口密钥' \
|
|
63
|
+
FUNMILL_BACKEND=windmill \
|
|
64
|
+
WINDMILL_URL='http://127.0.0.1:8813' \
|
|
65
|
+
WINDMILL_WORKSPACE=admins \
|
|
66
|
+
WINDMILL_TOKEN='刚创建的Windmill-Token' \
|
|
67
|
+
uv run funmill start
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Funmill API 固定监听 `0.0.0.0:8812`,并保持前台运行。
|
|
71
|
+
|
|
72
|
+
验证:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
curl http://127.0.0.1:8812/health
|
|
76
|
+
FUNMILL_API_KEY='自行设置的接口密钥' ./scripts/smoke.sh
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## 5. 管理后台服务
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
uv run funmill status windmill
|
|
83
|
+
uv run funmill restart windmill
|
|
84
|
+
uv run funmill stop windmill
|
|
85
|
+
tail -f ~/.farfarfun/funmill/services/windmill/windmill.log
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
重复启动会被 PID 文件拦截。`stop` 会向 Windmill 的独立进程组发送 `SIGTERM`,
|
|
89
|
+
等待正常退出后删除 PID 文件;服务异常退出后,`status` 会清理失效 PID。
|
|
90
|
+
|
|
91
|
+
## 增加 Worker
|
|
92
|
+
|
|
93
|
+
需要更高并发时,在同一台或其他机器额外启动 Worker。每个进程使用不同的
|
|
94
|
+
`WORKER_SUFFIX`:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
MODE=worker WORKER_SUFFIX=worker2 uv run funmill start windmill
|
|
98
|
+
MODE=worker WORKER_SUFFIX=worker3 uv run funmill start windmill
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
每个 Worker 使用 `windmill-<WORKER_SUFFIX>.pid` 和同名日志。查询或停止某个
|
|
102
|
+
Worker 时需要传入相同环境变量,例如:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
MODE=worker WORKER_SUFFIX=worker2 uv run funmill status windmill
|
|
106
|
+
MODE=worker WORKER_SUFFIX=worker2 uv run funmill stop windmill
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
需要开机启动、自动重启和日志轮转时,使用现有的 systemd 或进程管理器直接
|
|
110
|
+
管理已安装的 Windmill 二进制。不要在
|
|
111
|
+
一个普通 Worker 进程中设置 `NUM_WORKERS>1`;Windmill 会因为隔离安全限制将
|
|
112
|
+
它回退为 1,多个独立 Worker 进程更明确。
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from funmill.api.models import (
|
|
9
|
+
TaskDefinition,
|
|
10
|
+
TaskInfo,
|
|
11
|
+
TaskLanguage,
|
|
12
|
+
TaskLogs,
|
|
13
|
+
TaskProgress,
|
|
14
|
+
TaskResult,
|
|
15
|
+
TaskStatus,
|
|
16
|
+
TaskSubmit,
|
|
17
|
+
WorkflowSubmit,
|
|
18
|
+
)
|
|
19
|
+
from funmill.api.ports import THIRD_PARTY_WEB_PORT
|
|
20
|
+
|
|
21
|
+
from ..base import BackendError, TaskBackend
|
|
22
|
+
|
|
23
|
+
_CALLBACK_SOURCE = """import json
|
|
24
|
+
import os
|
|
25
|
+
from urllib.request import Request, urlopen
|
|
26
|
+
|
|
27
|
+
def main(callback_url: str, status: str, payload):
|
|
28
|
+
body = {
|
|
29
|
+
"task_id": os.environ["WM_ROOT_JOB_ID"],
|
|
30
|
+
"status": status,
|
|
31
|
+
"payload": payload,
|
|
32
|
+
}
|
|
33
|
+
request = Request(
|
|
34
|
+
callback_url,
|
|
35
|
+
data=json.dumps(body, default=str).encode(),
|
|
36
|
+
headers={"Content-Type": "application/json"},
|
|
37
|
+
method="POST",
|
|
38
|
+
)
|
|
39
|
+
with urlopen(request, timeout=10):
|
|
40
|
+
return payload
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
_DEPENDENCY_SOURCE = """from datetime import datetime, timezone
|
|
44
|
+
import os
|
|
45
|
+
from wmill import get_job
|
|
46
|
+
|
|
47
|
+
def main(dependencies: list[str], timeout_seconds: int):
|
|
48
|
+
root = get_job(os.environ["WM_ROOT_JOB_ID"])
|
|
49
|
+
created_at = datetime.fromisoformat(root["created_at"].replace("Z", "+00:00"))
|
|
50
|
+
if (datetime.now(timezone.utc) - created_at).total_seconds() > timeout_seconds:
|
|
51
|
+
raise TimeoutError("dependency wait timed out")
|
|
52
|
+
|
|
53
|
+
for task_id in dependencies:
|
|
54
|
+
job = get_job(task_id)
|
|
55
|
+
if "success" not in job:
|
|
56
|
+
return False
|
|
57
|
+
if job.get("canceled"):
|
|
58
|
+
raise RuntimeError(f"dependency {task_id} was canceled")
|
|
59
|
+
if not job["success"]:
|
|
60
|
+
raise RuntimeError(f"dependency {task_id} failed")
|
|
61
|
+
return True
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
_RESULT_SOURCE = """def main(results):
|
|
65
|
+
return results
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
_LANGUAGES = {
|
|
69
|
+
TaskLanguage.PYTHON: "python3",
|
|
70
|
+
TaskLanguage.BASH: "bash",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class WindmillBackend(TaskBackend):
|
|
75
|
+
name = "windmill"
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
base_url: str,
|
|
80
|
+
workspace: str,
|
|
81
|
+
token: str,
|
|
82
|
+
timeout: float = 30,
|
|
83
|
+
client: httpx.Client | None = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
self.token = token
|
|
86
|
+
self.base_url = base_url.rstrip("/")
|
|
87
|
+
self.client = client or httpx.Client(
|
|
88
|
+
base_url=f"{self.base_url}/api/w/{workspace}/",
|
|
89
|
+
timeout=timeout,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_env(cls) -> "WindmillBackend":
|
|
94
|
+
return cls(
|
|
95
|
+
base_url=os.getenv(
|
|
96
|
+
"WINDMILL_URL", f"http://127.0.0.1:{THIRD_PARTY_WEB_PORT}"
|
|
97
|
+
),
|
|
98
|
+
workspace=os.getenv("WINDMILL_WORKSPACE", "admins"),
|
|
99
|
+
token=os.getenv("WINDMILL_TOKEN", ""),
|
|
100
|
+
timeout=float(os.getenv("WINDMILL_TIMEOUT", "30")),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
104
|
+
if not self.token:
|
|
105
|
+
raise BackendError("WINDMILL_TOKEN is not configured", 503)
|
|
106
|
+
try:
|
|
107
|
+
response = self.client.request(
|
|
108
|
+
method,
|
|
109
|
+
path,
|
|
110
|
+
headers={"Authorization": f"Bearer {self.token}"},
|
|
111
|
+
**kwargs,
|
|
112
|
+
)
|
|
113
|
+
except httpx.TimeoutException as exc:
|
|
114
|
+
raise BackendError("Windmill request timed out", 504) from exc
|
|
115
|
+
except httpx.HTTPError as exc:
|
|
116
|
+
raise BackendError(f"Windmill is unavailable: {exc}", 502) from exc
|
|
117
|
+
if response.is_error:
|
|
118
|
+
status_code = 404 if response.status_code == 404 else 502
|
|
119
|
+
detail = response.text.strip()[:500] or f"HTTP {response.status_code}"
|
|
120
|
+
raise BackendError(f"Windmill rejected the request: {detail}", status_code)
|
|
121
|
+
return response
|
|
122
|
+
|
|
123
|
+
def health_check(self) -> None:
|
|
124
|
+
if not self.token:
|
|
125
|
+
raise BackendError("WINDMILL_TOKEN is not configured", 503)
|
|
126
|
+
try:
|
|
127
|
+
response = self.client.get(f"{self.base_url}/api/health/status")
|
|
128
|
+
except httpx.TimeoutException as exc:
|
|
129
|
+
raise BackendError("Windmill health check timed out", 504) from exc
|
|
130
|
+
except httpx.HTTPError as exc:
|
|
131
|
+
raise BackendError(f"Windmill is unavailable: {exc}", 502) from exc
|
|
132
|
+
if response.is_error:
|
|
133
|
+
raise BackendError(
|
|
134
|
+
f"Windmill health check failed: HTTP {response.status_code}", 502
|
|
135
|
+
)
|
|
136
|
+
status = response.json().get("status")
|
|
137
|
+
if status not in {"healthy", "ok"}:
|
|
138
|
+
raise BackendError(f"Windmill reports status {status!r}", 502)
|
|
139
|
+
|
|
140
|
+
def _submit_flow(self, value: dict[str, Any], args: dict[str, Any]) -> str:
|
|
141
|
+
response = self._request(
|
|
142
|
+
"POST", "jobs/run/preview_flow", json={"value": value, "args": args}
|
|
143
|
+
)
|
|
144
|
+
return self._response_task_id(response)
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def _response_task_id(response: httpx.Response) -> str:
|
|
148
|
+
lines = response.text.strip().splitlines()
|
|
149
|
+
if not lines:
|
|
150
|
+
raise BackendError("Windmill returned an empty task ID")
|
|
151
|
+
try:
|
|
152
|
+
return str(UUID(lines[0]))
|
|
153
|
+
except ValueError as exc:
|
|
154
|
+
raise BackendError(
|
|
155
|
+
f"Windmill returned an invalid task ID: {lines[0]!r}"
|
|
156
|
+
) from exc
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def _task_module(task_id: str, task: TaskDefinition) -> dict[str, Any]:
|
|
160
|
+
module: dict[str, Any] = {
|
|
161
|
+
"id": task_id,
|
|
162
|
+
"value": {
|
|
163
|
+
"type": "rawscript",
|
|
164
|
+
"language": _LANGUAGES[task.language],
|
|
165
|
+
"content": task.source,
|
|
166
|
+
"input_transforms": {
|
|
167
|
+
key: {"type": "static", "value": value}
|
|
168
|
+
for key, value in task.args.items()
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
if task.retry.attempts:
|
|
173
|
+
module["retry"] = {
|
|
174
|
+
"constant": {
|
|
175
|
+
"attempts": task.retry.attempts,
|
|
176
|
+
"seconds": task.retry.delay_seconds,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if task.timeout_seconds:
|
|
180
|
+
module["timeout"] = {"type": "static", "value": task.timeout_seconds}
|
|
181
|
+
return module
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def _dependency_module(
|
|
185
|
+
dependencies: list[str], timeout_seconds: int
|
|
186
|
+
) -> dict[str, Any]:
|
|
187
|
+
# ponytail: polling creates one short Windmill job per interval; replace with
|
|
188
|
+
# backend completion events when dependency volume makes that measurable.
|
|
189
|
+
return {
|
|
190
|
+
"id": "funmill_wait",
|
|
191
|
+
"value": {
|
|
192
|
+
"type": "whileloopflow",
|
|
193
|
+
"skip_failures": False,
|
|
194
|
+
"modules": [
|
|
195
|
+
{
|
|
196
|
+
"id": "funmill_check_dependencies",
|
|
197
|
+
"value": {
|
|
198
|
+
"type": "rawscript",
|
|
199
|
+
"language": "python3",
|
|
200
|
+
"content": _DEPENDENCY_SOURCE,
|
|
201
|
+
"input_transforms": {
|
|
202
|
+
"dependencies": {
|
|
203
|
+
"type": "static",
|
|
204
|
+
"value": dependencies,
|
|
205
|
+
},
|
|
206
|
+
"timeout_seconds": {
|
|
207
|
+
"type": "static",
|
|
208
|
+
"value": timeout_seconds,
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
"sleep": {"type": "static", "value": 2},
|
|
213
|
+
"stop_after_if": {"expr": "result === true"},
|
|
214
|
+
}
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
@staticmethod
|
|
220
|
+
def _add_callback(
|
|
221
|
+
value: dict[str, Any], callback_url: str | None, result_id: str
|
|
222
|
+
) -> None:
|
|
223
|
+
if callback_url is None:
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
def callback_module(module_id: str, status: str, payload_expr: str):
|
|
227
|
+
return {
|
|
228
|
+
"id": module_id,
|
|
229
|
+
"value": {
|
|
230
|
+
"type": "rawscript",
|
|
231
|
+
"language": "python3",
|
|
232
|
+
"content": _CALLBACK_SOURCE,
|
|
233
|
+
"input_transforms": {
|
|
234
|
+
"callback_url": {"type": "static", "value": callback_url},
|
|
235
|
+
"status": {"type": "static", "value": status},
|
|
236
|
+
"payload": {"type": "javascript", "expr": payload_expr},
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
"retry": {"constant": {"attempts": 3, "seconds": 2}},
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
result_expr = f"results[{json.dumps(result_id)}]"
|
|
243
|
+
value["modules"].append(
|
|
244
|
+
callback_module("funmill_callback", "succeeded", result_expr)
|
|
245
|
+
)
|
|
246
|
+
value["failure_module"] = callback_module("failure", "failed", "error")
|
|
247
|
+
|
|
248
|
+
@staticmethod
|
|
249
|
+
def _result_module(task_results: dict[str, str]) -> dict[str, Any]:
|
|
250
|
+
entries = ", ".join(
|
|
251
|
+
f"{json.dumps(task_id)}: {result_expr}"
|
|
252
|
+
for task_id, result_expr in task_results.items()
|
|
253
|
+
)
|
|
254
|
+
return {
|
|
255
|
+
"id": "funmill_result",
|
|
256
|
+
"value": {
|
|
257
|
+
"type": "rawscript",
|
|
258
|
+
"language": "python3",
|
|
259
|
+
"content": _RESULT_SOURCE,
|
|
260
|
+
"input_transforms": {
|
|
261
|
+
"results": {
|
|
262
|
+
"type": "javascript",
|
|
263
|
+
"expr": f"({{{entries}}})",
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
def submit_task(self, task: TaskSubmit) -> str:
|
|
270
|
+
modules = []
|
|
271
|
+
if task.depends_on:
|
|
272
|
+
modules.append(
|
|
273
|
+
self._dependency_module(
|
|
274
|
+
task.depends_on, task.dependency_timeout_seconds
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
modules.append(self._task_module("task", task))
|
|
278
|
+
value = {"modules": modules}
|
|
279
|
+
self._add_callback(
|
|
280
|
+
value,
|
|
281
|
+
str(task.callback_url) if task.callback_url else None,
|
|
282
|
+
"task",
|
|
283
|
+
)
|
|
284
|
+
return self._submit_flow(value, {})
|
|
285
|
+
|
|
286
|
+
def submit_workflow(self, workflow: WorkflowSubmit) -> str:
|
|
287
|
+
modules = []
|
|
288
|
+
task_results = {}
|
|
289
|
+
if workflow.depends_on:
|
|
290
|
+
modules.append(
|
|
291
|
+
self._dependency_module(
|
|
292
|
+
workflow.depends_on, workflow.dependency_timeout_seconds
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
for index, layer in enumerate(workflow.topological_layers()):
|
|
297
|
+
if len(layer) == 1:
|
|
298
|
+
module = self._task_module(layer[0].key, layer[0])
|
|
299
|
+
task_results[layer[0].key] = f"results[{json.dumps(layer[0].key)}]"
|
|
300
|
+
else:
|
|
301
|
+
layer_id = f"funmill_layer_{index}"
|
|
302
|
+
module = {
|
|
303
|
+
"id": layer_id,
|
|
304
|
+
"value": {
|
|
305
|
+
"type": "branchall",
|
|
306
|
+
"parallel": True,
|
|
307
|
+
"branches": [
|
|
308
|
+
{
|
|
309
|
+
"summary": task.key,
|
|
310
|
+
"modules": [self._task_module(task.key, task)],
|
|
311
|
+
}
|
|
312
|
+
for task in layer
|
|
313
|
+
],
|
|
314
|
+
},
|
|
315
|
+
}
|
|
316
|
+
for task_index, task in enumerate(layer):
|
|
317
|
+
task_results[task.key] = (
|
|
318
|
+
f"results[{json.dumps(layer_id)}][{task_index}]"
|
|
319
|
+
)
|
|
320
|
+
modules.append(module)
|
|
321
|
+
|
|
322
|
+
result_module = self._result_module(task_results)
|
|
323
|
+
modules.append(result_module)
|
|
324
|
+
|
|
325
|
+
value = {"modules": modules}
|
|
326
|
+
self._add_callback(
|
|
327
|
+
value,
|
|
328
|
+
str(workflow.callback_url) if workflow.callback_url else None,
|
|
329
|
+
result_module["id"],
|
|
330
|
+
)
|
|
331
|
+
return self._submit_flow(value, {})
|
|
332
|
+
|
|
333
|
+
def _get_job(self, task_id: str) -> dict[str, Any]:
|
|
334
|
+
return self._request("GET", f"jobs_u/get/{task_id}").json()
|
|
335
|
+
|
|
336
|
+
@staticmethod
|
|
337
|
+
def _status(job: dict[str, Any]) -> TaskStatus:
|
|
338
|
+
if job.get("canceled"):
|
|
339
|
+
return TaskStatus.CANCELED
|
|
340
|
+
if "success" in job:
|
|
341
|
+
return TaskStatus.SUCCEEDED if job["success"] else TaskStatus.FAILED
|
|
342
|
+
return TaskStatus.RUNNING if job.get("running") else TaskStatus.QUEUED
|
|
343
|
+
|
|
344
|
+
def get_task(self, task_id: str) -> TaskInfo:
|
|
345
|
+
job = self._get_job(task_id)
|
|
346
|
+
return TaskInfo(
|
|
347
|
+
task_id=task_id,
|
|
348
|
+
status=self._status(job),
|
|
349
|
+
created_at=job.get("created_at"),
|
|
350
|
+
started_at=job.get("started_at"),
|
|
351
|
+
completed_at=job.get("completed_at"),
|
|
352
|
+
duration_ms=job.get("duration_ms"),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def get_progress(self, task_id: str) -> TaskProgress:
|
|
356
|
+
job = self._get_job(task_id)
|
|
357
|
+
status = self._status(job)
|
|
358
|
+
if status == TaskStatus.SUCCEEDED:
|
|
359
|
+
return TaskProgress(task_id=task_id, progress=100)
|
|
360
|
+
|
|
361
|
+
modules = (job.get("flow_status") or {}).get("modules") or []
|
|
362
|
+
scores = []
|
|
363
|
+
for module in modules:
|
|
364
|
+
if module.get("type") == "Success":
|
|
365
|
+
scores.append(100)
|
|
366
|
+
elif module.get("type") == "InProgress":
|
|
367
|
+
scores.append(module.get("progress") or 0)
|
|
368
|
+
else:
|
|
369
|
+
scores.append(0)
|
|
370
|
+
progress = int(sum(scores) / len(scores)) if scores else None
|
|
371
|
+
return TaskProgress(task_id=task_id, progress=progress)
|
|
372
|
+
|
|
373
|
+
def get_logs(self, task_id: str) -> TaskLogs:
|
|
374
|
+
job = self._get_job(task_id)
|
|
375
|
+
if job.get("job_kind") in {"flow", "flowpreview", "singlestepflow"}:
|
|
376
|
+
response = self._request("GET", f"jobs_u/get_flow_all_logs/{task_id}")
|
|
377
|
+
logs = response.text
|
|
378
|
+
else:
|
|
379
|
+
logs = self._request("GET", f"jobs_u/get_logs/{task_id}").text
|
|
380
|
+
return TaskLogs(task_id=task_id, logs=logs)
|
|
381
|
+
|
|
382
|
+
def get_result(self, task_id: str) -> TaskResult:
|
|
383
|
+
result = self._request("GET", f"jobs_u/completed/get_result/{task_id}").json()
|
|
384
|
+
return TaskResult(task_id=task_id, result=result)
|
|
385
|
+
|
|
386
|
+
def cancel(self, task_id: str, reason: str) -> None:
|
|
387
|
+
self._request("POST", f"jobs_u/queue/cancel/{task_id}", json={"reason": reason})
|
|
388
|
+
|
|
389
|
+
def rerun(self, task_id: str) -> str:
|
|
390
|
+
job = self._get_job(task_id)
|
|
391
|
+
raw_flow = job.get("raw_flow")
|
|
392
|
+
if raw_flow:
|
|
393
|
+
return self._submit_flow(raw_flow, job.get("args") or {})
|
|
394
|
+
|
|
395
|
+
raw_code = job.get("raw_code")
|
|
396
|
+
if raw_code:
|
|
397
|
+
response = self._request(
|
|
398
|
+
"POST",
|
|
399
|
+
"jobs/run/preview",
|
|
400
|
+
json={
|
|
401
|
+
"content": raw_code,
|
|
402
|
+
"language": job.get("language"),
|
|
403
|
+
"kind": "code",
|
|
404
|
+
"args": job.get("args") or {},
|
|
405
|
+
},
|
|
406
|
+
)
|
|
407
|
+
return self._response_task_id(response)
|
|
408
|
+
|
|
409
|
+
raise BackendError(
|
|
410
|
+
"task cannot be rerun because its source is unavailable", 409
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
def close(self) -> None:
|
|
414
|
+
self.client.close()
|