@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.
- package/.streamlit/config.toml +0 -1
- package/MANUAL-INSTALACAO.md +15 -5
- package/README.md +3 -2
- package/app/main.py +109 -3
- package/hermes_ui/pipeline_worker.py +291 -21
- package/package.json +1 -1
package/.streamlit/config.toml
CHANGED
package/MANUAL-INSTALACAO.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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`
|
|
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.
|
|
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]:
|
|
3345
|
-
|
|
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 =
|
|
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/package.json
CHANGED