@danhachuel/thunderbolt 0.3.35 → 0.3.37
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.
- package/MANUAL-INSTALACAO.md +29 -6
- package/README.md +18 -5
- package/THIRD-PARTY-NOTICES.md +12 -0
- package/app/main.py +383 -4
- package/hermes_ui/pipeline_worker.py +291 -21
- package/hermes_ui/storage.py +25 -0
- package/integrations/music_uploads.py +381 -0
- package/package.json +1 -1
- package/requirements.txt +2 -0
|
@@ -2,8 +2,10 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
|
+
import queue
|
|
5
6
|
import re
|
|
6
7
|
import subprocess
|
|
8
|
+
import threading
|
|
7
9
|
import time
|
|
8
10
|
from datetime import datetime, timezone
|
|
9
11
|
from pathlib import Path
|
|
@@ -20,13 +22,18 @@ from hermes_ui.thumbnail_generation import generate_thumbnail_image
|
|
|
20
22
|
PIPELINE_LOCK_FILENAME = "pipeline_worker.lock"
|
|
21
23
|
PIPELINE_LOG_FILENAME = "pipeline_worker.json"
|
|
22
24
|
VIDEO_TIMEOUT_SECONDS = 20 * 60
|
|
23
|
-
STALE_TASK_SECONDS =
|
|
25
|
+
STALE_TASK_SECONDS = VIDEO_TIMEOUT_SECONDS + 5 * 60
|
|
26
|
+
WORKER_HEARTBEAT_TIMEOUT_SECONDS = 15
|
|
24
27
|
|
|
25
28
|
|
|
26
29
|
class PipelineError(RuntimeError):
|
|
27
30
|
"""Raised when a pipeline stage cannot complete with an actionable error."""
|
|
28
31
|
|
|
29
32
|
|
|
33
|
+
class PipelineStopped(PipelineError):
|
|
34
|
+
"""Raised when the user stops a task while the worker is processing it."""
|
|
35
|
+
|
|
36
|
+
|
|
30
37
|
def _now() -> str:
|
|
31
38
|
return datetime.now(timezone.utc).isoformat()
|
|
32
39
|
|
|
@@ -41,16 +48,45 @@ def _lock_path() -> Path:
|
|
|
41
48
|
return STORAGE / "state" / PIPELINE_LOCK_FILENAME
|
|
42
49
|
|
|
43
50
|
|
|
51
|
+
def _pid_alive(pid: int) -> bool:
|
|
52
|
+
if pid <= 0:
|
|
53
|
+
return False
|
|
54
|
+
try:
|
|
55
|
+
os.kill(pid, 0)
|
|
56
|
+
except ProcessLookupError:
|
|
57
|
+
return False
|
|
58
|
+
except PermissionError:
|
|
59
|
+
return True
|
|
60
|
+
except OSError:
|
|
61
|
+
return False
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
|
|
44
65
|
def _acquire_lock() -> Path | None:
|
|
45
66
|
path = _lock_path()
|
|
46
67
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
47
68
|
try:
|
|
48
69
|
descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
70
|
+
except FileExistsError:
|
|
71
|
+
try:
|
|
72
|
+
old_pid = int(path.read_text(encoding="utf-8").strip())
|
|
73
|
+
except (OSError, ValueError):
|
|
74
|
+
old_pid = 0
|
|
75
|
+
if _pid_alive(old_pid):
|
|
76
|
+
return None
|
|
77
|
+
try:
|
|
78
|
+
path.unlink()
|
|
79
|
+
except OSError:
|
|
80
|
+
return None
|
|
81
|
+
try:
|
|
82
|
+
descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
83
|
+
except FileExistsError:
|
|
84
|
+
return None
|
|
85
|
+
try:
|
|
49
86
|
os.write(descriptor, str(os.getpid()).encode("ascii"))
|
|
87
|
+
finally:
|
|
50
88
|
os.close(descriptor)
|
|
51
|
-
|
|
52
|
-
except FileExistsError:
|
|
53
|
-
return None
|
|
89
|
+
return path
|
|
54
90
|
|
|
55
91
|
|
|
56
92
|
def _write_worker_state(**updates: Any) -> None:
|
|
@@ -62,6 +98,71 @@ def _write_worker_state(**updates: Any) -> None:
|
|
|
62
98
|
write_json(PIPELINE_LOG_FILENAME, state)
|
|
63
99
|
|
|
64
100
|
|
|
101
|
+
def _worker_heartbeat(**updates: Any) -> None:
|
|
102
|
+
_write_worker_state(
|
|
103
|
+
worker_pid=os.getpid(),
|
|
104
|
+
last_heartbeat_at=_now(),
|
|
105
|
+
**updates,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _parse_timestamp(value: Any) -> datetime | None:
|
|
110
|
+
text = str(value or "").strip()
|
|
111
|
+
if not text:
|
|
112
|
+
return None
|
|
113
|
+
try:
|
|
114
|
+
parsed = datetime.fromisoformat(text)
|
|
115
|
+
except ValueError:
|
|
116
|
+
return None
|
|
117
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def load_pipeline_worker_status() -> dict[str, Any]:
|
|
121
|
+
"""Return the persisted worker heartbeat for the Backlog UI."""
|
|
122
|
+
status = read_json(PIPELINE_LOG_FILENAME, {})
|
|
123
|
+
if not isinstance(status, dict):
|
|
124
|
+
status = {}
|
|
125
|
+
heartbeat_at = _parse_timestamp(status.get("last_heartbeat_at"))
|
|
126
|
+
status["alive"] = bool(
|
|
127
|
+
heartbeat_at
|
|
128
|
+
and (datetime.now(timezone.utc) - heartbeat_at.astimezone(timezone.utc)).total_seconds()
|
|
129
|
+
<= WORKER_HEARTBEAT_TIMEOUT_SECONDS
|
|
130
|
+
)
|
|
131
|
+
return status
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _recover_stale_tasks() -> list[str]:
|
|
135
|
+
"""Convert abandoned doing tasks to failed after the worker timeout window."""
|
|
136
|
+
from hermes_ui.domain import update_task
|
|
137
|
+
|
|
138
|
+
recovered: list[str] = []
|
|
139
|
+
current_time = datetime.now(timezone.utc)
|
|
140
|
+
for task in read_json("tasks.json", []):
|
|
141
|
+
if not isinstance(task, dict) or str(task.get("state") or "") != "doing":
|
|
142
|
+
continue
|
|
143
|
+
updated_at = _parse_timestamp(task.get("updated_at"))
|
|
144
|
+
if not updated_at:
|
|
145
|
+
continue
|
|
146
|
+
age_seconds = (current_time - updated_at.astimezone(timezone.utc)).total_seconds()
|
|
147
|
+
if age_seconds <= STALE_TASK_SECONDS:
|
|
148
|
+
continue
|
|
149
|
+
task_id = str(task.get("id") or "")
|
|
150
|
+
if not task_id:
|
|
151
|
+
continue
|
|
152
|
+
message = (
|
|
153
|
+
f"A tarefa ficou sem heartbeat durante mais de {STALE_TASK_SECONDS // 60} minutos. "
|
|
154
|
+
"Foi marcada como falhada para evitar execução eterna; reveja o log do worker."
|
|
155
|
+
)
|
|
156
|
+
update_task(task_id, {"state": "failed", "error": message, "failed_stage": task.get("stage") or "pipeline"})
|
|
157
|
+
recovered.append(task_id)
|
|
158
|
+
return recovered
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def recover_stale_tasks() -> list[str]:
|
|
162
|
+
"""Public wrapper used by the UI to recover tasks after an abrupt worker exit."""
|
|
163
|
+
return _recover_stale_tasks()
|
|
164
|
+
|
|
165
|
+
|
|
65
166
|
def _task_by_id(task_id: str) -> dict[str, Any] | None:
|
|
66
167
|
return next((task for task in read_json("tasks.json", []) if isinstance(task, dict) and task.get("id") == task_id), None)
|
|
67
168
|
|
|
@@ -69,9 +170,20 @@ def _task_by_id(task_id: str) -> dict[str, Any] | None:
|
|
|
69
170
|
def _update(task_id: str, **updates: Any) -> dict[str, Any]:
|
|
70
171
|
from hermes_ui.domain import update_task
|
|
71
172
|
|
|
173
|
+
current = _task_by_id(task_id)
|
|
174
|
+
if not current:
|
|
175
|
+
raise PipelineError(f"Tarefa {task_id} deixou de existir durante a execução.")
|
|
176
|
+
if str(current.get("state") or "") in {"blocked", "cancelled"}:
|
|
177
|
+
raise PipelineStopped("A tarefa foi parada pelo utilizador.")
|
|
72
178
|
updated = update_task(task_id, updates)
|
|
73
179
|
if not updated:
|
|
74
180
|
raise PipelineError(f"Tarefa {task_id} deixou de existir durante a execução.")
|
|
181
|
+
_worker_heartbeat(
|
|
182
|
+
task_id=task_id,
|
|
183
|
+
status="running",
|
|
184
|
+
stage=str(updated.get("stage") or "pipeline"),
|
|
185
|
+
progress=int(updated.get("progress") or 0),
|
|
186
|
+
)
|
|
75
187
|
return updated
|
|
76
188
|
|
|
77
189
|
|
|
@@ -111,6 +223,75 @@ def _save_json_artifact(task_id: str, name: str, payload: dict[str, Any]) -> str
|
|
|
111
223
|
return str(path)
|
|
112
224
|
|
|
113
225
|
|
|
226
|
+
def _configured_moneyprinter_root(settings: dict[str, Any]) -> Path | None:
|
|
227
|
+
"""Resolve the installed MoneyPrinterTurbo project selected by the user."""
|
|
228
|
+
configured = str(settings.get("moneyprinter_path") or os.environ.get("MONEYPRINTER_PATH") or "").strip()
|
|
229
|
+
if not configured:
|
|
230
|
+
return None
|
|
231
|
+
root = Path(configured).expanduser().resolve()
|
|
232
|
+
if not (root / "cli.py").is_file():
|
|
233
|
+
raise PipelineError(f"A pasta configurada do MoneyPrinterTurbo não contém cli.py: {root}")
|
|
234
|
+
if not ((root / "config.toml").is_file() or (root / "config.example.toml").is_file()):
|
|
235
|
+
raise PipelineError(f"A pasta configurada do MoneyPrinterTurbo não contém config.toml: {root}")
|
|
236
|
+
return root
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _helper_output_value(output: str, key: str) -> str:
|
|
240
|
+
match = re.search(rf"(?m)^{re.escape(key)}=(.+)$", output)
|
|
241
|
+
return match.group(1).strip() if match else ""
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _redact_helper_output(text: str) -> str:
|
|
245
|
+
for key in ("MPT_LLM_API_KEY", "MPT_PEXELS_API_KEY"):
|
|
246
|
+
secret = os.environ.get(key, "").strip()
|
|
247
|
+
if secret:
|
|
248
|
+
text = text.replace(secret, "[redacted]")
|
|
249
|
+
return text
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _persist_video_diagnostics(task: dict[str, Any], output: str) -> dict[str, str]:
|
|
253
|
+
"""Persist only bounded helper diagnostics and return its declared file paths."""
|
|
254
|
+
task_id = str(task.get("id") or "").strip()
|
|
255
|
+
if not task_id:
|
|
256
|
+
return {}
|
|
257
|
+
log_file = _helper_output_value(output, "LOG_FILE")
|
|
258
|
+
result_file = _helper_output_value(output, "RESULT_FILE")
|
|
259
|
+
try:
|
|
260
|
+
payload: dict[str, Any] = {
|
|
261
|
+
"captured_at": _now(),
|
|
262
|
+
"log_file": log_file,
|
|
263
|
+
"result_file": result_file,
|
|
264
|
+
"output_tail": _redact_helper_output(output[-6000:]),
|
|
265
|
+
}
|
|
266
|
+
artifact_path = _save_json_artifact(task_id, "video-diagnostics", payload)
|
|
267
|
+
current = _task_by_id(task_id) or task
|
|
268
|
+
artifacts = dict(current.get("artifacts") or {})
|
|
269
|
+
artifacts["video_diagnostics"] = artifact_path
|
|
270
|
+
updates: dict[str, Any] = {"artifacts": artifacts}
|
|
271
|
+
if log_file:
|
|
272
|
+
updates["video_log"] = log_file
|
|
273
|
+
artifacts["video_log"] = log_file
|
|
274
|
+
if result_file:
|
|
275
|
+
updates["video_result"] = result_file
|
|
276
|
+
artifacts["video_result"] = result_file
|
|
277
|
+
from hermes_ui.domain import update_task
|
|
278
|
+
update_task(task_id, updates)
|
|
279
|
+
return {"log_file": log_file, "result_file": result_file, "artifact": artifact_path}
|
|
280
|
+
except Exception:
|
|
281
|
+
# Diagnostics must never hide the actual generation error.
|
|
282
|
+
return {"log_file": log_file, "result_file": result_file}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _stop_process(process: subprocess.Popen[str]) -> None:
|
|
286
|
+
if process.poll() is None:
|
|
287
|
+
process.kill()
|
|
288
|
+
try:
|
|
289
|
+
process.wait(timeout=5)
|
|
290
|
+
except subprocess.TimeoutExpired:
|
|
291
|
+
process.kill()
|
|
292
|
+
process.wait()
|
|
293
|
+
|
|
294
|
+
|
|
114
295
|
def _run_video_helper(task: dict[str, Any]) -> Path:
|
|
115
296
|
helper_dir = Path(__file__).resolve().parents[1] / "seed" / "skills"
|
|
116
297
|
helper = helper_dir / "mpt_agent.py"
|
|
@@ -120,6 +301,10 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
|
|
|
120
301
|
if not subject:
|
|
121
302
|
raise PipelineError("A etapa Vídeo não recebeu um tema válido.")
|
|
122
303
|
settings = _settings()
|
|
304
|
+
configured_root = _configured_moneyprinter_root(settings)
|
|
305
|
+
task_id = str(task.get("id") or "").strip()
|
|
306
|
+
if not task_id:
|
|
307
|
+
raise PipelineError("A tarefa de vídeo não tem um identificador válido.")
|
|
123
308
|
env = os.environ.copy()
|
|
124
309
|
card = active_llm_card(settings)
|
|
125
310
|
provider = str(card.get("provider") or "openai").strip()
|
|
@@ -134,23 +319,97 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
|
|
|
134
319
|
for key, value in env_values.items():
|
|
135
320
|
if value:
|
|
136
321
|
env[key] = value
|
|
137
|
-
command = ["uv", "run", "--no-project", "--python", "3.11", "python", "mpt_agent.py"
|
|
322
|
+
command = ["uv", "run", "--no-project", "--python", "3.11", "python", "mpt_agent.py"]
|
|
323
|
+
if configured_root:
|
|
324
|
+
command.extend(["--root", str(configured_root)])
|
|
325
|
+
command.extend(["--subject", subject])
|
|
326
|
+
output_lines: list[str] = []
|
|
327
|
+
line_queue: queue.Queue[str | None] = queue.Queue()
|
|
328
|
+
started_at = time.monotonic()
|
|
329
|
+
process: subprocess.Popen[str] | None = None
|
|
330
|
+
|
|
331
|
+
def _read_output() -> None:
|
|
332
|
+
if process is None or process.stdout is None:
|
|
333
|
+
line_queue.put(None)
|
|
334
|
+
return
|
|
335
|
+
for line in iter(process.stdout.readline, ""):
|
|
336
|
+
line_queue.put(line.rstrip())
|
|
337
|
+
process.stdout.close()
|
|
338
|
+
line_queue.put(None)
|
|
339
|
+
|
|
138
340
|
try:
|
|
139
|
-
|
|
341
|
+
process = subprocess.Popen(
|
|
342
|
+
command,
|
|
343
|
+
cwd=helper_dir,
|
|
344
|
+
env=env,
|
|
345
|
+
stdout=subprocess.PIPE,
|
|
346
|
+
stderr=subprocess.STDOUT,
|
|
347
|
+
text=True,
|
|
348
|
+
bufsize=1,
|
|
349
|
+
)
|
|
140
350
|
except FileNotFoundError as exc:
|
|
141
351
|
raise PipelineError("O comando uv não está instalado; não foi possível iniciar a geração de vídeo.") from exc
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
352
|
+
|
|
353
|
+
reader = threading.Thread(target=_read_output, name=f"mpt-output-{task.get('id', 'video')}", daemon=True)
|
|
354
|
+
reader.start()
|
|
355
|
+
output_finished = False
|
|
356
|
+
last_heartbeat = 0.0
|
|
357
|
+
try:
|
|
358
|
+
while True:
|
|
359
|
+
try:
|
|
360
|
+
line = line_queue.get(timeout=0.5)
|
|
361
|
+
if line is None:
|
|
362
|
+
output_finished = True
|
|
363
|
+
elif line:
|
|
364
|
+
output_lines.append(line)
|
|
365
|
+
except queue.Empty:
|
|
366
|
+
pass
|
|
367
|
+
elapsed = time.monotonic() - started_at
|
|
368
|
+
if elapsed - last_heartbeat >= 5:
|
|
369
|
+
# O helper expõe o resultado final, mas não uma percentagem estável.
|
|
370
|
+
# Mantemos uma faixa reservada para a etapa de vídeo e avançamos-a
|
|
371
|
+
# lentamente enquanto o processo responde, sem fingir conclusão.
|
|
372
|
+
video_progress = min(79, 68 + int(elapsed // 15))
|
|
373
|
+
current_task = _task_by_id(task_id)
|
|
374
|
+
if current_task and str(current_task.get("state") or "") in {"blocked", "cancelled"}:
|
|
375
|
+
_stop_process(process)
|
|
376
|
+
raise PipelineStopped("A tarefa foi parada pelo utilizador.")
|
|
377
|
+
_update(
|
|
378
|
+
task_id,
|
|
379
|
+
progress=video_progress,
|
|
380
|
+
video_elapsed_seconds=int(elapsed),
|
|
381
|
+
)
|
|
382
|
+
_worker_heartbeat(
|
|
383
|
+
task_id=str(task.get("id") or ""),
|
|
384
|
+
status="running",
|
|
385
|
+
stage="video",
|
|
386
|
+
progress=video_progress,
|
|
387
|
+
video_elapsed_seconds=int(elapsed),
|
|
388
|
+
)
|
|
389
|
+
last_heartbeat = elapsed
|
|
390
|
+
if process.poll() is not None and output_finished:
|
|
391
|
+
break
|
|
392
|
+
if elapsed >= VIDEO_TIMEOUT_SECONDS:
|
|
393
|
+
_stop_process(process)
|
|
394
|
+
raise PipelineError(f"A etapa Vídeo excedeu o limite de {VIDEO_TIMEOUT_SECONDS // 60} minutos e foi encerrada.")
|
|
395
|
+
finally:
|
|
396
|
+
reader.join(timeout=2)
|
|
397
|
+
_persist_video_diagnostics(task, "\n".join(output_lines))
|
|
398
|
+
if process.returncode is None:
|
|
399
|
+
process.wait(timeout=5)
|
|
400
|
+
result_code = process.returncode
|
|
401
|
+
output = "\n".join(output_lines)
|
|
402
|
+
_persist_video_diagnostics(task, output)
|
|
403
|
+
if result_code == 10:
|
|
146
404
|
raise PipelineError("A geração de vídeo precisa de credenciais adicionais do MoneyPrinterTurbo.")
|
|
147
|
-
if
|
|
148
|
-
detail = output[-1200:].strip() or "erro sem detalhes devolvidos pelo helper"
|
|
405
|
+
if result_code != 0:
|
|
406
|
+
detail = _redact_helper_output(output[-1200:]).strip() or "erro sem detalhes devolvidos pelo helper"
|
|
149
407
|
raise PipelineError(f"MoneyPrinterTurbo falhou na etapa Vídeo: {detail}")
|
|
150
408
|
match = re.search(r"(?m)^VIDEO_FILE=(.+)$", output)
|
|
151
409
|
video_path = Path(match.group(1).strip()).expanduser() if match else None
|
|
152
410
|
if not video_path or not video_path.is_file() or video_path.stat().st_size <= 0:
|
|
153
|
-
|
|
411
|
+
result_root = configured_root or (Path.home() / "MoneyPrinterTurbo")
|
|
412
|
+
result_file = result_root / ".agent-logs" / "moneyprinterturbo-video" / "latest-result.json"
|
|
154
413
|
if result_file.is_file():
|
|
155
414
|
try:
|
|
156
415
|
payload = json.loads(result_file.read_text(encoding="utf-8"))
|
|
@@ -285,23 +544,33 @@ def run_once() -> dict[str, Any]:
|
|
|
285
544
|
if lock is None:
|
|
286
545
|
return {"ok": True, "busy": True}
|
|
287
546
|
try:
|
|
547
|
+
recovered = _recover_stale_tasks()
|
|
288
548
|
tasks = read_json("tasks.json", [])
|
|
289
549
|
candidate = next((task for task in tasks if isinstance(task, dict) and task.get("state") in {"to_do", "doing"}), None)
|
|
290
550
|
if not candidate:
|
|
291
|
-
|
|
292
|
-
return {"ok": True, "status": "idle"}
|
|
551
|
+
_worker_heartbeat(last_task_id=None, last_error="", status="idle", stage="idle", progress=0, recovered_task_ids=recovered)
|
|
552
|
+
return {"ok": True, "status": "idle", "recovered_task_ids": recovered}
|
|
293
553
|
task_id = str(candidate.get("id") or "")
|
|
294
|
-
|
|
554
|
+
_worker_heartbeat(last_task_id=task_id, status="running", stage=str(candidate.get("stage") or "pipeline"), progress=int(candidate.get("progress") or 0), last_error="", recovered_task_ids=recovered)
|
|
295
555
|
try:
|
|
296
556
|
result = _run_task(candidate)
|
|
297
|
-
|
|
298
|
-
return {"ok": True, "task_id": task_id, "task": result}
|
|
557
|
+
_worker_heartbeat(status="completed", last_error="", stage=str(result.get("stage") or "upload"), progress=100, task_id=task_id)
|
|
558
|
+
return {"ok": True, "task_id": task_id, "task": result, "recovered_task_ids": recovered}
|
|
559
|
+
except PipelineStopped as exc:
|
|
560
|
+
current_task = _task_by_id(task_id) or candidate
|
|
561
|
+
current_state = str(current_task.get("state") or "")
|
|
562
|
+
if current_state not in {"blocked", "cancelled"}:
|
|
563
|
+
from hermes_ui.domain import update_task
|
|
564
|
+
update_task(task_id, {"state": "blocked", "error": str(exc), "failed_stage": current_task.get("stage") or "pipeline"})
|
|
565
|
+
_worker_heartbeat(status="stopped", last_error=str(exc), stage=str(current_task.get("stage") or "pipeline"), progress=int(current_task.get("progress") or 0), task_id=task_id)
|
|
566
|
+
return {"ok": True, "task_id": task_id, "status": "stopped", "recovered_task_ids": recovered}
|
|
299
567
|
except Exception as exc:
|
|
300
568
|
message = str(exc)[:2000]
|
|
301
569
|
from hermes_ui.domain import update_task
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
570
|
+
current_task = _task_by_id(task_id) or candidate
|
|
571
|
+
update_task(task_id, {"state": "failed", "error": message, "failed_stage": current_task.get("stage") or "pipeline"})
|
|
572
|
+
_worker_heartbeat(status="failed", last_error=message, stage=str(current_task.get("stage") or "pipeline"), progress=int(current_task.get("progress") or 0), task_id=task_id)
|
|
573
|
+
return {"ok": False, "task_id": task_id, "error": message, "recovered_task_ids": recovered}
|
|
305
574
|
finally:
|
|
306
575
|
try:
|
|
307
576
|
lock.unlink()
|
|
@@ -311,6 +580,7 @@ def run_once() -> dict[str, Any]:
|
|
|
311
580
|
|
|
312
581
|
def run_worker(interval_seconds: int = 5) -> None:
|
|
313
582
|
ensure_storage()
|
|
583
|
+
_worker_heartbeat(status="starting", stage="idle", progress=0, last_error="")
|
|
314
584
|
while True:
|
|
315
585
|
run_once()
|
|
316
586
|
time.sleep(max(2, int(interval_seconds)))
|
package/hermes_ui/storage.py
CHANGED
|
@@ -268,6 +268,31 @@ DEFAULTS: dict[str, Any] = {
|
|
|
268
268
|
"suno_api_key": "",
|
|
269
269
|
"suno_api_base_url": "",
|
|
270
270
|
"suno_api_endpoint": "/api/generate",
|
|
271
|
+
"jewelmusic_enabled": False,
|
|
272
|
+
"jewelmusic_api_key": "",
|
|
273
|
+
"jewelmusic_base_url": "https://api.jewelmusic.com",
|
|
274
|
+
"jewelmusic_proxy_url": "",
|
|
275
|
+
"jewelmusic_timeout_seconds": 120,
|
|
276
|
+
"pushtunes_enabled": False,
|
|
277
|
+
"pushtunes_executable": "pushtunes",
|
|
278
|
+
"pushtunes_source": "csv",
|
|
279
|
+
"pushtunes_target": "ytm",
|
|
280
|
+
"pushtunes_operation": "tracks",
|
|
281
|
+
"pushtunes_profile": "",
|
|
282
|
+
"pushtunes_csv_file": "",
|
|
283
|
+
"pushtunes_ytm_auth_file": "",
|
|
284
|
+
"pushtunes_tidal_session_file": "",
|
|
285
|
+
"pushtunes_playlist_name": "",
|
|
286
|
+
"pushtunes_similarity": 0.8,
|
|
287
|
+
"pushtunes_working_directory": "",
|
|
288
|
+
"pushtunes_spotify_client_id": "",
|
|
289
|
+
"pushtunes_spotify_client_secret": "",
|
|
290
|
+
"pushtunes_spotify_redirect_uri": "",
|
|
291
|
+
"pushtunes_timeout_seconds": 1800,
|
|
292
|
+
"ytmusicapi_enabled": False,
|
|
293
|
+
"ytmusicapi_auth_file": "",
|
|
294
|
+
"ytmusicapi_proxy_url": "",
|
|
295
|
+
"ytmusicapi_timeout_seconds": 240,
|
|
271
296
|
"voice_preview_provider": "edge",
|
|
272
297
|
"voice_preview_rate": "+0%",
|
|
273
298
|
"direct_cookie_sid": "",
|