@danhachuel/thunderbolt 0.3.36 → 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.
@@ -1,5 +1,4 @@
1
1
  [theme]
2
- base = "dark"
3
2
  font = "sans serif"
4
3
 
5
4
  [theme.dark]
@@ -2,7 +2,7 @@
2
2
 
3
3
  Este manual descreve a instalação local da UI Thunderbolt, baseada no MoneyPrinterTurbo, utilizando o pacote npm `@danhachuel/thunderbolt`. O fluxo recomendado instala automaticamente o ambiente Python, as dependências da aplicação, as dependências do MoneyPrinterTurbo, o Streamlit e o suporte FFmpeg através de `imageio-ffmpeg`.
4
4
 
5
- > **Versão deste manual:** 0.3.36
5
+ > **Versão deste manual:** 0.3.37
6
6
  > **Pacote npm:** `@danhachuel/thunderbolt`
7
7
  > **Porta padrão da UI:** `localhost:3030`
8
8
  > **Repositório:** [github.com/DanHachuel/thunderbolt](https://github.com/DanHachuel/thunderbolt)
@@ -100,13 +100,13 @@ Execute:
100
100
  Windows PowerShell ou MobaXterm:
101
101
 
102
102
  ```powershell
103
- npx.cmd --yes @danhachuel/thunderbolt@0.3.36 install
103
+ npx.cmd --yes @danhachuel/thunderbolt@0.3.37 install
104
104
  ```
105
105
 
106
106
  Linux/macOS:
107
107
 
108
108
  ```bash
109
- npx --yes @danhachuel/thunderbolt@0.3.36 install
109
+ npx --yes @danhachuel/thunderbolt@0.3.37 install
110
110
  ```
111
111
 
112
112
  A instalação normal é **segura para actualizações**: preserva `storage`, Blueprints, Brandings, configurações e artefactos do utilizador. Remove apenas `.venv`, o clone técnico do MoneyPrinterTurbo e dependências que serão recriadas. Uma pasta antiga sem dados do utilizador, como `C:\Users\<utilizador>\AppData\Local\hermes` da tentativa incompleta, pode ser removida; uma pasta antiga que contenha Blueprints, Brandings ou storage é preservada e apenas avisada no terminal. Feche processos Python, Node, Streamlit e MobaXterm que estejam a usar as pastas antes de executar.
@@ -200,7 +200,7 @@ Todas as subabas internas e o conteúdo das páginas também são traduzidos nos
200
200
 
201
201
  ### Temas Light e Dark
202
202
 
203
- A aplicação usa o mecanismo nativo de temas do Streamlit e é distribuída com `.streamlit/config.toml`, seguindo o padrão de configuração do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo). O ficheiro define **Dark** como base inicial. A alternância para **Light** e o regresso a **Dark** ficam exclusivamente no menu nativo de três pontos do Streamlit, no local original do toolbar; não existe um selector Theme dentro da página. Os componentes próprios da UI herdam as cores do tema activo através de `currentColor` e `color-mix`. O toolbar nativo, o indicador de execução, **Deploy** e o menu principal continuam sem sobreposições CSS.
203
+ A aplicação usa o mecanismo nativo de temas do Streamlit e é distribuída com `.streamlit/config.toml`, seguindo o padrão de configuração do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo). O ficheiro disponibiliza as variantes nomeadas **Dark** e **Light**. A alternância entre elas fica exclusivamente no menu nativo de três pontos do Streamlit, no local original do toolbar; não existe um selector Theme dentro da página. Os componentes próprios da UI herdam as cores do tema activo através de `currentColor` e `color-mix`. O toolbar nativo, o indicador de execução, **Deploy** e o menu principal continuam sem sobreposições CSS.
204
204
 
205
205
  ## 4. Diagnóstico antes de iniciar
206
206
 
@@ -386,6 +386,16 @@ Na primeira execução, a barra lateral apresenta **Início**, **Niche Finder**,
386
386
  | Telegram Proxy | Proxy HTTP/HTTPS/SOCKS opcional para ambientes sem acesso directo |
387
387
  | Telegram timeout | Limite de espera de cada envio, entre 5 e 120 segundos |
388
388
 
389
+ ### Execução do pipeline de vídeos e acompanhamento
390
+
391
+ Ao clicar em **Start** no **Backlog Vídeos**, a tarefa passa para `doing` e é processada pelo worker de pipeline iniciado pelo launcher normal. O worker grava o estado em `storage/state/pipeline_worker.json` e actualiza `updated_at`, a etapa e a percentagem em `storage/state/tasks.json`. O painel do Backlog consulta esse estado automaticamente a cada cinco segundos e mostra o worker, a etapa corrente, a percentagem, a idade da actualização e a mensagem de erro quando existe.
392
+
393
+ A percentagem representa o avanço conhecido do pipeline: tema, roteiro, título/keywords, thumbnail, vídeo e upload. Durante a chamada longa ao MoneyPrinterTurbo, a UI mantém um heartbeat a cada cinco segundos e avança apenas dentro da faixa reservada à geração do vídeo; não apresenta 100% antes de existir um MP4 válido. O worker passa explicitamente a **Pasta do motor de vídeo** configurada na UI ao helper, por isso o clone usado, os logs e o manifesto pertencem à instalação seleccionada pelo utilizador.
394
+
395
+ Uma execução da etapa Vídeo tem limite de 20 minutos. Se o processo externo terminar com erro, exceder o limite, devolver um resultado inválido ou ocorrer uma excepção inesperada, a tarefa é marcada como `failed`, com a etapa em `failed_stage`. As últimas linhas devolvidas pelo helper, sem as credenciais configuradas, são guardadas num artefacto `video-diagnostics` e as referências `video_log`/`video_result` ficam associadas à tarefa. Se o utilizador clicar em **Stop**, a tarefa passa para `blocked`, o subprocesso é terminado cooperativamente e o worker não substitui esse estado por `failed`.
396
+
397
+ Se o launcher ou o worker for encerrado abruptamente, uma tarefa `doing` sem actualização durante 25 minutos é recuperada e marcada como `failed`, evitando estados indefinidos eternos. Para processar novas tarefas, deixe a aplicação iniciada com o comando normal; o painel avisa quando não existe heartbeat recente do worker.
398
+
389
399
  ### Upload Música — JewelMusic, Pushtunes e ytmusicapi
390
400
 
391
401
  A área **Pipeline Música > Upload Música** separa três métodos com contratos diferentes. Em **JewelMusic**, active a integração, introduza a API Key fornecida pelo dashboard da JewelMusic e confirme a Base URL oficial `https://api.jewelmusic.com` e, se necessário, configure proxy e timeout. Carregue ou seleccione um ficheiro de música, indique artista e título e clique em **Enviar música para JewelMusic**. O teste de ligação consulta `/v1/ping`; o upload envia `multipart/form-data` para `/v1/tracks/upload` com os metadados preenchidos.
@@ -471,7 +481,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
471
481
 
472
482
  Os parâmetros da UI são número de clusters entre 2 e 10, suporte mínimo entre 0,01 e 0,50, país, engagement, intervalo de datas e tags, todos dentro da área principal da aba. O núcleo normaliza os dados, calcula engagement, aplica filtros, faz transformação logarítmica e standardização, executa K-Means e calcula itemsets/regras com FP-Growth. Não são apresentados resultados até ao primeiro clique em **Analisar Nichos**; o mesmo botão aplica alterações posteriores aos filtros. Os resultados são DataFrames de clusters, itemsets frequentes, regras de associação e dados analisados; o gráfico de dispersão é criado nativamente com Plotly.
473
483
 
474
- As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.3.36 install`; o instalador detecta e reutiliza o que já estiver válido.
484
+ As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.3.37 install`; o instalador detecta e reutiliza o que já estiver válido.
475
485
 
476
486
  ### Niche Finder Apify
477
487
 
package/README.md CHANGED
@@ -12,6 +12,7 @@ A primeira versão implementa a camada UI independente com:
12
12
  |---|---|
13
13
  | Início | Resumo de canais, tarefas, backlog, execução e falhas, com as filas do Pipeline inline |
14
14
  | Pipeline Vídeos | Menu expansível com Criação de Vídeos, Backlog Vídeos, Roteiros, Thumbnails e Upload |
15
+ | Worker de vídeo | Heartbeat persistido, barra de progresso por etapas, timeout de 20 minutos, recuperação de tarefas abandonadas e diagnóstico bounded do helper MoneyPrinterTurbo |
15
16
  | Pipeline Música | Menu expansível com Criação de Músicas e Upload Música |
16
17
  | Blueprints Youtube | Leitura da pasta `storage/blueprints/`, upload/validação de JSON e criação a partir de link YouTube |
17
18
  | Brandings | Subaba própria dentro de Blueprints, upload/listagem de Brandings e criação conjunta com Blueprint |
@@ -66,9 +67,9 @@ No topo da área principal da aplicação existe o menu nativo de idioma no padr
66
67
 
67
68
  ## Temas claro e escuro
68
69
 
69
- A UI suporta os temas **Dark** e **Light** através do menu nativo de três pontos do Streamlit, no local original do toolbar. Não existe um selector Theme adicional dentro da página. A configuração distribuída em `.streamlit/config.toml` define **Dark** como base inicial e o menu nativo continua responsável por alternar entre os modos, seguindo o padrão do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo). O CSS próprio do Thunderbolt usa cores semânticas, `currentColor` e `color-mix` para acompanhar o tema activo, sem alterar a posição nem a funcionalidade do toolbar, do botão Deploy e do menu principal.
70
+ A UI suporta os temas **Dark** e **Light** através do menu nativo de três pontos do Streamlit, no local original do toolbar. Não existe um selector Theme adicional dentro da página. A configuração distribuída em `.streamlit/config.toml` disponibiliza as variantes nomeadas **Dark** e **Light**, e o menu nativo continua responsável por alternar entre os modos, seguindo o padrão do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo). O CSS próprio do Thunderbolt usa cores semânticas, `currentColor` e `color-mix` para acompanhar o tema activo, sem alterar a posição nem a funcionalidade do toolbar, do botão Deploy e do menu principal.
70
71
 
71
- ## Navegação da UI 0.3.36
72
+ ## Navegação da UI 0.3.37
72
73
 
73
74
  A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Pipeline TikTok**, **Automação**, **Edição**, **AI Influencers** e **Configurações**. **Pipeline Vídeos** é expansível e contém **Criação de Vídeos**, **Backlog Vídeos**, **Roteiros**, **Thumbnails** e **Upload**. **Pipeline Música** é expansível e contém **Criação de Músicas** e **Upload Música**. **Automação** também é expansível e contém **Automação Youtube**. **Edição** é expansível e contém **Limpador de Metadados**, **Cortes**, **Editor Python** e **Download Mídia**, nessa ordem. **AI Influencers** é expansível e contém **Personagens**, **Redes Sociais**, **Tutorial Meta** e **Tutorial Supabase**, nessa ordem. **Niche Finder** é expansível e contém **Niche Finder Kaggle** e **Niche Finder Apify**. **Configurações** é expansível e contém **Canais Youtube**, **Blueprints Youtube**, **MCP**, **Contas Google**, **Configuração API** e **Notificações**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
74
75
 
package/app/main.py CHANGED
@@ -6,7 +6,7 @@ import json
6
6
  import mimetypes
7
7
  import re
8
8
  from contextlib import nullcontext
9
- from datetime import date, datetime
9
+ from datetime import date, datetime, timezone
10
10
  import sys
11
11
  import uuid
12
12
  from pathlib import Path
@@ -26,6 +26,7 @@ except (OSError, json.JSONDecodeError):
26
26
  from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, pipeline_summary, set_channel_defaults, transition_task, update_channel, update_channel_video
27
27
  from hermes_ui.drafts import list_drafts, save_draft
28
28
  from hermes_ui.automation_worker import load_worker_status
29
+ from hermes_ui.pipeline_worker import load_pipeline_worker_status, recover_stale_tasks, STALE_TASK_SECONDS, WORKER_HEARTBEAT_TIMEOUT_SECONDS
29
30
  from hermes_ui.storage import BLUEPRINTS, DEFAULT_LLM_PROVIDER, MEDIA_DOWNLOADS, STORAGE, TIKTOK_PROMPT_MASTERS, ensure_storage, get_display_name, list_blueprint_files, list_prompt_master_files, load_blueprint_file, load_prompt_master_file, now, read_json, set_display_name, write_json
30
31
  from app.modules.niche_finder.apify import ApifyError, DEFAULT_ACTOR_ID, abort_actor_run, build_actor_input, get_dataset_items, normalize_video_items, start_actor_run, wait_for_actor_run
31
32
  from app.modules.niche_finder.core import NicheAnalysisError, run_niche_analysis
@@ -2449,6 +2450,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2449
2450
  tasks = create_tasks_for_batch(batch)
2450
2451
  st.success(f"Lote {batch['id']} criado com {len(tasks)} tarefa(s). Abra {ui_text('Backlog Vídeos', current_ui_language())} para acompanhar.")
2451
2452
 
2453
+ _render_pipeline_progress_panel()
2452
2454
  if draft_tab is not None:
2453
2455
  with draft_tab:
2454
2456
  render_video_from_draft()
@@ -3315,10 +3317,106 @@ def render_python_editor():
3315
3317
  st.error(str(exc))
3316
3318
 
3317
3319
 
3320
+ _PIPELINE_STAGE_LABELS = {
3321
+ "niche": "Tema",
3322
+ "blueprint": "Blueprint",
3323
+ "brand": "Branding",
3324
+ "topic": "Tema",
3325
+ "script": "Roteiro",
3326
+ "title": "Título",
3327
+ "keywords": "Keywords",
3328
+ "thumbnail_prompt": "Prompt da thumbnail",
3329
+ "thumbnail": "Thumbnail",
3330
+ "video": "Vídeo",
3331
+ "edit": "Edição",
3332
+ "upload": "Upload",
3333
+ "idle": "A aguardar",
3334
+ }
3335
+
3336
+
3337
+ def _pipeline_progress_value(task: dict[str, Any]) -> int:
3338
+ try:
3339
+ return max(0, min(100, int(task.get("progress") or 0)))
3340
+ except (TypeError, ValueError):
3341
+ return 0
3342
+
3343
+
3344
+ def _pipeline_stage_label(task: dict[str, Any]) -> str:
3345
+ stage = str(task.get("stage") or "pipeline")
3346
+ return _PIPELINE_STAGE_LABELS.get(stage, stage.replace("_", " ").title())
3347
+
3348
+
3349
+ def _pipeline_time_age(value: Any) -> str:
3350
+ text = str(value or "").strip()
3351
+ if not text:
3352
+ return "sem actualização registada"
3353
+ try:
3354
+ parsed = datetime.fromisoformat(text)
3355
+ if parsed.tzinfo is None:
3356
+ parsed = parsed.replace(tzinfo=timezone.utc)
3357
+ seconds = max(0, int((datetime.now(timezone.utc) - parsed.astimezone(timezone.utc)).total_seconds()))
3358
+ except ValueError:
3359
+ return f"última actualização: {text}"
3360
+ if seconds < 60:
3361
+ return f"actualizado há {seconds}s"
3362
+ if seconds < 3600:
3363
+ return f"actualizado há {seconds // 60}min"
3364
+ return f"actualizado há {seconds // 3600}h"
3365
+
3366
+
3367
+ def _render_pipeline_worker_banner(worker_status: dict[str, Any], active_count: int) -> None:
3368
+ if worker_status.get("alive"):
3369
+ stage = _PIPELINE_STAGE_LABELS.get(str(worker_status.get("stage") or "idle"), str(worker_status.get("stage") or "idle"))
3370
+ progress = max(0, min(100, int(worker_status.get("progress") or 0)))
3371
+ st.success(f"Worker de vídeo activo · {active_count} tarefa(s) em execução · {stage} · {progress}%")
3372
+ else:
3373
+ st.warning("Worker de vídeo sem heartbeat recente. O launcher deve estar aberto para processar as tarefas.")
3374
+ heartbeat = worker_status.get("last_heartbeat_at") or worker_status.get("updated_at")
3375
+ if heartbeat:
3376
+ st.caption(f"{_pipeline_time_age(heartbeat)} · timeout de execução: {STALE_TASK_SECONDS // 60} minutos")
3377
+ if worker_status.get("last_error"):
3378
+ st.error(f"Último erro do worker: {worker_status['last_error']}")
3379
+
3380
+
3381
+ @st.fragment(run_every=5.0)
3382
+ def _render_pipeline_progress_live() -> None:
3383
+ """Poll the persisted pipeline state only while video tasks are active."""
3384
+ worker_status = load_pipeline_worker_status()
3385
+ tasks = read_json("tasks.json", [])
3386
+ active = [task for task in tasks if isinstance(task, dict) and str(task.get("state") or "") == "doing"]
3387
+ if not worker_status.get("alive") and active:
3388
+ recovered = recover_stale_tasks()
3389
+ if recovered:
3390
+ tasks = read_json("tasks.json", [])
3391
+ active = [task for task in tasks if isinstance(task, dict) and str(task.get("state") or "") == "doing"]
3392
+ worker_status = load_pipeline_worker_status()
3393
+ if not active:
3394
+ # A fragment that was already polling must stop itself after the worker
3395
+ # reaches done/failed; otherwise Streamlit keeps refreshing an obsolete
3396
+ # fragment even though the page no longer renders an active task.
3397
+ st.rerun(scope="app")
3398
+ return
3399
+ _render_pipeline_worker_banner(worker_status, len(active))
3400
+ for task in active:
3401
+ progress = _pipeline_progress_value(task)
3402
+ label = str(task.get("title") or task.get("topic") or task.get("id") or "Vídeo")
3403
+ st.progress(progress, text=f"{label} · {_pipeline_stage_label(task)} · {progress}%")
3404
+ st.caption(f"{task.get('channel_name') or 'Canal'} · {_pipeline_time_age(task.get('updated_at'))}")
3405
+ if task.get("error"):
3406
+ st.error(str(task.get("error")))
3407
+
3408
+
3409
+ def _render_pipeline_progress_panel() -> None:
3410
+ tasks = read_json("tasks.json", [])
3411
+ if any(isinstance(task, dict) and str(task.get("state") or "") == "doing" for task in tasks):
3412
+ _render_pipeline_progress_live()
3413
+
3414
+
3318
3415
  def render_videos():
3319
3416
  st.subheader("Backlog Videos")
3320
3417
  st.caption("Acompanhamento dos vídeos criados, estados da pipeline e controlos de execução.")
3321
3418
  st.caption(f"Os vídeos são guardados em `{STORAGE / 'videos'}`.")
3419
+ _render_pipeline_progress_panel()
3322
3420
  tasks = read_json("tasks.json", [])
3323
3421
  if not tasks:
3324
3422
  st.info("Nenhum vídeo criado.")
@@ -3341,8 +3439,16 @@ def render_videos():
3341
3439
  prompt_note = ' · prompt pronto' if task.get('thumbnail_prompt') else ''
3342
3440
  st.caption(f"Thumbnail: {status}{prompt_note}")
3343
3441
  with cols[1]: st.write(task.get("format", "wide"))
3344
- with cols[2]: st.write(task.get("stage", "—"))
3345
- with cols[3]: st.write(task.get("state", "—"))
3442
+ with cols[2]:
3443
+ st.write(_pipeline_stage_label(task))
3444
+ if str(task.get("state") or "") in {"to_do", "doing", "blocked"}:
3445
+ progress = _pipeline_progress_value(task)
3446
+ st.progress(progress, text=f"{progress}%")
3447
+ with cols[3]:
3448
+ st.write(task.get("state", "—"))
3449
+ if task.get("error"):
3450
+ st.caption(str(task.get("error"))[:240])
3451
+
3346
3452
  with cols[4]:
3347
3453
  state = str(task.get("state") or "")
3348
3454
  start_col, stop_col = st.columns(2)
@@ -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)))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.36",
3
+ "version": "0.3.37",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",