@danhachuel/thunderbolt 0.2.32 → 0.2.33

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,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.2.31
5
+ > **Versão deste manual:** 0.2.33
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.2.31 install
103
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.33 install
104
104
  ```
105
105
 
106
106
  Linux/macOS:
107
107
 
108
108
  ```bash
109
- npx --yes @danhachuel/thunderbolt@0.2.31 install
109
+ npx --yes @danhachuel/thunderbolt@0.2.33 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.
@@ -220,6 +220,12 @@ Depois de instalar e diagnosticar:
220
220
  npx --yes @danhachuel/thunderbolt
221
221
  ```
222
222
 
223
+ O comando normal inicia a UI e o worker local de Automação. O worker verifica o relógio local do computador e cria os lotes dos canais com **Automação ON** quando o horário `HH:MM` coincide. Para executar apenas o worker, sem abrir a UI:
224
+
225
+ ```bash
226
+ npx --yes @danhachuel/thunderbolt worker
227
+ ```
228
+
223
229
  Abra no navegador:
224
230
 
225
231
  ```text
@@ -372,7 +378,7 @@ Após iniciar a aplicação, valide o seguinte percurso:
372
378
  9. **Pipeline > Limpador de Metadados:** suba um vídeo de terceiro, preencha título, preview, links, timestamps e tags, aplique a limpeza e descarregue a cópia limpa e o manifesto JSON. O original fica preservado e vídeos das páginas de criação não são aceites nesta área.
373
379
  10. **Configurações > Configurações Técnicas:** confirme que os caminhos e credenciais estão locais e não aparecem no Git.
374
380
  11. **Criação de Vídeos:** confirme que `Pexels/Pixabay` substitui o label antigo, que `Estilo IA` aparece apenas em `full_ia` e que `Apenas Música` exige áudio e guarda `background_mode=none`.
375
- 12. **Automação:** no cartão de cada canal, escolha o **Blueprint padrão** e a **Voz padrão**, clique em `Guardar` e confirme que o resumo do cartão se actualiza. Configure também **Automação ON** e um horário `HH:MM`; confirme a lista de vídeos cadastrados. Os mesmos defaults aparecem em **Canais** e são usados em novas tarefas. A UI não inicia jobs em segundo plano nesta entrega.
381
+ 12. **Automação:** no cartão de cada canal, escolha o **Blueprint padrão** e a **Voz padrão**, clique em `Guardar` e confirme que o resumo do cartão se actualiza. Configure também **Automação ON** e um horário `HH:MM`; confirme a lista de vídeos cadastrados. Os mesmos defaults aparecem em **Canais** e são usados em novas tarefas. Inicie o Thunderbolt pelo launcher, confirme o aviso verde **Worker activo** e verifique que o relógio apresentado corresponde ao computador. O worker cria no máximo um lote por canal por dia quando o horário local coincide.
376
382
  13. **Configurações > Configurações Técnicas > Teste de vozes:** teste Edge/Azure ou provider configurado e confirme reprodução/download sem criação de tarefa.
377
383
  14. **Upload directo:** configure manualmente cookies, sessionInfo e INNERTUBE_API_KEY, atribua o `DELEGATED_SESSION_ID` no canal e teste apenas com um vídeo de validação. O método envia chunks de 256 KiB e não extrai cookies do navegador.
378
384
  15. **Configurações > MCP > Client MCP:** confirme que Short Video Maker, AutoVio, OpenMontage e OpenCut aparecem com as portas padrão editáveis. O estado **Activo** é uma preferência local; a detecção deve indicar **Não detectado** quando os serviços externos não estiverem instalados ou iniciados.
@@ -387,13 +393,13 @@ Ao abrir a página, o Thunderbolt prepara automaticamente os dados públicos atr
387
393
 
388
394
  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. 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. A análise inicial é automática; o botão **Analisar Nichos** recalcula os resultados depois de alterar os 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.
389
395
 
390
- 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.2.31 install`; o instalador detecta e reutiliza o que já estiver válido.
396
+ 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.2.33 install`; o instalador detecta e reutiliza o que já estiver válido.
391
397
 
392
398
  ## Pipeline: Criação de Vídeos, Criação de Músicas e Automação
393
399
 
394
400
  O modo `Pexels/Pixabay` representa materiais de stock. Ao seleccionar `full_ia`, o selector `Estilo IA` apresenta os 12 estilos disponíveis. Ao seleccionar `Apenas Música`, a UI obriga a escolher uma música existente, carregar um ficheiro ou solicitar uma música a um endpoint Suno configurado. O vídeo recebe `background_mode=none`, sem fundo Pexels/Pixabay ou IA.
395
401
 
396
- A aba **Automação** lista os canais e vídeos, permite escolher e guardar **Blueprint padrão** e **Voz padrão** por canal, guarda **Automação ON** e valida horários diários no formato `HH:MM`. Os valores ficam sincronizados com o editor existente no cartão da aba **Canais** e são copiados para novas tarefas. É apenas uma camada de UI nesta versão, sem execução de worker.
402
+ A aba **Automação** lista os canais e vídeos, permite escolher e guardar **Blueprint padrão** e **Voz padrão** por canal, guarda **Automação ON** e valida horários diários no formato `HH:MM`. Os valores ficam sincronizados com o editor existente no cartão da aba **Canais** e são copiados para novas tarefas. O launcher inicia o worker local, que consulta o relógio do computador, cria lotes agendados na fila e evita duplicar o mesmo canal no mesmo dia.
397
403
 
398
404
  A área **Teste de vozes**, dentro de **Configurações > Configurações Técnicas**, é isolada da pipeline. O preview pode ser reproduzido e descarregado de `storage/voice_previews/`. Caminhos vazios, directórios, ficheiros vazios e previews sem permissões de leitura são ignorados com uma mensagem clara, sem traceback. Se uma instalação antiga mostrar que `edge-tts` está em falta, execute `npx.cmd --yes @danhachuel/thunderbolt install`; o detector reinstala apenas a dependência ausente.
399
405
 
package/README.md CHANGED
@@ -16,7 +16,7 @@ A primeira versão implementa a camada UI independente com:
16
16
  | Brandings | Subaba própria dentro de Blueprints, upload/listagem de Brandings e criação conjunta com Blueprint |
17
17
  | Canais | Subabas de importação pública sem API Key, Data API opcional e cadastro manual independente |
18
18
  | Criação de Vídeos / Criação de Músicas | Subabas Criar vídeo e Vídeos; lotes; 51 rótulos de idioma; Pexels/Pixabay, full IA com Estilo IA e Apenas Música com agente musical; a segunda página reutiliza o mesmo fluxo com título próprio |
19
- | Automação | Lista de vídeos e canais, selectores editáveis de Blueprint/voz padrão, Automação ON e horário diário HH:MM; UI configurável sem worker em segundo plano |
19
+ | Automação | Lista de vídeos e canais, selectores editáveis de Blueprint/voz padrão, Automação ON, horário diário HH:MM e worker local baseado no relógio do computador |
20
20
  | Niche Finder | Preparação e análise automáticas, filtros, K-Means, FP-Growth, regras de associação, clusters e gráfico Plotly nativo |
21
21
  | Upload | YouTube via `youtube-automation-agent` adaptado internamente, OAuth directo de redundância, Upload directo experimental, TikTok, Instagram e Facebook Pages no front end |
22
22
  | MCP | Catálogo local opcional de Short Video Maker, AutoVio, OpenMontage e OpenCut, com portas editáveis e activação |
@@ -26,7 +26,7 @@ A primeira versão implementa a camada UI independente com:
26
26
 
27
27
  Os adaptadores do MoneyPrinterTurbo e de publicação nas plataformas são ligados pelas configurações locais e pelos pontos de integração em `integrations/`. A UI não inventa dados quando um serviço externo ou credencial não está disponível.
28
28
 
29
- ## Navegação da UI 0.2.32
29
+ ## Navegação da UI 0.2.33
30
30
 
31
31
  A barra lateral mantém apenas os cinco níveis principais, nesta ordem: **Início**, **Pipeline**, **Automação**, **Niche Finder** e **Configurações**. **Pipeline** é expansível e contém **Criação de Vídeos**, **Criação de Músicas**, **Upload** e **Limpador de Metadados**. **Configurações** é expansível e contém **Canais**, **Blueprints**, **MCP** e **Configurações Técnicas**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
32
32
 
@@ -63,6 +63,12 @@ node scripts/cli.mjs --check
63
63
  node scripts/cli.mjs
64
64
  ```
65
65
 
66
+ O comando normal inicia a UI e o worker local de Automação. O worker verifica o relógio local do computador e coloca na fila os lotes de canais com **Automação ON** quando o horário `HH:MM` coincide. Para executar apenas o worker, sem abrir a UI:
67
+
68
+ ```bash
69
+ node scripts/cli.mjs worker
70
+ ```
71
+
66
72
  O pacote está publicado no npm como `@danhachuel/thunderbolt` e pode ser executado directamente via `npx`:
67
73
 
68
74
  ```bash
@@ -109,9 +115,9 @@ thunderbolt
109
115
  No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
110
116
 
111
117
  ```powershell
112
- npx.cmd --yes @danhachuel/thunderbolt@0.2.32 install
113
- npx.cmd --yes @danhachuel/thunderbolt@0.2.32 doctor
114
- npx.cmd --yes @danhachuel/thunderbolt@0.2.32
118
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.33 install
119
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.33 doctor
120
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.33
115
121
  ```
116
122
 
117
123
  Como alternativa, pode permitir scripts para o seu utilizador:
package/app/main.py CHANGED
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import re
5
- from datetime import date
5
+ from datetime import date, datetime
6
6
  import sys
7
7
  from pathlib import Path
8
8
 
@@ -18,6 +18,7 @@ except (OSError, json.JSONDecodeError):
18
18
  APP_VERSION = ""
19
19
 
20
20
  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
21
+ from hermes_ui.automation_worker import load_worker_status
21
22
  from hermes_ui.storage import BLUEPRINTS, ensure_storage, list_blueprint_files, load_blueprint_file, now, read_json, write_json
22
23
  from app.modules.niche_finder.core import NicheAnalysisError, run_niche_analysis
23
24
  from app.modules.niche_finder.data_loader import DatasetError, download_kaggle_dataset, load_dataframe
@@ -788,8 +789,18 @@ def render_videos():
788
789
 
789
790
  def render_automation():
790
791
  st.title("Automação")
791
- st.caption("Agendamento diário da geração por canal. Esta versão configura e guarda a UI; não inicia gerações em segundo plano.")
792
- st.info("A geração só será executada quando existir um worker/agendador ligado à pipeline. Activar Automação ON não inicia um processo automaticamente nesta etapa.")
792
+ st.caption("Agendamento diário da geração por canal. O worker verifica o relógio local do computador e coloca os lotes agendados na fila.")
793
+ worker_status = load_worker_status()
794
+ local_now = datetime.now().astimezone()
795
+ if worker_status.get("alive"):
796
+ st.success(f"Worker activo · relógio local: {local_now.strftime('%d/%m/%Y %H:%M:%S %Z')}")
797
+ else:
798
+ st.warning("Worker de automação não está activo. Inicie o Thunderbolt pelo launcher (`npx.cmd --yes @danhachuel/thunderbolt`) para activar as verificações horárias.")
799
+ last_tick = worker_status.get("last_tick_local")
800
+ if last_tick:
801
+ st.caption(f"Última verificação do worker: {last_tick}")
802
+ if worker_status.get("last_error"):
803
+ st.error(f"Último erro do worker: {worker_status['last_error']}")
793
804
  channels = read_json("channels.json", [])
794
805
  if not channels:
795
806
  st.info("Nenhum canal cadastrado para configurar.")
@@ -0,0 +1,273 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import time
6
+ from datetime import datetime, tzinfo
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from . import storage
11
+ from .domain import create_batch, create_tasks_for_batch
12
+
13
+ WORKER_STATE_FILE = "automation_worker.json"
14
+ LOCK_FILENAME = "automation_worker.lock"
15
+ DEFAULT_INTERVAL_SECONDS = 10
16
+ DEFAULT_STATE: dict[str, Any] = {
17
+ "worker_started_at": None,
18
+ "worker_pid": None,
19
+ "last_heartbeat_local": None,
20
+ "last_tick_local": None,
21
+ "last_error": "",
22
+ "last_runs": {},
23
+ }
24
+
25
+
26
+ def _local_now() -> datetime:
27
+ """Return the local wall-clock time of the computer running Thunderbolt."""
28
+ return datetime.now().astimezone()
29
+
30
+
31
+ def _local_iso(value: datetime) -> str:
32
+ return value.astimezone().isoformat(timespec="seconds")
33
+
34
+
35
+ def _state() -> dict[str, Any]:
36
+ saved = storage.read_json(WORKER_STATE_FILE, DEFAULT_STATE)
37
+ if not isinstance(saved, dict):
38
+ saved = {}
39
+ merged = {**DEFAULT_STATE, **saved}
40
+ if not isinstance(merged.get("last_runs"), dict):
41
+ merged["last_runs"] = {}
42
+ return merged
43
+
44
+
45
+ def load_worker_status() -> dict[str, Any]:
46
+ """Read status displayed by the Automação page without starting a worker."""
47
+ status = _state()
48
+ heartbeat = str(status.get("last_heartbeat_local") or "")
49
+ status["alive"] = False
50
+ if heartbeat:
51
+ try:
52
+ heartbeat_at = datetime.fromisoformat(heartbeat)
53
+ status["alive"] = (datetime.now().astimezone() - heartbeat_at.astimezone()).total_seconds() <= 30
54
+ except ValueError:
55
+ status["alive"] = False
56
+ return status
57
+
58
+
59
+ def _write_status(status: dict[str, Any]) -> None:
60
+ storage.write_json(WORKER_STATE_FILE, {**DEFAULT_STATE, **status})
61
+
62
+
63
+ def _pid_alive(pid: int) -> bool:
64
+ if pid <= 0:
65
+ return False
66
+ try:
67
+ os.kill(pid, 0)
68
+ except ProcessLookupError:
69
+ return False
70
+ except PermissionError:
71
+ return True
72
+ except OSError:
73
+ return False
74
+ return True
75
+
76
+
77
+ def _acquire_lock() -> Path | None:
78
+ lock_path = storage.STORAGE / LOCK_FILENAME
79
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
80
+ try:
81
+ descriptor = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
82
+ except FileExistsError:
83
+ try:
84
+ old_pid = int(lock_path.read_text(encoding="utf-8").strip())
85
+ except (OSError, ValueError):
86
+ old_pid = 0
87
+ if _pid_alive(old_pid):
88
+ return None
89
+ try:
90
+ lock_path.unlink()
91
+ except OSError:
92
+ return None
93
+ try:
94
+ descriptor = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
95
+ except FileExistsError:
96
+ return None
97
+ try:
98
+ os.write(descriptor, str(os.getpid()).encode("ascii"))
99
+ finally:
100
+ os.close(descriptor)
101
+ return lock_path
102
+
103
+
104
+ def _release_lock(lock_path: Path | None) -> None:
105
+ if lock_path is not None:
106
+ try:
107
+ lock_path.unlink()
108
+ except OSError:
109
+ pass
110
+
111
+
112
+ def _valid_schedule(value: Any) -> bool:
113
+ text = str(value or "").strip()
114
+ if len(text) != 5 or text[2] != ":":
115
+ return False
116
+ try:
117
+ hour, minute = (int(part) for part in text.split(":"))
118
+ except ValueError:
119
+ return False
120
+ return 0 <= hour <= 23 and 0 <= minute <= 59
121
+
122
+
123
+ def _daily_quantity(channel: dict[str, Any]) -> int:
124
+ try:
125
+ return max(1, min(100, int(channel.get("daily_limit", 1))))
126
+ except (TypeError, ValueError):
127
+ return 1
128
+
129
+
130
+ def _automation_topic(channel: dict[str, Any]) -> str:
131
+ topic = str(channel.get("automation_topic") or "").strip()
132
+ return topic or f"Geração automática — {channel.get('name') or 'Canal'}"
133
+
134
+
135
+ def _batch_for_day(channel_id: str, day: str) -> dict[str, Any] | None:
136
+ batches = storage.read_json("batches.json", [])
137
+ if not isinstance(batches, list):
138
+ return None
139
+ for batch in reversed(batches):
140
+ if not isinstance(batch, dict) or channel_id not in (batch.get("channel_ids") or []):
141
+ continue
142
+ options = batch.get("options") or {}
143
+ if options.get("automation_worker") is True and options.get("automation_date") == day:
144
+ return batch
145
+ return None
146
+
147
+
148
+ def _tasks_for_batch(batch_id: str) -> list[dict[str, Any]]:
149
+ tasks = storage.read_json("tasks.json", [])
150
+ return [task for task in tasks if isinstance(task, dict) and task.get("batch_id") == batch_id]
151
+
152
+
153
+ def _create_channel_batch(channel: dict[str, Any], when: datetime) -> dict[str, Any]:
154
+ channel_id = str(channel["id"])
155
+ date_key = when.date().isoformat()
156
+ style_wide = str(channel.get("style_wide") or "pexels")
157
+ music_mode = style_wide == "music"
158
+ options = {
159
+ "language": channel.get("language") or "Português",
160
+ "format": "wide",
161
+ "style_wide": style_wide,
162
+ "style_ia": channel.get("style_ia") or "",
163
+ "music_mode": music_mode,
164
+ "background_mode": "none" if music_mode else ("ai" if style_wide == "full_ia" else "stock"),
165
+ "music_path": channel.get("music_path") or "",
166
+ "music_source": channel.get("music_source") or "",
167
+ "automation_worker": True,
168
+ "automation_date": date_key,
169
+ "automation_scheduled_at": _local_iso(when),
170
+ }
171
+ batch = create_batch(
172
+ "single",
173
+ [channel_id],
174
+ _automation_topic(channel),
175
+ _daily_quantity(channel),
176
+ options,
177
+ )
178
+ tasks = create_tasks_for_batch(batch)
179
+ return {"batch": batch, "tasks": tasks, "channel_id": channel_id}
180
+
181
+
182
+ def run_once(when: datetime | None = None) -> dict[str, Any]:
183
+ """Run one deterministic local-time tick; safe to call from tests or a worker loop."""
184
+ current = when.astimezone() if when is not None and when.tzinfo else (when or _local_now())
185
+ current_minute = current.strftime("%H:%M")
186
+ day = current.date().isoformat()
187
+ status = _state()
188
+ status["last_tick_local"] = _local_iso(current)
189
+ status["last_heartbeat_local"] = _local_iso(current)
190
+ status["last_error"] = ""
191
+ created: list[dict[str, Any]] = []
192
+ skipped: list[str] = []
193
+ channels = storage.read_json("channels.json", [])
194
+ if not isinstance(channels, list):
195
+ channels = []
196
+ try:
197
+ for channel in channels:
198
+ if not isinstance(channel, dict):
199
+ continue
200
+ channel_id = str(channel.get("id") or "")
201
+ if not channel_id or not bool(channel.get("active", True)) or not bool(channel.get("automation_on", False)):
202
+ continue
203
+ if not _valid_schedule(channel.get("automation_time")) or str(channel.get("automation_time")).strip() != current_minute:
204
+ continue
205
+ existing = _batch_for_day(channel_id, day)
206
+ if existing:
207
+ existing_tasks = _tasks_for_batch(str(existing.get("id") or ""))
208
+ if not existing_tasks:
209
+ existing_tasks = create_tasks_for_batch(existing)
210
+ skipped.append(channel_id)
211
+ continue
212
+ created.append(_create_channel_batch(channel, current))
213
+ for item in created:
214
+ batch = item["batch"]
215
+ status["last_runs"][item["channel_id"]] = {
216
+ "date": day,
217
+ "time": current_minute,
218
+ "batch_id": batch["id"],
219
+ "task_ids": [task["id"] for task in item["tasks"]],
220
+ }
221
+ _write_status(status)
222
+ return {
223
+ "ok": True,
224
+ "local_time": _local_iso(current),
225
+ "scheduled_time": current_minute,
226
+ "created": created,
227
+ "skipped": skipped,
228
+ }
229
+ except Exception as exc:
230
+ status["last_error"] = str(exc)
231
+ _write_status(status)
232
+ return {"ok": False, "local_time": _local_iso(current), "error": str(exc), "created": created}
233
+
234
+
235
+ def run_worker(interval_seconds: int = DEFAULT_INTERVAL_SECONDS) -> None:
236
+ storage.ensure_storage()
237
+ lock_path = _acquire_lock()
238
+ if lock_path is None:
239
+ raise RuntimeError("Já existe um worker de automação activo para este storage do Thunderbolt.")
240
+ started = _local_now()
241
+ status = _state()
242
+ status.update({"worker_started_at": _local_iso(started), "worker_pid": os.getpid(), "last_error": ""})
243
+ _write_status(status)
244
+ try:
245
+ while True:
246
+ result = run_once()
247
+ if not result.get("ok"):
248
+ print(f"Thunderbolt worker: erro: {result.get('error')}", flush=True)
249
+ elif result.get("created"):
250
+ print(f"Thunderbolt worker: {len(result['created'])} canal(is) agendado(s) às {result['scheduled_time']}.", flush=True)
251
+ time.sleep(max(2, int(interval_seconds)))
252
+ finally:
253
+ current = _state()
254
+ current["worker_pid"] = None
255
+ current["last_heartbeat_local"] = None
256
+ _write_status(current)
257
+ _release_lock(lock_path)
258
+
259
+
260
+ def main() -> None:
261
+ parser = argparse.ArgumentParser(description="Worker local de automação diária do Thunderbolt")
262
+ parser.add_argument("--once", action="store_true", help="Executa apenas um tick usando o relógio local")
263
+ parser.add_argument("--interval", type=int, default=DEFAULT_INTERVAL_SECONDS, help="Intervalo de verificação em segundos")
264
+ args = parser.parse_args()
265
+ if args.once:
266
+ storage.ensure_storage()
267
+ print(run_once(), flush=True)
268
+ return
269
+ run_worker(args.interval)
270
+
271
+
272
+ if __name__ == "__main__":
273
+ main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.32",
3
+ "version": "0.2.33",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "main": "scripts/cli.mjs",
6
6
  "type": "module",
package/scripts/cli.mjs CHANGED
@@ -133,10 +133,39 @@ if (!existsSync(main)) {
133
133
  process.exit(1);
134
134
  }
135
135
  ensureRuntimeStorage();
136
+ const storageDir = process.env.THUNDERBOLT_STORAGE_DIR || join(thunderboltHome, "storage");
137
+ const runtimeEnv = {
138
+ ...process.env,
139
+ THUNDERBOLT_STORAGE_DIR: storageDir,
140
+ STREAMLIT_BROWSER_GATHER_USAGE_STATS: "false",
141
+ STREAMLIT_SERVER_HEADLESS: "true",
142
+ };
143
+
144
+ if (args[0] === "worker" || args.includes("--worker")) {
145
+ run(python, ["-m", "hermes_ui.automation_worker", ...args.filter((arg) => arg !== "worker" && arg !== "--worker")], "worker de automação");
146
+ }
147
+
136
148
  const port = process.env.THUNDERBOLT_PORT || process.env.HERMES_PORT || "3030";
149
+ const worker = spawn(python, ["-m", "hermes_ui.automation_worker"], {
150
+ cwd: root,
151
+ stdio: "inherit",
152
+ env: runtimeEnv,
153
+ windowsHide: false,
154
+ });
137
155
  const child = spawn(python, ["-m", "streamlit", "run", main, "--server.port", port, "--server.address", "localhost"], {
138
156
  cwd: root,
139
157
  stdio: "inherit",
140
- env: { ...process.env, THUNDERBOLT_STORAGE_DIR: process.env.THUNDERBOLT_STORAGE_DIR || join(thunderboltHome, "storage") },
158
+ env: runtimeEnv,
159
+ windowsHide: false,
160
+ });
161
+
162
+ const stopWorker = () => {
163
+ if (!worker.killed) worker.kill();
164
+ };
165
+ process.on("SIGINT", stopWorker);
166
+ process.on("SIGTERM", stopWorker);
167
+ child.on("exit", (code, signal) => {
168
+ stopWorker();
169
+ process.exit(code ?? (signal ? 1 : 0));
141
170
  });
142
- child.on("exit", (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
171
+ worker.on("error", (error) => console.error(`Thunderbolt worker: ${error.message}`));