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.
@@ -0,0 +1,458 @@
1
+ import base64
2
+ import json
3
+ import os
4
+ import re
5
+ import shlex
6
+ import time
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from funmill.api.models import (
12
+ TaskDefinition,
13
+ TaskInfo,
14
+ TaskLanguage,
15
+ TaskLogs,
16
+ TaskProgress,
17
+ TaskResult,
18
+ TaskStatus,
19
+ TaskSubmit,
20
+ WorkflowSubmit,
21
+ )
22
+ from funmill.api.ports import THIRD_PARTY_WEB_PORT
23
+
24
+ from ..base import BackendError, TaskBackend
25
+
26
+ _DAG_NAME = "funmill"
27
+ _TASK_ID = re.compile(r"^[A-Za-z0-9_-]+$")
28
+ _TERMINAL_NODE_STATES = {"succeeded", "failed", "aborted", "skipped", "rejected"}
29
+
30
+ _DEPENDENCY_SOURCE = """#!/usr/bin/env python3
31
+ import base64
32
+ import json
33
+ import os
34
+ import time
35
+ from urllib.parse import quote
36
+ from urllib.request import Request, urlopen
37
+
38
+ base_url = base64.b64decode("__BASE_URL__").decode()
39
+ dependencies = json.loads(base64.b64decode("__DEPENDENCIES__"))
40
+ deadline = time.monotonic() + __TIMEOUT_SECONDS__
41
+ token = os.getenv("FUNMILL_DAGU_TOKEN", "")
42
+ headers = {"Authorization": f"Bearer {token}"} if token else {}
43
+
44
+ while True:
45
+ waiting = False
46
+ for task_id in dependencies:
47
+ request = Request(
48
+ f"{base_url}/dag-runs/funmill/{quote(task_id, safe='')}",
49
+ headers=headers,
50
+ )
51
+ with urlopen(request, timeout=30) as response:
52
+ status = json.load(response)["dagRunDetails"]["statusLabel"]
53
+ if status in {"failed", "partially_succeeded", "rejected"}:
54
+ raise RuntimeError(f"dependency {task_id} failed")
55
+ if status == "aborted":
56
+ raise RuntimeError(f"dependency {task_id} was canceled")
57
+ waiting |= status != "succeeded"
58
+ if not waiting:
59
+ break
60
+ if time.monotonic() >= deadline:
61
+ raise TimeoutError("dependency wait timed out")
62
+ time.sleep(2)
63
+ """
64
+
65
+ _CALLBACK_SOURCE = """#!/usr/bin/env python3
66
+ import base64
67
+ import json
68
+ import os
69
+ from urllib.request import Request, urlopen
70
+
71
+ def decode(value):
72
+ if isinstance(value, str):
73
+ if value.startswith("json:"):
74
+ return json.loads(value[5:])
75
+ if value.startswith("text:"):
76
+ return value[5:]
77
+ try:
78
+ return decode(json.loads(value))
79
+ except (json.JSONDecodeError, TypeError):
80
+ return value
81
+ if isinstance(value, dict):
82
+ return {key: decode(item) for key, item in value.items()}
83
+ return value
84
+
85
+ body = {
86
+ "task_id": os.environ["FUNMILL_CALLBACK_TASK_ID"],
87
+ "status": os.environ["FUNMILL_CALLBACK_STATUS"],
88
+ "payload": decode(os.environ["FUNMILL_CALLBACK_PAYLOAD"]),
89
+ }
90
+ request = Request(
91
+ base64.b64decode("__CALLBACK_URL__").decode(),
92
+ data=json.dumps(body, default=str).encode(),
93
+ headers={"Content-Type": "application/json"},
94
+ method="POST",
95
+ )
96
+ with urlopen(request, timeout=10):
97
+ pass
98
+ """
99
+
100
+
101
+ def _encoded(value: str) -> str:
102
+ return base64.b64encode(value.encode()).decode()
103
+
104
+
105
+ def _decode_result(value: Any) -> Any:
106
+ if isinstance(value, str):
107
+ if value.startswith("json:"):
108
+ return json.loads(value[5:])
109
+ if value.startswith("text:"):
110
+ return value[5:]
111
+ try:
112
+ return _decode_result(json.loads(value))
113
+ except (json.JSONDecodeError, TypeError):
114
+ return value
115
+ if isinstance(value, dict):
116
+ return {key: _decode_result(item) for key, item in value.items()}
117
+ return value
118
+
119
+
120
+ def _python_script(task: TaskDefinition) -> str:
121
+ source = _encoded(task.source)
122
+ args = _encoded(json.dumps(task.args, separators=(",", ":")))
123
+ return f"""#!/usr/bin/env python3
124
+ import base64
125
+ import json
126
+ import os
127
+
128
+ namespace = {{"__name__": "__funmill__"}}
129
+ source = base64.b64decode({source!r}).decode()
130
+ args = json.loads(base64.b64decode({args!r}))
131
+ exec(compile(source, "<funmill>", "exec"), namespace)
132
+ result = namespace["main"](**args)
133
+ with open(os.environ["DAGU_OUTPUT_FILE"], "a", encoding="utf-8") as output:
134
+ output.write("result=json:" + json.dumps(result, default=str) + "\\n")
135
+ """
136
+
137
+
138
+ def _bash_script(task: TaskDefinition) -> str:
139
+ args = " ".join(
140
+ shlex.quote(value if isinstance(value, str) else json.dumps(value))
141
+ for value in task.args.values()
142
+ )
143
+ return (
144
+ "#!/usr/bin/env bash\n"
145
+ "set -euo pipefail\n"
146
+ f"__funmill_source={shlex.quote(_encoded(task.source))}\n"
147
+ '__funmill_script="$(mktemp "${TMPDIR:-/tmp}/funmill.XXXXXX")"\n'
148
+ '__funmill_result="$(mktemp "${TMPDIR:-/tmp}/funmill.XXXXXX")"\n'
149
+ 'trap \'rm -f "$__funmill_script" "$__funmill_result"\' EXIT\n'
150
+ "if ! printf '%s' \"$__funmill_source\" | base64 --decode "
151
+ '>"$__funmill_script" 2>/dev/null; then\n'
152
+ ' printf \'%s\' "$__funmill_source" | base64 -D >"$__funmill_script"\n'
153
+ "fi\n"
154
+ 'source "$__funmill_script"\n'
155
+ "set -euo pipefail\n"
156
+ f'main {args} | tee "$__funmill_result"\n'
157
+ '__funmill_delimiter="FUNMILL_RESULT_$$"\n'
158
+ "{\n"
159
+ " printf 'result<<%s\\n' \"$__funmill_delimiter\"\n"
160
+ " printf 'text:'\n"
161
+ ' cat "$__funmill_result"\n'
162
+ " printf '\\n%s\\n' \"$__funmill_delimiter\"\n"
163
+ '} >>"$DAGU_OUTPUT_FILE"\n'
164
+ )
165
+
166
+
167
+ class DaguBackend(TaskBackend):
168
+ name = "dagu"
169
+
170
+ def __init__(
171
+ self,
172
+ base_url: str,
173
+ token: str = "",
174
+ timeout: float = 30,
175
+ client: httpx.Client | None = None,
176
+ ) -> None:
177
+ self.base_url = base_url.rstrip("/")
178
+ self.token = token
179
+ self.client = client or httpx.Client(
180
+ base_url=f"{self.base_url}/api/v1/", timeout=timeout
181
+ )
182
+
183
+ @classmethod
184
+ def from_env(cls) -> "DaguBackend":
185
+ return cls(
186
+ base_url=os.getenv("DAGU_URL", f"http://127.0.0.1:{THIRD_PARTY_WEB_PORT}"),
187
+ token=os.getenv("DAGU_TOKEN", ""),
188
+ timeout=float(os.getenv("DAGU_TIMEOUT", "30")),
189
+ )
190
+
191
+ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
192
+ headers = kwargs.pop("headers", {})
193
+ if self.token:
194
+ headers["Authorization"] = f"Bearer {self.token}"
195
+ try:
196
+ response = self.client.request(method, path, headers=headers, **kwargs)
197
+ except httpx.TimeoutException as exc:
198
+ raise BackendError("Dagu request timed out", 504) from exc
199
+ except httpx.HTTPError as exc:
200
+ raise BackendError(f"Dagu is unavailable: {exc}", 502) from exc
201
+ if response.is_error:
202
+ status_code = 404 if response.status_code == 404 else 502
203
+ detail = response.text.strip()[:500] or f"HTTP {response.status_code}"
204
+ raise BackendError(f"Dagu rejected the request: {detail}", status_code)
205
+ return response
206
+
207
+ def health_check(self) -> None:
208
+ response = self._request("GET", "health")
209
+ status = response.json().get("status")
210
+ if status not in {"healthy", "ok"}:
211
+ raise BackendError(f"Dagu reports status {status!r}", 502)
212
+
213
+ @staticmethod
214
+ def _task_id(value: Any) -> str:
215
+ if not isinstance(value, str):
216
+ raise BackendError(f"Dagu returned an invalid task ID: {value!r}")
217
+ task_id = value
218
+ if task_id == "latest" or not _TASK_ID.fullmatch(task_id):
219
+ raise BackendError(f"Dagu returned an invalid task ID: {task_id!r}")
220
+ return task_id
221
+
222
+ def _task_step(
223
+ self, step_id: str, task: TaskDefinition, dependencies: list[str]
224
+ ) -> dict[str, Any]:
225
+ step: dict[str, Any] = {
226
+ "id": step_id,
227
+ "run": (
228
+ _python_script(task)
229
+ if task.language == TaskLanguage.PYTHON
230
+ else _bash_script(task)
231
+ ),
232
+ "outputs": [{"name": "result"}],
233
+ }
234
+ if dependencies:
235
+ step["depends"] = dependencies
236
+ if task.retry.attempts:
237
+ step["retry_policy"] = {
238
+ "limit": task.retry.attempts,
239
+ "interval_sec": task.retry.delay_seconds,
240
+ }
241
+ if task.timeout_seconds:
242
+ step["timeout_sec"] = task.timeout_seconds
243
+ return step
244
+
245
+ def _dependency_step(
246
+ self, dependencies: list[str], timeout_seconds: int
247
+ ) -> dict[str, Any]:
248
+ dependencies = [self._task_id(task_id) for task_id in dependencies]
249
+ source = (
250
+ _DEPENDENCY_SOURCE.replace(
251
+ "__BASE_URL__", _encoded(f"{self.base_url}/api/v1")
252
+ )
253
+ .replace("__DEPENDENCIES__", _encoded(json.dumps(dependencies)))
254
+ .replace("__TIMEOUT_SECONDS__", str(timeout_seconds))
255
+ )
256
+ return {"id": "funmill_wait", "run": source}
257
+
258
+ @staticmethod
259
+ def _result_step(task_ids: list[str]) -> dict[str, Any]:
260
+ result: str | dict[str, str]
261
+ if task_ids == ["task"]:
262
+ result = "${steps.task.outputs.result}"
263
+ else:
264
+ result = {
265
+ task_id: f"${{steps.{task_id}.outputs.result}}" for task_id in task_ids
266
+ }
267
+ return {
268
+ "id": "funmill_result",
269
+ "depends": task_ids,
270
+ "action": "outputs.write",
271
+ "with": {"values": {"result": result}},
272
+ }
273
+
274
+ @staticmethod
275
+ def _callback(status: str, callback_url: str) -> dict[str, Any]:
276
+ payload = (
277
+ "${steps.funmill_result.outputs.result}"
278
+ if status == "succeeded"
279
+ else f"text:Dagu run {status}"
280
+ )
281
+ return {
282
+ "run": _CALLBACK_SOURCE.replace("__CALLBACK_URL__", _encoded(callback_url)),
283
+ "env": {
284
+ "FUNMILL_CALLBACK_TASK_ID": "${context.run.id}",
285
+ "FUNMILL_CALLBACK_STATUS": status,
286
+ "FUNMILL_CALLBACK_PAYLOAD": payload,
287
+ },
288
+ "retry_policy": {"limit": 3, "interval_sec": 2},
289
+ }
290
+
291
+ def _submit(
292
+ self,
293
+ steps: list[dict[str, Any]],
294
+ callback_url: str | None,
295
+ ) -> str:
296
+ spec: dict[str, Any] = {"name": _DAG_NAME, "steps": steps}
297
+ if callback_url:
298
+ success_callback = self._callback("succeeded", callback_url)
299
+ success_callback.update(
300
+ {"id": "funmill_callback", "depends": ["funmill_result"]}
301
+ )
302
+ steps.append(success_callback)
303
+ spec["handler_on"] = {
304
+ "failure": self._callback("failed", callback_url),
305
+ "abort": self._callback("canceled", callback_url),
306
+ }
307
+ response = self._request(
308
+ "POST", "dag-runs", json={"spec": json.dumps(spec, separators=(",", ":"))}
309
+ )
310
+ return self._task_id(response.json().get("dagRunId"))
311
+
312
+ def submit_task(self, task: TaskSubmit) -> str:
313
+ steps = []
314
+ dependencies = []
315
+ if task.depends_on:
316
+ steps.append(
317
+ self._dependency_step(task.depends_on, task.dependency_timeout_seconds)
318
+ )
319
+ dependencies.append("funmill_wait")
320
+ steps.append(self._task_step("task", task, dependencies))
321
+ steps.append(self._result_step(["task"]))
322
+ return self._submit(
323
+ steps, str(task.callback_url) if task.callback_url else None
324
+ )
325
+
326
+ def submit_workflow(self, workflow: WorkflowSubmit) -> str:
327
+ steps = []
328
+ if workflow.depends_on:
329
+ steps.append(
330
+ self._dependency_step(
331
+ workflow.depends_on, workflow.dependency_timeout_seconds
332
+ )
333
+ )
334
+ for task in workflow.tasks:
335
+ dependencies = list(task.depends_on)
336
+ if workflow.depends_on and not dependencies:
337
+ dependencies.append("funmill_wait")
338
+ steps.append(self._task_step(task.key, task, dependencies))
339
+ task_ids = [task.key for task in workflow.tasks]
340
+ steps.append(self._result_step(task_ids))
341
+ return self._submit(
342
+ steps, str(workflow.callback_url) if workflow.callback_url else None
343
+ )
344
+
345
+ def _details(self, task_id: str) -> dict[str, Any]:
346
+ task_id = self._task_id(task_id)
347
+ response = self._request("GET", f"dag-runs/{_DAG_NAME}/{task_id}").json()
348
+ try:
349
+ return response["dagRunDetails"]
350
+ except (KeyError, TypeError) as exc:
351
+ raise BackendError("Dagu returned invalid task details") from exc
352
+
353
+ @staticmethod
354
+ def _status(details: dict[str, Any]) -> TaskStatus:
355
+ states = {
356
+ "not_started": TaskStatus.QUEUED,
357
+ "queued": TaskStatus.QUEUED,
358
+ "running": TaskStatus.RUNNING,
359
+ "waiting": TaskStatus.RUNNING,
360
+ "succeeded": TaskStatus.SUCCEEDED,
361
+ "failed": TaskStatus.FAILED,
362
+ "partially_succeeded": TaskStatus.FAILED,
363
+ "rejected": TaskStatus.FAILED,
364
+ "aborted": TaskStatus.CANCELED,
365
+ }
366
+ try:
367
+ return states[details["statusLabel"]]
368
+ except KeyError as exc:
369
+ raise BackendError("Dagu returned an unknown task status") from exc
370
+
371
+ def get_task(self, task_id: str) -> TaskInfo:
372
+ details = self._details(task_id)
373
+ return TaskInfo(
374
+ task_id=task_id,
375
+ status=self._status(details),
376
+ created_at=details.get("queuedAt") or details.get("startedAt") or None,
377
+ started_at=details.get("startedAt") or None,
378
+ completed_at=details.get("finishedAt") or None,
379
+ )
380
+
381
+ def get_progress(self, task_id: str) -> TaskProgress:
382
+ details = self._details(task_id)
383
+ if self._status(details) == TaskStatus.SUCCEEDED:
384
+ return TaskProgress(task_id=task_id, progress=100)
385
+ nodes = [
386
+ node
387
+ for node in details.get("nodes", [])
388
+ if not (node.get("step", {}).get("id") or "").startswith("funmill_")
389
+ ]
390
+ completed = sum(
391
+ node.get("statusLabel") in _TERMINAL_NODE_STATES for node in nodes
392
+ )
393
+ progress = int(completed * 100 / len(nodes)) if nodes else None
394
+ return TaskProgress(task_id=task_id, progress=progress)
395
+
396
+ def get_logs(self, task_id: str) -> TaskLogs:
397
+ task_id = self._task_id(task_id)
398
+ details = self._details(task_id)
399
+ parts = []
400
+ for node in details.get("nodes", []):
401
+ step = node.get("step", {}).get("name")
402
+ if not step or node.get("statusLabel") == "not_started":
403
+ continue
404
+ # ponytail: Dagu lacks combined logs; use one request per stream.
405
+ for stream in ("stdout", "stderr"):
406
+ try:
407
+ content = (
408
+ self._request(
409
+ "GET",
410
+ f"dag-runs/{_DAG_NAME}/{task_id}/steps/{step}/log",
411
+ params={"stream": stream},
412
+ )
413
+ .json()
414
+ .get("content", "")
415
+ )
416
+ except BackendError as exc:
417
+ if exc.status_code == 404:
418
+ continue
419
+ raise
420
+ if content:
421
+ parts.append(f"[{step} {stream}]\n{content}")
422
+ return TaskLogs(task_id=task_id, logs="\n".join(parts))
423
+
424
+ def get_result(self, task_id: str) -> TaskResult:
425
+ task_id = self._task_id(task_id)
426
+ terminal = self._status(self._details(task_id)) not in {
427
+ TaskStatus.QUEUED,
428
+ TaskStatus.RUNNING,
429
+ }
430
+ for attempt in range(10):
431
+ payload = self._request(
432
+ "GET", f"dag-runs/{_DAG_NAME}/{task_id}/outputs"
433
+ ).json()
434
+ if (
435
+ not terminal
436
+ or payload.get("metadata", {}).get("status")
437
+ or attempt == 9
438
+ ):
439
+ break
440
+ # Dagu marks a run complete just before publishing outputs.json.
441
+ time.sleep(0.05)
442
+ outputs = payload.get("outputs", {})
443
+ return TaskResult(task_id=task_id, result=_decode_result(outputs.get("result")))
444
+
445
+ def cancel(self, task_id: str, reason: str) -> None:
446
+ del reason
447
+ task_id = self._task_id(task_id)
448
+ self._request("POST", f"dag-runs/{_DAG_NAME}/{task_id}/stop")
449
+
450
+ def rerun(self, task_id: str) -> str:
451
+ task_id = self._task_id(task_id)
452
+ response = self._request(
453
+ "POST", f"dag-runs/{_DAG_NAME}/{task_id}/reschedule", json={}
454
+ )
455
+ return self._task_id(response.json().get("dagRunId"))
456
+
457
+ def close(self) -> None:
458
+ self.client.close()
@@ -0,0 +1,149 @@
1
+ import hashlib
2
+ import os
3
+ import platform
4
+ import shutil
5
+ import tarfile
6
+ import tempfile
7
+ from pathlib import Path
8
+ from urllib.request import Request, urlopen
9
+
10
+ from funmill.api.ports import SERVICE_BIND_HOST, THIRD_PARTY_WEB_PORT
11
+
12
+ from ..service import start_background, status_background, stop_background
13
+
14
+ VERSION = "v2.16.3"
15
+ _RELEASE = f"https://github.com/dagucloud/dagu/releases/download/{VERSION}"
16
+ _BUILDS = {
17
+ ("darwin", "x86_64"): (
18
+ "darwin_amd64",
19
+ "09bd9122f9db02bc4247cb23d0a8a8a033482c381acfc701dc6cb248849e4d36",
20
+ "b99d0bb23e33c6f4a5ad22094d40acdbbc209ec3d801c99bf82fe072fd336306",
21
+ ),
22
+ ("darwin", "arm64"): (
23
+ "darwin_arm64",
24
+ "191ed4edc217680eae24ce348c29d6d4e2f10a6940f0281a7d1f9ca15c451555",
25
+ "ad9f1bdff3c1813f7caf39bc8cef72fa80435111f3980ecefc78de0a8d6b4918",
26
+ ),
27
+ ("linux", "x86_64"): (
28
+ "linux_amd64",
29
+ "2237cdd6287db00857af3471bcad7cea0bf786055bbd24f1bc53a02ceb9d4b06",
30
+ "fea2f1330644da0e54be983d34c1819f6158001eac23485ea9a476f26a8aba44",
31
+ ),
32
+ ("linux", "arm64"): (
33
+ "linux_arm64",
34
+ "e1f5fd611b003ee73e4846bad24455e02e052ff60314723e44aa067522c71762",
35
+ "6c0068970f6fcf678601566fb1e165aa4fb8f785d27b86677d227aaa32b1d0e3",
36
+ ),
37
+ }
38
+ _MACHINES = {"amd64": "x86_64", "aarch64": "arm64"}
39
+
40
+
41
+ def _home() -> Path:
42
+ return Path(os.getenv("FUNMILL_HOME", Path.home() / ".farfarfun" / "funmill"))
43
+
44
+
45
+ def _target() -> Path:
46
+ return _home() / "services" / "dagu" / "dagu"
47
+
48
+
49
+ def _sha256(path: Path) -> str:
50
+ with path.open("rb") as file:
51
+ return hashlib.file_digest(file, "sha256").hexdigest()
52
+
53
+
54
+ def _build() -> tuple[str, str, str]:
55
+ key = (
56
+ platform.system().lower(),
57
+ _MACHINES.get(platform.machine().lower(), platform.machine().lower()),
58
+ )
59
+ try:
60
+ return _BUILDS[key]
61
+ except KeyError as exc:
62
+ raise RuntimeError(
63
+ "Dagu installer only supports macOS/Linux amd64/arm64"
64
+ ) from exc
65
+
66
+
67
+ def install(force: bool = False) -> Path:
68
+ suffix, archive_sha256, binary_sha256 = _build()
69
+ target = _target()
70
+ if target.exists() and _sha256(target) == binary_sha256:
71
+ return target
72
+ if target.exists() and not force:
73
+ raise RuntimeError(
74
+ f"{target} exists but has an unexpected checksum; use --force"
75
+ )
76
+
77
+ target.parent.mkdir(parents=True, exist_ok=True)
78
+ archive_name = f"dagu_{VERSION.removeprefix('v')}_{suffix}.tar.gz"
79
+ with tempfile.TemporaryDirectory(dir=target.parent) as temporary_dir:
80
+ archive_path = Path(temporary_dir) / archive_name
81
+ request = Request(
82
+ f"{_RELEASE}/{archive_name}", headers={"User-Agent": "funmill"}
83
+ )
84
+ print(f"downloading Dagu {VERSION}...", flush=True)
85
+ with urlopen(request, timeout=30) as response, archive_path.open("wb") as file:
86
+ shutil.copyfileobj(response, file)
87
+ if _sha256(archive_path) != archive_sha256:
88
+ raise RuntimeError("downloaded Dagu archive failed SHA-256 verification")
89
+
90
+ executable = Path(temporary_dir) / "dagu"
91
+ with tarfile.open(archive_path, "r:gz") as archive:
92
+ member = archive.getmember("dagu")
93
+ if not member.isfile():
94
+ raise RuntimeError("downloaded Dagu archive does not contain a binary")
95
+ source = archive.extractfile(member)
96
+ if source is None:
97
+ raise RuntimeError("downloaded Dagu archive does not contain a binary")
98
+ with source, executable.open("wb") as file:
99
+ shutil.copyfileobj(source, file)
100
+ if _sha256(executable) != binary_sha256:
101
+ raise RuntimeError("downloaded Dagu binary failed SHA-256 verification")
102
+ executable.chmod(0o755)
103
+ executable.replace(target)
104
+ return target
105
+
106
+
107
+ def start() -> None:
108
+ executable = _target()
109
+ if not executable.exists():
110
+ system_executable = shutil.which("dagu")
111
+ if system_executable is None:
112
+ raise RuntimeError("Dagu is not installed; run: funmill install dagu")
113
+ executable = Path(system_executable)
114
+
115
+ environment = os.environ.copy()
116
+ environment.setdefault("DAGU_AUTH_MODE", "none")
117
+ service_directory = _target().parent
118
+ environment["DAGU_HOME"] = str(service_directory / "data")
119
+ environment.setdefault("DAGU_COORDINATOR_ENABLED", "false")
120
+ environment.setdefault(
121
+ "FUNMILL_DAGU_URL", f"http://127.0.0.1:{THIRD_PARTY_WEB_PORT}/api/v1"
122
+ )
123
+ if environment.get("DAGU_TOKEN"):
124
+ environment.setdefault("FUNMILL_DAGU_TOKEN", environment["DAGU_TOKEN"])
125
+ start_background(
126
+ "dagu",
127
+ [
128
+ str(executable),
129
+ "start-all",
130
+ "--dagu-home",
131
+ str(service_directory / "data"),
132
+ "--host",
133
+ SERVICE_BIND_HOST,
134
+ "--port",
135
+ str(THIRD_PARTY_WEB_PORT),
136
+ "--coordinator.host",
137
+ SERVICE_BIND_HOST,
138
+ ],
139
+ environment,
140
+ service_directory,
141
+ )
142
+
143
+
144
+ def stop() -> None:
145
+ stop_background("dagu", _target().parent)
146
+
147
+
148
+ def status() -> bool:
149
+ return status_background("dagu", _target().parent)