@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.
@@ -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 = 2 * 60 * 60
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
- return path
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", "--subject", subject]
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
- result = subprocess.run(command, cwd=helper_dir, env=env, capture_output=True, text=True, timeout=VIDEO_TIMEOUT_SECONDS, check=False)
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
- except subprocess.TimeoutExpired as exc:
143
- raise PipelineError(f"A etapa Vídeo excedeu o limite de {VIDEO_TIMEOUT_SECONDS // 60} minutos e foi encerrada.") from exc
144
- output = "\n".join(part for part in (result.stdout, result.stderr) if part)
145
- if result.returncode == 10:
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 result.returncode != 0:
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
- result_file = Path.home() / "MoneyPrinterTurbo" / ".agent-logs" / "moneyprinterturbo-video" / "latest-result.json"
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
- _write_worker_state(last_task_id=None, last_error="", status="idle")
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
- _write_worker_state(last_task_id=task_id, status="running", last_error="")
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
- _write_worker_state(status="completed", last_error="")
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
- update_task(task_id, {"state": "failed", "error": message})
303
- _write_worker_state(status="failed", last_error=message)
304
- return {"ok": False, "task_id": task_id, "error": message}
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)))
@@ -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": "",