shade-sdk 0.1.0__py3-none-any.whl
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.
- shade_sdk/__init__.py +5 -0
- shade_sdk/runtime/__init__.py +33 -0
- shade_sdk/runtime/config.py +76 -0
- shade_sdk/runtime/ledger.py +505 -0
- shade_sdk/runtime/output.py +136 -0
- shade_sdk/runtime/protocolo.py +47 -0
- shade_sdk/runtime/recovery.py +136 -0
- shade_sdk/runtime/report.py +304 -0
- shade_sdk-0.1.0.dist-info/METADATA +27 -0
- shade_sdk-0.1.0.dist-info/RECORD +11 -0
- shade_sdk-0.1.0.dist-info/WHEEL +4 -0
shade_sdk/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"""shade-sdk: o runtime dos robôs da ShadeOne (ADR 019)."""
|
|
2
|
+
|
|
3
|
+
#: Semver próprio do SDK (ADR 019, D10), desacoplado da versão da plataforma. Subir aqui pede
|
|
4
|
+
#: regenerar a wheel e o lock da imagem: `uv run python scripts/gerar_lock_do_sandbox.py`.
|
|
5
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""O que roda dentro do robô: ledger, recuperação, relatório, configuração e saída.
|
|
2
|
+
|
|
3
|
+
Os nomes que o robô gerado usa ficam reexportados aqui (``from shade_sdk.runtime import
|
|
4
|
+
Settings``), mas o import de cada módulo é preguiçoso: ``config`` carrega o ``.env`` do
|
|
5
|
+
diretório corrente e ``ledger`` liga o canal do registro quando ``SHADE_REGISTRO_DIR`` existe,
|
|
6
|
+
e quem só precisa do número do protocolo (o executor da plataforma) não deve disparar nada
|
|
7
|
+
disso.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
#: nome reexportado → o módulo de ``shade_sdk.runtime`` que o define.
|
|
16
|
+
_REEXPORTADOS = {
|
|
17
|
+
"_CanalDoRegistro": "ledger",
|
|
18
|
+
"_RegistroAntesDeEfetivar": "ledger",
|
|
19
|
+
"RecoveryStopError": "recovery",
|
|
20
|
+
"append_historico": "report",
|
|
21
|
+
"generate_report": "report",
|
|
22
|
+
"Settings": "config",
|
|
23
|
+
"RUNTIME_PROTOCOL": "protocolo",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
__all__ = sorted(_REEXPORTADOS)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def __getattr__(nome: str) -> Any:
|
|
30
|
+
modulo = _REEXPORTADOS.get(nome)
|
|
31
|
+
if modulo is None:
|
|
32
|
+
raise AttributeError(f"module {__name__!r} has no attribute {nome!r}")
|
|
33
|
+
return getattr(importlib.import_module(f"{__name__}.{modulo}"), nome)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Configurações do robô: ``Settings`` e a carga do ``.env`` para o ambiente do processo.
|
|
2
|
+
|
|
3
|
+
Era ``runtime_templates/config_runtime.py`` no backend, copiado como ``src/config.py`` do
|
|
4
|
+
projeto gerado; desde a HU-29.1 o ``src/config.py`` gerado reexporta daqui. Importar este
|
|
5
|
+
módulo carrega o ``.env`` do diretório corrente (``_load_env_file``), como antes: é o que leva
|
|
6
|
+
credenciais, ``TELEMETRY_*`` e ``RECOVERY_*`` para o ``os.getenv`` do robô.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from pydantic import Field
|
|
13
|
+
from pydantic_settings import BaseSettings
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _load_env_file(path: str = ".env") -> None:
|
|
17
|
+
"""Carrega o .env para o ambiente do processo.
|
|
18
|
+
|
|
19
|
+
O Settings abaixo já lê o .env — mas SÓ para os campos declarados nele.
|
|
20
|
+
Todo o resto que o bot lê com os.getenv (credenciais, TELEMETRY_*, SMTP_* /
|
|
21
|
+
NOTIFY_EMAIL_TO, RECOVERY_*, SMOKE_TARGET_URL) chegava VAZIO quando
|
|
22
|
+
configurado no .env, que é exatamente o que o .env.example e o README mandam
|
|
23
|
+
preencher: nada neste projeto levava o arquivo para o ambiente.
|
|
24
|
+
|
|
25
|
+
Precedência: variável já presente no ambiente VENCE o arquivo — quem exporta
|
|
26
|
+
no shell ou no agendador manda mais que o .env commitado ao lado do bot.
|
|
27
|
+
"""
|
|
28
|
+
env_path = Path(path)
|
|
29
|
+
if not env_path.exists():
|
|
30
|
+
return
|
|
31
|
+
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
|
|
32
|
+
line = raw_line.strip()
|
|
33
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
34
|
+
continue
|
|
35
|
+
key, _, value = line.partition("=")
|
|
36
|
+
key = key.strip()
|
|
37
|
+
if key and key not in os.environ:
|
|
38
|
+
os.environ[key] = value.strip().strip('"').strip("'")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Roda no import de src.config — antes de qualquer Settings() ou os.getenv do bot.
|
|
42
|
+
_load_env_file()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Settings(BaseSettings):
|
|
46
|
+
"""Configurações carregadas de variáveis de ambiente / .env"""
|
|
47
|
+
|
|
48
|
+
# Execução
|
|
49
|
+
environment: str = Field(default="development", alias="ENVIRONMENT")
|
|
50
|
+
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
|
|
51
|
+
default_timeout_ms: int = Field(default=30_000, alias="DEFAULT_TIMEOUT_MS")
|
|
52
|
+
dry_run: bool = Field(default=False, alias="DRY_RUN")
|
|
53
|
+
|
|
54
|
+
# Browser
|
|
55
|
+
headless: bool = Field(default=False, alias="HEADLESS")
|
|
56
|
+
base_url: str = Field(default="", alias="BASE_URL")
|
|
57
|
+
|
|
58
|
+
# Credenciais (preencher no .env — nunca hardcoded)
|
|
59
|
+
# username: str = Field(default="", alias="SHADE_USERNAME")
|
|
60
|
+
# password: str = Field(default="", alias="SHADE_PASSWORD")
|
|
61
|
+
|
|
62
|
+
class Config:
|
|
63
|
+
env_file = ".env"
|
|
64
|
+
env_file_encoding = "utf-8"
|
|
65
|
+
case_sensitive = False
|
|
66
|
+
# O .env carrega MAIS do que estes campos — credenciais lidas via
|
|
67
|
+
# os.getenv (APP_PASSWORD...), TELEMETRY_*, DB_PATH, NOTIFY_EMAIL_TO,
|
|
68
|
+
# tokens de API. O default do pydantic-settings é "forbid", e um .env
|
|
69
|
+
# com qualquer uma dessas chaves derrubava o bot em Settings(), na
|
|
70
|
+
# primeira linha de run_automation. Extra ignorado: quem lê essas
|
|
71
|
+
# variáveis é o código que as declara.
|
|
72
|
+
extra = "ignore"
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def is_production(self) -> bool:
|
|
76
|
+
return self.environment.lower() == "production"
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
"""Idempotência do bot gerado — identidade do item por CHAVE DE NEGÓCIO.
|
|
2
|
+
|
|
3
|
+
**O problema.** A retomada de lote (``BATCH_RESUME``) guardava o **índice** da
|
|
4
|
+
linha em ``data/checkpoint.json``. Índice é âncora **posicional**: basta a fila
|
|
5
|
+
ganhar uma linha no topo — comportamento normal de uma fila de pedidos — para o
|
|
6
|
+
índice N passar a apontar para outra transação. O bot então **pula a nova e
|
|
7
|
+
recadastra a já feita**. É a mesma armadilha que a validação ao vivo corrigiu nos
|
|
8
|
+
seletores (linha de grade ancorada por CONTEÚDO, não por posição — #700).
|
|
9
|
+
|
|
10
|
+
**A correção.** A identidade do item passa a ser derivada do que ele É:
|
|
11
|
+
|
|
12
|
+
1. as **colunas-chave** que a pessoa escolheu na Revisão (nº do pedido,
|
|
13
|
+
documento…), quando escolhidas; ou
|
|
14
|
+
2. o **conteúdo inteiro** do item (todos os campos), quando não há escolha.
|
|
15
|
+
|
|
16
|
+
Ambas sobrevivem a reordenação e a inserção. O índice **não** é usado em nenhum
|
|
17
|
+
dos dois caminhos — nem como fallback: cair na posição no meio de um lote
|
|
18
|
+
reordenado é justamente o defeito.
|
|
19
|
+
|
|
20
|
+
**Ordinal entre itens idênticos.** Duas linhas rigorosamente iguais são dois
|
|
21
|
+
pedidos, não um. A chave carrega o ordinal do item entre os idênticos do mesmo
|
|
22
|
+
lote (``identidade#2``), o que é estável sob reordenação (um multiconjunto não
|
|
23
|
+
tem ordem) e evita engolir a segunda ocorrência.
|
|
24
|
+
|
|
25
|
+
**PII.** A chave pode ser um documento (CPF/CNPJ). No ledger vai só o
|
|
26
|
+
``sha256`` — ``checkpoint.json`` é um arquivo de diagnóstico, costuma ser aberto
|
|
27
|
+
e enviado em suporte, e não deve carregar o dado. Não é anonimização forte (quem
|
|
28
|
+
tem a planilha recomputa o hash); é a mesma disciplina de não deixar o dado
|
|
29
|
+
viajar para onde ele não precisa estar. O rastro **legível** fica no arquivo de
|
|
30
|
+
controle ao lado da entrada, que já vive na mesma zona de confiança da planilha.
|
|
31
|
+
|
|
32
|
+
**Versionamento.** O ledger v2 é um objeto ``{version, key_fields, done}``. O
|
|
33
|
+
formato v1 (lista crua de índices) é **migrado**, não descartado: o índice N vira
|
|
34
|
+
a chave da linha N do lote atual — exatamente o que a v1 teria pulado.
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
Era ``runtime_templates/idempotency_runtime.py`` no backend, colado por marcador dentro do
|
|
40
|
+
``main.py`` gerado; desde a HU-29.1 o robô o importa daqui. O ``main.py`` gerado configura os
|
|
41
|
+
campos-chave com ``_configurar_ledger`` (a escolha da Revisão) e importa os nomes que usa.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import json
|
|
47
|
+
|
|
48
|
+
from loguru import logger
|
|
49
|
+
|
|
50
|
+
from shade_sdk.runtime.protocolo import (
|
|
51
|
+
CAMPOS,
|
|
52
|
+
DIGITOS_DA_SEQUENCIA,
|
|
53
|
+
OP_DESFAZER,
|
|
54
|
+
OP_FEITO,
|
|
55
|
+
OP_INCERTO,
|
|
56
|
+
PEDIDOS,
|
|
57
|
+
POLLING_DA_RESPOSTA_SECONDS,
|
|
58
|
+
RESPOSTAS,
|
|
59
|
+
VARIAVEL_DA_PASTA,
|
|
60
|
+
VIVO,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
_CHECKPOINT_PATH = "data/checkpoint.json"
|
|
64
|
+
_CHECKPOINT_VERSION = 3
|
|
65
|
+
# Campos que identificam um item (escolha da pessoa na Revisão). Vazio → a
|
|
66
|
+
# identidade é o conteúdo inteiro do item. Quem muda é ``_configurar_ledger``.
|
|
67
|
+
_KEY_FIELDS: tuple = ()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _configurar_ledger(*, key_fields: tuple = ()) -> None:
|
|
71
|
+
"""Os campos-chave do item, antes do primeiro uso do ledger.
|
|
72
|
+
|
|
73
|
+
O ``main.py`` gerado chama no import, com a coluna-chave escolhida na Revisão. Trocar a
|
|
74
|
+
chave depois de gravar o ledger o invalida inteiro (``_load_checkpoint`` para).
|
|
75
|
+
"""
|
|
76
|
+
global _KEY_FIELDS
|
|
77
|
+
_KEY_FIELDS = tuple(key_fields)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _item_identity(row: dict) -> str:
|
|
81
|
+
"""Identidade de NEGÓCIO do item — nunca a posição na fila.
|
|
82
|
+
|
|
83
|
+
Com colunas-chave escolhidas, a identidade são os valores delas. Sem escolha,
|
|
84
|
+
é o conteúdo inteiro do item: também sobrevive a reordenação e a inserção de
|
|
85
|
+
linhas, ao contrário do índice. Campos internos (``_item_index``,
|
|
86
|
+
``_status``…) ficam de fora — ``_item_index`` É a posição, e deixá-lo entrar
|
|
87
|
+
reintroduziria o defeito pela porta dos fundos.
|
|
88
|
+
|
|
89
|
+
String vazia = não dá para identificar este item (o chamador avisa e NÃO pula).
|
|
90
|
+
"""
|
|
91
|
+
if _KEY_FIELDS:
|
|
92
|
+
parts = [str(row.get(f, "") or "").strip() for f in _KEY_FIELDS]
|
|
93
|
+
if any(parts):
|
|
94
|
+
return "\x1f".join(parts)
|
|
95
|
+
return "\x1f".join(
|
|
96
|
+
f"{k}={row[k]}" for k in sorted(row) if not str(k).startswith("_") and str(row[k]).strip()
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _item_digest(identity: str, occurrences: dict) -> str:
|
|
101
|
+
"""Chave do item no ledger: identidade + ordinal entre itens IDÊNTICOS do lote.
|
|
102
|
+
|
|
103
|
+
Duas linhas rigorosamente iguais são dois pedidos, não um. O ordinal é estável
|
|
104
|
+
sob reordenação (itens idênticos não têm ordem entre si).
|
|
105
|
+
|
|
106
|
+
PII: só o sha256 vai para o ledger — ``checkpoint.json`` costuma ser aberto e
|
|
107
|
+
enviado num diagnóstico, e a chave pode ser um documento.
|
|
108
|
+
"""
|
|
109
|
+
if not identity:
|
|
110
|
+
return ""
|
|
111
|
+
import hashlib
|
|
112
|
+
|
|
113
|
+
occurrences[identity] = occurrences.get(identity, 0) + 1
|
|
114
|
+
return hashlib.sha256(f"{identity}#{occurrences[identity]}".encode()).hexdigest()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _ledger_ilegivel(path: str, motivo: str):
|
|
118
|
+
"""O ledger existe mas não dá para confiar nele. PARA, com saída acionável.
|
|
119
|
+
|
|
120
|
+
Devolver "nenhum item foi feito" seria fail-OPEN: o lote inteiro voltaria ao
|
|
121
|
+
sistema do cliente. E a hora em que isso aconteceria é exatamente a hora em que
|
|
122
|
+
a retomada mais importa — um ledger truncado vem de um processo morto no meio
|
|
123
|
+
(Ctrl+C, queda de energia, agendador matando o job). Transformar "fui
|
|
124
|
+
interrompido" em "recadastrei tudo" é o defeito que esta camada existe para
|
|
125
|
+
matar; a mesma escolha fail-closed da troca de chave vale aqui.
|
|
126
|
+
"""
|
|
127
|
+
return RuntimeError(
|
|
128
|
+
f"Nao da para confiar no controle de itens ja feitos em {path}: {motivo}. "
|
|
129
|
+
f"Rodar assim recadastraria itens que ja foram processados. Guarde o arquivo "
|
|
130
|
+
f"para diagnostico e: apague-o + rode com --reprocessar-tudo se aceita refazer "
|
|
131
|
+
f"o lote inteiro, ou restaure uma copia boa do arquivo."
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _load_checkpoint(path: str, rows: list):
|
|
136
|
+
"""Lê o ledger. Devolve ``(feitos, incertos)``. Ausente → vazios; ilegível → para.
|
|
137
|
+
|
|
138
|
+
``incertos`` são itens cuja EFETIVAÇÃO rodou sem que o resultado tenha sido
|
|
139
|
+
confirmado. Não são "por fazer" (refazer duplica) nem "feitos" (dar por feito
|
|
140
|
+
perde trabalho em silêncio) — ficam retidos até uma pessoa conferir.
|
|
141
|
+
"""
|
|
142
|
+
try:
|
|
143
|
+
with open(path, encoding="utf-8") as _cf:
|
|
144
|
+
data = json.load(_cf)
|
|
145
|
+
except FileNotFoundError:
|
|
146
|
+
return set(), set()
|
|
147
|
+
except Exception as _exc: # noqa: BLE001
|
|
148
|
+
raise _ledger_ilegivel(path, f"nao foi possivel ler o arquivo ({_exc})") from None
|
|
149
|
+
if isinstance(data, list):
|
|
150
|
+
# v1: lista de ÍNDICES. Migra para chave de negócio pela posição no lote
|
|
151
|
+
# atual — exatamente o que a v1 teria pulado. Índice sem linha some.
|
|
152
|
+
_occ: dict = {}
|
|
153
|
+
_by_index = {
|
|
154
|
+
_i: _item_digest(_item_identity(_r), _occ) for _i, _r in enumerate(rows or [], start=1)
|
|
155
|
+
}
|
|
156
|
+
done = {_by_index[_i] for _i in data if isinstance(_i, int) and _by_index.get(_i)}
|
|
157
|
+
logger.warning(
|
|
158
|
+
f"Checkpoint no formato antigo (por posicao) convertido para chave de negocio: "
|
|
159
|
+
f"{len(done)} de {len(data)} item(ns) preservado(s). A partir daqui a retomada "
|
|
160
|
+
f"sobrevive a reordenacao da fila."
|
|
161
|
+
)
|
|
162
|
+
return done, set()
|
|
163
|
+
# Daqui para baixo, toda forma inesperada é ledger ilegível — não "vazio".
|
|
164
|
+
# Cada `isinstance` abaixo já mordeu na auditoria: `done` como número derrubava
|
|
165
|
+
# o bot com TypeError cru, e `key_fields` como número idem.
|
|
166
|
+
if not isinstance(data, dict):
|
|
167
|
+
raise _ledger_ilegivel(path, "o conteudo nao e um registro de controle")
|
|
168
|
+
_versao = data.get("version")
|
|
169
|
+
if _versao == 2:
|
|
170
|
+
# v2 não tinha a lista de incertos. Aceita e sobe para v3 na próxima
|
|
171
|
+
# gravação: o que existe lá é "feito", que continua valendo.
|
|
172
|
+
data = dict(data, incerto=[])
|
|
173
|
+
elif _versao != _CHECKPOINT_VERSION:
|
|
174
|
+
raise _ledger_ilegivel(path, f"versao desconhecida ({_versao!r})")
|
|
175
|
+
_raw_fields = data.get("key_fields")
|
|
176
|
+
_raw_done = data.get("done")
|
|
177
|
+
_raw_incerto = data.get("incerto", [])
|
|
178
|
+
if (
|
|
179
|
+
not isinstance(_raw_fields, list)
|
|
180
|
+
or not isinstance(_raw_done, list)
|
|
181
|
+
or not isinstance(_raw_incerto, list)
|
|
182
|
+
):
|
|
183
|
+
raise _ledger_ilegivel(path, "os campos de controle estao com o formato errado")
|
|
184
|
+
_stored = [str(f) for f in _raw_fields]
|
|
185
|
+
if _stored != [str(f) for f in _KEY_FIELDS]:
|
|
186
|
+
# Trocar a chave invalida o ledger inteiro: os hashes gravados não têm como
|
|
187
|
+
# casar. Parar é a escolha segura — seguir em silêncio recadastraria tudo.
|
|
188
|
+
raise _ledger_ilegivel(
|
|
189
|
+
path,
|
|
190
|
+
f"ele foi gravado com outra chave de identificacao "
|
|
191
|
+
f"({_stored or 'conteudo da linha'} != {list(_KEY_FIELDS) or 'conteudo da linha'})",
|
|
192
|
+
)
|
|
193
|
+
return (
|
|
194
|
+
{str(k) for k in _raw_done if str(k)},
|
|
195
|
+
{str(k) for k in _raw_incerto if str(k)},
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _save_checkpoint(path: str, done: set, incerto: set = frozenset()) -> bool:
|
|
200
|
+
"""Persiste o ledger de forma idempotente (reescrita completa, ordenada).
|
|
201
|
+
|
|
202
|
+
ESCRITA ATÔMICA (arquivo temporário + ``os.replace``). A reescrita direta roda
|
|
203
|
+
a cada item concluído, então a janela em que o arquivo fica truncado é
|
|
204
|
+
percorrida dezenas de vezes num lote — e morrer dentro dela deixava um JSON
|
|
205
|
+
pela metade, justamente na interrupção que a retomada existe para cobrir.
|
|
206
|
+
``os.replace`` é atômico em POSIX e Windows: ou o ledger antigo, ou o novo.
|
|
207
|
+
|
|
208
|
+
DURÁVEL: o conteúdo vai ao disco (``fsync`` do temporário) antes da troca,
|
|
209
|
+
e a troca também (``_fsync_do_diretorio``). O registro feito ANTES de
|
|
210
|
+
efetivar (``_RegistroAntesDeEfetivar``) precisa sobreviver até a uma queda
|
|
211
|
+
de energia na máquina de quem roda o robô.
|
|
212
|
+
|
|
213
|
+
Devolve se gravou: quem registra antes de efetivar não efetiva sem o registro.
|
|
214
|
+
"""
|
|
215
|
+
try:
|
|
216
|
+
import os as _os
|
|
217
|
+
|
|
218
|
+
_os.makedirs(_os.path.dirname(path) or ".", exist_ok=True)
|
|
219
|
+
_tmp = path + ".tmp"
|
|
220
|
+
with open(_tmp, "w", encoding="utf-8") as _cf:
|
|
221
|
+
json.dump(
|
|
222
|
+
{
|
|
223
|
+
"version": _CHECKPOINT_VERSION,
|
|
224
|
+
"key_fields": [str(f) for f in _KEY_FIELDS],
|
|
225
|
+
"done": sorted(done),
|
|
226
|
+
"incerto": sorted(incerto),
|
|
227
|
+
},
|
|
228
|
+
_cf,
|
|
229
|
+
)
|
|
230
|
+
_cf.flush()
|
|
231
|
+
_os.fsync(_cf.fileno())
|
|
232
|
+
_os.replace(_tmp, path)
|
|
233
|
+
except Exception as _exc: # noqa: BLE001
|
|
234
|
+
# ERRO, não aviso: o ledger ficou para trás do que aconteceu. Se isto se
|
|
235
|
+
# repetir (disco cheio, permissao), a proxima execucao nao sabe o que ja
|
|
236
|
+
# passou por aqui.
|
|
237
|
+
logger.error(
|
|
238
|
+
f"Falha ao gravar o controle de itens ja feitos em {path}: {_exc}. "
|
|
239
|
+
f"Os itens a partir daqui PODEM ser refeitos ou ficar retidos para "
|
|
240
|
+
f"conferencia na proxima execucao — resolva antes de rodar de novo."
|
|
241
|
+
)
|
|
242
|
+
return False
|
|
243
|
+
_fsync_do_diretorio(path)
|
|
244
|
+
return True
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _fsync_do_diretorio(path: str) -> None:
|
|
248
|
+
"""``fsync`` do diretório do ledger, depois do ``os.replace``.
|
|
249
|
+
|
|
250
|
+
Sem ele, uma queda de energia logo depois da troca pode deixar o diretório
|
|
251
|
+
apontando para o ledger ANTIGO, sem o registro feito antes de efetivar, e a
|
|
252
|
+
retomada refaria o item. É melhor esforço: o Windows não abre diretório, e há
|
|
253
|
+
filesystem que recusa o ``fsync`` dele (alguns FUSE, o bind mount do Docker
|
|
254
|
+
Desktop). Aí fica o que o sistema já garante: a troca continua atômica, e
|
|
255
|
+
contra o processo morto (SIGKILL) o cache do kernel já basta.
|
|
256
|
+
|
|
257
|
+
Nunca levanta, nem no ``close``: ``_save_checkpoint`` não levanta, e quem o
|
|
258
|
+
chama no ``except`` do guard ou depois do "Criado" conta com isso.
|
|
259
|
+
"""
|
|
260
|
+
import os as _os
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
_fd = _os.open(_os.path.dirname(path) or ".", _os.O_RDONLY)
|
|
264
|
+
except OSError:
|
|
265
|
+
return
|
|
266
|
+
try:
|
|
267
|
+
_os.fsync(_fd)
|
|
268
|
+
except OSError:
|
|
269
|
+
pass
|
|
270
|
+
try:
|
|
271
|
+
_os.close(_fd)
|
|
272
|
+
except OSError:
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class _RegistroAntesDeEfetivar:
|
|
277
|
+
"""Registra o item como INCERTO no ledger ANTES de despachar a efetivação
|
|
278
|
+
(write-ahead), para a morte do processo depois do efeito não fazer a
|
|
279
|
+
retomada refazê-lo.
|
|
280
|
+
|
|
281
|
+
Até aqui o ``incerto`` só chegava ao ledger no ``except`` do guard do item,
|
|
282
|
+
com o processo vivo. Um SIGKILL entre o clique de salvar e o registro de
|
|
283
|
+
``feito`` (parada por perda de posse, OOM, estouro de tempo, queda do
|
|
284
|
+
runner) deixava o ledger sem o item, e a retomada o refazia: pedido
|
|
285
|
+
duplicado no sistema do cliente. Com o registro antes, a retomada o RETÉM
|
|
286
|
+
para conferência, o mesmo destino da efetivação que não confirmou.
|
|
287
|
+
|
|
288
|
+
- Chamar (o gancho ``antes_de_efetivar`` do método de página, depois das
|
|
289
|
+
esperas e imediatamente antes do despacho) registra e grava. Sem gravar,
|
|
290
|
+
LEVANTA: efetivar sem o registro reabriria a janela.
|
|
291
|
+
- ``desfazer()`` é do processo VIVO que viu a chamada de efetivação levantar
|
|
292
|
+
antes de retornar: o caso comum é o Playwright recusar a acionabilidade
|
|
293
|
+
antes de despachar, e o item volta para a fila. Tira só o que ESTA
|
|
294
|
+
instância marcou: num item com dois passos de efetivação, a falha do
|
|
295
|
+
segundo não apaga a marca do primeiro.
|
|
296
|
+
- Item sem chave (digest vazio) não tem o que registrar, como no resto do
|
|
297
|
+
ledger.
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
def __init__(self, path: str, done: set, incerto: set, digest: str) -> None:
|
|
301
|
+
self.path = path
|
|
302
|
+
self.done = done
|
|
303
|
+
self.incerto = incerto
|
|
304
|
+
self.digest = digest
|
|
305
|
+
self.marcou = False
|
|
306
|
+
|
|
307
|
+
def __call__(self) -> None:
|
|
308
|
+
if not self.digest or self.digest in self.incerto:
|
|
309
|
+
return
|
|
310
|
+
if _CANAL_DO_REGISTRO is not None:
|
|
311
|
+
# Na plataforma (HU-17.7), o servidor decide ANTES do registro local: gravar o
|
|
312
|
+
# incerto aqui primeiro deixaria no arquivo uma duvida que nunca efetivou a cada
|
|
313
|
+
# oscilacao da API.
|
|
314
|
+
_resposta = _CANAL_DO_REGISTRO.pedir(self.digest)
|
|
315
|
+
if _resposta == "feita":
|
|
316
|
+
self.done.add(self.digest)
|
|
317
|
+
_save_checkpoint(self.path, self.done, self.incerto)
|
|
318
|
+
raise _JaFeitoNoServidor(self.digest)
|
|
319
|
+
if _resposta == "retida":
|
|
320
|
+
self.incerto.add(self.digest)
|
|
321
|
+
_save_checkpoint(self.path, self.done, self.incerto)
|
|
322
|
+
raise _RetidoNoServidor(self.digest)
|
|
323
|
+
if _resposta not in ("registrada", "seguir"):
|
|
324
|
+
raise _RegistroIndisponivel(_resposta)
|
|
325
|
+
self.incerto.add(self.digest)
|
|
326
|
+
if not _save_checkpoint(self.path, self.done, self.incerto):
|
|
327
|
+
self.incerto.discard(self.digest)
|
|
328
|
+
if _CANAL_DO_REGISTRO is not None:
|
|
329
|
+
_CANAL_DO_REGISTRO.enfileirar(OP_DESFAZER, self.digest)
|
|
330
|
+
raise RuntimeError(
|
|
331
|
+
f"Nao foi possivel registrar o item em {self.path} antes de efetivar: "
|
|
332
|
+
f"sem esse registro, uma interrupcao agora faria o item ser refeito na "
|
|
333
|
+
f"retomada. O item NAO foi efetivado."
|
|
334
|
+
)
|
|
335
|
+
self.marcou = True
|
|
336
|
+
|
|
337
|
+
def desfazer(self) -> None:
|
|
338
|
+
if not self.marcou:
|
|
339
|
+
return
|
|
340
|
+
self.marcou = False
|
|
341
|
+
self.incerto.discard(self.digest)
|
|
342
|
+
_save_checkpoint(self.path, self.done, self.incerto)
|
|
343
|
+
if _CANAL_DO_REGISTRO is not None:
|
|
344
|
+
_CANAL_DO_REGISTRO.enfileirar(OP_DESFAZER, self.digest)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# ── Registro no servidor (HU-17.7, ADR 012) ──────────────────────────────────
|
|
348
|
+
# Na plataforma, o executor injeta SHADE_REGISTRO_DIR e o robo pede o registro de
|
|
349
|
+
# cada item ANTES de efetivar, por arquivos nessa pasta (o sandbox nao tem rede para
|
|
350
|
+
# a plataforma). Sem a variavel (o robo rodando na maquina de quem o baixou, o
|
|
351
|
+
# ensaio, o smoke), nada disto liga: so o ledger local, como sempre.
|
|
352
|
+
|
|
353
|
+
#: Quanto o robo espera a resposta de um registro: o executor atende em serie, e um
|
|
354
|
+
#: feito ainda subindo (ate 40 s) vem antes do incerto (outros 40 s). Abaixo da posse
|
|
355
|
+
#: do run (120 s) menos a margem com que o executor para o container (30 s).
|
|
356
|
+
_PRAZO_DO_REGISTRO = 85.0
|
|
357
|
+
#: Sem o executor dar sinal de vida por este tempo, o robo se encerra: um executor
|
|
358
|
+
#: morto nao renova a posse, e outro pode estar rodando o run.
|
|
359
|
+
_SEM_VIDA_DO_EXECUTOR = 30.0
|
|
360
|
+
_REGISTRO_PAROU = []
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class _JaFeitoNoServidor(BaseException):
|
|
364
|
+
"""O servidor ja tem o item como feito: pular, sem efetivar.
|
|
365
|
+
|
|
366
|
+
BaseException, e nao Exception: a re-tentativa por passo e a recuperacao do item
|
|
367
|
+
capturam Exception, e uma delas faria o item seguir (ou ser dado por feito)."""
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class _RetidoNoServidor(BaseException):
|
|
371
|
+
"""Uma execucao anterior comecou a efetivar o item e nao confirmou: fica retido
|
|
372
|
+
para conferencia, sem efetivar."""
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class _RegistroIndisponivel(BaseException):
|
|
376
|
+
"""O registro nao respondeu (ou recusou): o robo para sem efetivar. Sem o
|
|
377
|
+
registro, uma interrupcao agora faria o item ser refeito."""
|
|
378
|
+
|
|
379
|
+
def __init__(self, motivo: str) -> None:
|
|
380
|
+
super().__init__(motivo)
|
|
381
|
+
_REGISTRO_PAROU.append(motivo)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class _CanalDoRegistro:
|
|
385
|
+
"""Os pedidos ao executor, um arquivo por pedido, numerados em ordem."""
|
|
386
|
+
|
|
387
|
+
def __init__(self, pasta: str) -> None:
|
|
388
|
+
import os as _os
|
|
389
|
+
import threading as _th
|
|
390
|
+
|
|
391
|
+
self.pasta = pasta
|
|
392
|
+
self.pedidos = _os.path.join(pasta, PEDIDOS)
|
|
393
|
+
self.respostas = _os.path.join(pasta, RESPOSTAS)
|
|
394
|
+
self.seq = 0
|
|
395
|
+
self.campos_gravados = False
|
|
396
|
+
self._trava = _th.Lock()
|
|
397
|
+
|
|
398
|
+
def _escrever(self, op: str, chave: str) -> int:
|
|
399
|
+
import os as _os
|
|
400
|
+
|
|
401
|
+
with self._trava:
|
|
402
|
+
if not self.campos_gravados:
|
|
403
|
+
_tmp = _os.path.join(self.pasta, ".campos.tmp")
|
|
404
|
+
with open(_tmp, "w", encoding="utf-8") as _f:
|
|
405
|
+
json.dump([str(f) for f in _KEY_FIELDS], _f)
|
|
406
|
+
_os.replace(_tmp, _os.path.join(self.pasta, CAMPOS))
|
|
407
|
+
self.campos_gravados = True
|
|
408
|
+
# O numero so avanca depois de o pedido existir: um pedido que nao gravou nao
|
|
409
|
+
# deixa buraco na sequencia (o executor recusaria o registro esperando por ele).
|
|
410
|
+
_seq = self.seq + 1
|
|
411
|
+
_nome = f"{_seq:0{DIGITOS_DA_SEQUENCIA}d}.json"
|
|
412
|
+
_tmp = _os.path.join(self.pedidos, "." + _nome + ".tmp")
|
|
413
|
+
with open(_tmp, "w", encoding="utf-8") as _f:
|
|
414
|
+
json.dump({"op": op, "key": chave}, _f)
|
|
415
|
+
_os.replace(_tmp, _os.path.join(self.pedidos, _nome))
|
|
416
|
+
self.seq = _seq
|
|
417
|
+
return _seq
|
|
418
|
+
|
|
419
|
+
def pedir(self, chave: str) -> str:
|
|
420
|
+
"""Pede o registro do item como incerto e espera a resposta."""
|
|
421
|
+
import os as _os
|
|
422
|
+
import time as _t
|
|
423
|
+
|
|
424
|
+
try:
|
|
425
|
+
_seq = self._escrever(OP_INCERTO, chave)
|
|
426
|
+
except Exception as _exc: # noqa: BLE001
|
|
427
|
+
return f"pedido nao gravou ({_exc})"
|
|
428
|
+
_arquivo = _os.path.join(self.respostas, f"{_seq:0{DIGITOS_DA_SEQUENCIA}d}.json")
|
|
429
|
+
_limite = _t.monotonic() + _PRAZO_DO_REGISTRO
|
|
430
|
+
while _t.monotonic() < _limite:
|
|
431
|
+
try:
|
|
432
|
+
with open(_arquivo, encoding="utf-8") as _f:
|
|
433
|
+
_resposta = str(json.load(_f).get("r", ""))
|
|
434
|
+
except FileNotFoundError:
|
|
435
|
+
_t.sleep(POLLING_DA_RESPOSTA_SECONDS)
|
|
436
|
+
continue
|
|
437
|
+
except Exception: # noqa: BLE001
|
|
438
|
+
_resposta = "resposta ilegivel"
|
|
439
|
+
try:
|
|
440
|
+
_os.remove(_arquivo)
|
|
441
|
+
except OSError:
|
|
442
|
+
pass
|
|
443
|
+
return _resposta
|
|
444
|
+
return "sem resposta"
|
|
445
|
+
|
|
446
|
+
def enfileirar(self, op: str, chave: str) -> None:
|
|
447
|
+
"""O feito e o desfazer: sem esperar, e sem nunca levantar (o item ja esta
|
|
448
|
+
decidido; a falha so deixa a duvida no servidor, do lado seguro)."""
|
|
449
|
+
try:
|
|
450
|
+
self._escrever(op, chave)
|
|
451
|
+
except Exception as _exc: # noqa: BLE001
|
|
452
|
+
logger.warning(f"Registro de {op} do item nao gravou: {_exc}")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _vigiar_o_executor(pasta: str) -> None:
|
|
456
|
+
"""Encerra o robo se o executor parar de dar sinal de vida (o arquivo `vivo`
|
|
457
|
+
deixa de mudar): sem executor, ninguem segura a posse do run."""
|
|
458
|
+
import os as _os
|
|
459
|
+
import time as _t
|
|
460
|
+
|
|
461
|
+
_arquivo = _os.path.join(pasta, VIVO)
|
|
462
|
+
_visto = None
|
|
463
|
+
_desde = _t.monotonic()
|
|
464
|
+
while True:
|
|
465
|
+
try:
|
|
466
|
+
with open(_arquivo, encoding="ascii") as _f:
|
|
467
|
+
_valor = _f.read()
|
|
468
|
+
except Exception: # noqa: BLE001
|
|
469
|
+
_valor = None
|
|
470
|
+
if _valor != _visto:
|
|
471
|
+
_visto, _desde = _valor, _t.monotonic()
|
|
472
|
+
elif _t.monotonic() - _desde > _SEM_VIDA_DO_EXECUTOR:
|
|
473
|
+
logger.error(
|
|
474
|
+
"O executor deixou de dar sinal de vida: o robo para aqui, sem efetivar "
|
|
475
|
+
"mais nada (outro executor pode retomar a execucao)."
|
|
476
|
+
)
|
|
477
|
+
_os._exit(75)
|
|
478
|
+
_t.sleep(1)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _iniciar_o_registro():
|
|
482
|
+
import os as _os
|
|
483
|
+
|
|
484
|
+
_pasta = _os.environ.get(VARIAVEL_DA_PASTA)
|
|
485
|
+
if not _pasta:
|
|
486
|
+
return None
|
|
487
|
+
import threading as _th
|
|
488
|
+
|
|
489
|
+
_th.Thread(target=_vigiar_o_executor, args=(_pasta,), daemon=True).start()
|
|
490
|
+
return _CanalDoRegistro(_pasta)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
_CANAL_DO_REGISTRO = _iniciar_o_registro()
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _concluir_item(path: str, done: set, incerto: set, digest: str) -> None:
|
|
497
|
+
"""O item foi feito (ou dado por feito): sai da duvida, entra no ledger local e,
|
|
498
|
+
na plataforma, o feito vai para o servidor."""
|
|
499
|
+
if not digest:
|
|
500
|
+
return
|
|
501
|
+
done.add(digest)
|
|
502
|
+
incerto.discard(digest)
|
|
503
|
+
_save_checkpoint(path, done, incerto)
|
|
504
|
+
if _CANAL_DO_REGISTRO is not None:
|
|
505
|
+
_CANAL_DO_REGISTRO.enfileirar(OP_FEITO, digest)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""A planilha de saída do robô: ``data/output.xlsx`` (ou ``.csv`` sem ``openpyxl``).
|
|
2
|
+
|
|
3
|
+
Duas variantes, como no gerador: ``_write_output`` grava como veio; ``_write_output_com_pii``
|
|
4
|
+
aplica a decisão de PII por coluna (Fronteira 3, LGPD): ``drop_cols`` não são gravadas e
|
|
5
|
+
``mask_cols`` passam por ``_mask_pii``. O mascaramento acontece na fronteira de gravação, e o
|
|
6
|
+
valor em memória segue real. O ``main.py`` gerado importa uma das duas com o nome
|
|
7
|
+
``_write_output``.
|
|
8
|
+
|
|
9
|
+
Coluna ``_status`` → cabeçalho "Status"; a ordem é: colunas de entrada | Status | capturadas.
|
|
10
|
+
|
|
11
|
+
Era ``runtime_templates/output_writer_runtime.py`` e ``output_writer_pii_runtime.py`` no
|
|
12
|
+
backend, colados dentro do ``main.py`` gerado; desde a HU-29.1 o robô os importa daqui.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import csv
|
|
18
|
+
import os
|
|
19
|
+
|
|
20
|
+
from loguru import logger
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _csv_celula_segura(v):
|
|
24
|
+
"""Escape pelo formato: o Excel calcula célula de CSV iniciada por = + - @ (ou
|
|
25
|
+
tab/CR). Prefixa com apóstrofo; número puro ("-5") segue intacto."""
|
|
26
|
+
if isinstance(v, str) and v[:1] in ("=", "+", "-", "@", "\t", "\r"):
|
|
27
|
+
try:
|
|
28
|
+
float(v)
|
|
29
|
+
except ValueError:
|
|
30
|
+
return "'" + v
|
|
31
|
+
return v
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _write_output(results: list[dict], path: str = "data/output.xlsx") -> None:
|
|
35
|
+
"""Grava planilha de saída: colunas de entrada + Status + valores capturados."""
|
|
36
|
+
if not results:
|
|
37
|
+
return
|
|
38
|
+
fields: list[str] = []
|
|
39
|
+
for r in results:
|
|
40
|
+
for k in r:
|
|
41
|
+
if k not in fields:
|
|
42
|
+
fields.append(k)
|
|
43
|
+
header = ["Status" if f == "_status" else f for f in fields]
|
|
44
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
45
|
+
try:
|
|
46
|
+
import openpyxl as _xl
|
|
47
|
+
|
|
48
|
+
wb = _xl.Workbook()
|
|
49
|
+
ws = wb.active
|
|
50
|
+
ws.append(header)
|
|
51
|
+
for r in results:
|
|
52
|
+
ws.append([r.get(f, "") for f in fields])
|
|
53
|
+
# Escape pelo formato: o openpyxl grava como FÓRMULA todo texto iniciado por
|
|
54
|
+
# "=" (dado do sistema-alvo); o Excel do cliente a calcularia ao abrir.
|
|
55
|
+
for _linha in ws.iter_rows():
|
|
56
|
+
for _c in _linha:
|
|
57
|
+
if _c.data_type == "f":
|
|
58
|
+
_c.data_type = "s"
|
|
59
|
+
wb.save(path)
|
|
60
|
+
except ImportError:
|
|
61
|
+
path = path.replace(".xlsx", ".csv")
|
|
62
|
+
with open(path, "w", newline="", encoding="utf-8-sig") as f:
|
|
63
|
+
writer = csv.writer(f)
|
|
64
|
+
writer.writerow([_csv_celula_segura(h) for h in header])
|
|
65
|
+
for r in results:
|
|
66
|
+
writer.writerow([_csv_celula_segura(r.get(f, "")) for f in fields])
|
|
67
|
+
logger.success(f"Saída gravada em {path} ({len(results)} linha(s)).")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _mask_pii(value) -> str:
|
|
71
|
+
"""Mascara parcialmente um valor de saída marcado como dado pessoal (LGPD).
|
|
72
|
+
|
|
73
|
+
Preserva o mínimo de utilidade (formato reconhecível) sem expor o dado cru:
|
|
74
|
+
e-mail mantém a inicial + domínio; documento/telefone mantêm os 2 últimos
|
|
75
|
+
dígitos; demais valores mantêm a inicial. Valor vazio permanece vazio."""
|
|
76
|
+
s = str(value or "")
|
|
77
|
+
if not s:
|
|
78
|
+
return s
|
|
79
|
+
if "@" in s and "." in s.split("@")[-1]:
|
|
80
|
+
local, _, domain = s.partition("@")
|
|
81
|
+
return (local[:1] or "*") + "***@" + domain
|
|
82
|
+
digits = "".join(ch for ch in s if ch.isdigit())
|
|
83
|
+
if len(digits) >= 4:
|
|
84
|
+
return "***" + s[-2:]
|
|
85
|
+
return s[:1] + "***"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _write_output_com_pii(
|
|
89
|
+
results: list[dict],
|
|
90
|
+
path: str = "data/output.xlsx",
|
|
91
|
+
mask_cols: tuple = (),
|
|
92
|
+
drop_cols: tuple = (),
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Grava a planilha de saída aplicando a política de PII por coluna (LGPD):
|
|
95
|
+
``drop_cols`` são omitidas; ``mask_cols`` são mascaradas via ``_mask_pii``."""
|
|
96
|
+
if not results:
|
|
97
|
+
return
|
|
98
|
+
mask_set = set(mask_cols)
|
|
99
|
+
drop_set = set(drop_cols)
|
|
100
|
+
fields: list[str] = []
|
|
101
|
+
for r in results:
|
|
102
|
+
for k in r:
|
|
103
|
+
if k in drop_set:
|
|
104
|
+
continue
|
|
105
|
+
if k not in fields:
|
|
106
|
+
fields.append(k)
|
|
107
|
+
header = ["Status" if f == "_status" else f for f in fields]
|
|
108
|
+
|
|
109
|
+
def _cell(r: dict, f: str):
|
|
110
|
+
v = r.get(f, "")
|
|
111
|
+
return _mask_pii(v) if f in mask_set else v
|
|
112
|
+
|
|
113
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
114
|
+
try:
|
|
115
|
+
import openpyxl as _xl
|
|
116
|
+
|
|
117
|
+
wb = _xl.Workbook()
|
|
118
|
+
ws = wb.active
|
|
119
|
+
ws.append(header)
|
|
120
|
+
for r in results:
|
|
121
|
+
ws.append([_cell(r, f) for f in fields])
|
|
122
|
+
# Escape pelo formato: o openpyxl grava como FÓRMULA todo texto iniciado por
|
|
123
|
+
# "=" (dado do sistema-alvo); o Excel do cliente a calcularia ao abrir.
|
|
124
|
+
for _linha in ws.iter_rows():
|
|
125
|
+
for _c in _linha:
|
|
126
|
+
if _c.data_type == "f":
|
|
127
|
+
_c.data_type = "s"
|
|
128
|
+
wb.save(path)
|
|
129
|
+
except ImportError:
|
|
130
|
+
path = path.replace(".xlsx", ".csv")
|
|
131
|
+
with open(path, "w", newline="", encoding="utf-8-sig") as f:
|
|
132
|
+
writer = csv.writer(f)
|
|
133
|
+
writer.writerow([_csv_celula_segura(h) for h in header])
|
|
134
|
+
for r in results:
|
|
135
|
+
writer.writerow([_csv_celula_segura(_cell(r, f)) for f in fields])
|
|
136
|
+
logger.success(f"Saída gravada em {path} ({len(results)} linha(s)).")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""O protocolo do runtime: o contrato entre o robô e o executor da plataforma (ADR 019, D10).
|
|
2
|
+
|
|
3
|
+
Na plataforma, o executor injeta ``SHADE_REGISTRO_DIR`` e o robô pede o registro de cada item
|
|
4
|
+
antes de efetivar por arquivos nessa pasta (HU-17.7, ADR 012): o sandbox não tem rede para a
|
|
5
|
+
plataforma. O formato da pasta é contrato público, e este módulo é a fonte dele dos dois lados
|
|
6
|
+
(o ledger do robô e ``workers/registro_do_item.py`` no executor)::
|
|
7
|
+
|
|
8
|
+
$SHADE_REGISTRO_DIR/
|
|
9
|
+
campos.json ["pedido"] os campos-chave do robô, gravado uma vez
|
|
10
|
+
pedidos/NNNNNNNNNNNN.json {"op": "incerto" | "feito" | "desfazer", "key": "<sha256>"}
|
|
11
|
+
respostas/NNNNNNNNNNNN.json {"r": "registrada" | "seguir" | "feita" | "retida" | ...}
|
|
12
|
+
vivo um contador que o executor troca enquanto a posse vale
|
|
13
|
+
|
|
14
|
+
- Cada arquivo nasce num temporário e entra por ``os.replace`` (atômico): quem lê nunca vê
|
|
15
|
+
um JSON pela metade.
|
|
16
|
+
- A sequência ``NNNNNNNNNNNN`` (12 dígitos) começa em 1 e não tem buraco: o número só avança
|
|
17
|
+
depois de o pedido existir.
|
|
18
|
+
- Só o ``incerto`` tem resposta, e o robô a espera por polling de 10 ms até o prazo do
|
|
19
|
+
registro; ``feito`` e ``desfazer`` vão sem esperar.
|
|
20
|
+
- O robô se encerra se o ``vivo`` parar de mudar (o executor morto não renova a posse).
|
|
21
|
+
|
|
22
|
+
**Mudar qualquer coisa deste formato sobe ``RUNTIME_PROTOCOL``.** O número vai no manifesto da
|
|
23
|
+
release (``runtime_protocol``), e o executor recusa, antes de subir o container, o artefato
|
|
24
|
+
com um número que ele não fala.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
#: A versão do contrato acima. O executor aceita um conjunto de números (ver
|
|
30
|
+
#: ``workers/registro_do_item.py``); o robô fala exatamente um.
|
|
31
|
+
RUNTIME_PROTOCOL = 1
|
|
32
|
+
|
|
33
|
+
#: A variável que liga o modo plataforma no robô (o caminho da pasta).
|
|
34
|
+
VARIAVEL_DA_PASTA = "SHADE_REGISTRO_DIR"
|
|
35
|
+
PEDIDOS = "pedidos"
|
|
36
|
+
RESPOSTAS = "respostas"
|
|
37
|
+
CAMPOS = "campos.json"
|
|
38
|
+
VIVO = "vivo"
|
|
39
|
+
|
|
40
|
+
OP_INCERTO = "incerto"
|
|
41
|
+
OP_FEITO = "feito"
|
|
42
|
+
OP_DESFAZER = "desfazer"
|
|
43
|
+
|
|
44
|
+
#: Quantos dígitos tem o número do pedido e da resposta no nome do arquivo.
|
|
45
|
+
DIGITOS_DA_SEQUENCIA = 12
|
|
46
|
+
#: O intervalo com que o robô procura a resposta de um ``incerto``.
|
|
47
|
+
POLLING_DA_RESPOSTA_SECONDS = 0.01
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Recuperação do robô: classificação da falha, sessão perdida (S3) e modal inesperado (S2).
|
|
2
|
+
|
|
3
|
+
Era ``runtime_templates/recovery_runtime.py`` no backend, colado em três trechos dentro do
|
|
4
|
+
``main.py`` gerado; desde a HU-29.1 o robô o importa daqui. A política (``_RECOVERY_POLICY``)
|
|
5
|
+
é do processo e segue no ``main.py``.
|
|
6
|
+
|
|
7
|
+
As constantes ajustáveis por ambiente são lidas no import deste módulo: o ``main.py`` gerado
|
|
8
|
+
o importa depois de ``src.config``, que leva o ``.env`` para o ambiente antes.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
_RECOVERY_MAX_ATTEMPTS = int(os.getenv("RECOVERY_MAX_ATTEMPTS", "3"))
|
|
16
|
+
_RECOVERY_BACKOFF_S = float(os.getenv("RECOVERY_BACKOFF_S", "1.0"))
|
|
17
|
+
_RECOVERY_PAUSE_S = int(os.getenv("RECOVERY_PAUSE_S", "60")) # alert_pause: segundos de espera HITL
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RecoveryStopError(RuntimeError):
|
|
21
|
+
"""Política stop: sinaliza que o lote deve ser encerrado imediatamente."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _classify_recovery_state(exc: Exception) -> str:
|
|
25
|
+
"""Classifica a exceção num estado de recuperação.
|
|
26
|
+
|
|
27
|
+
- "transient": timeout / erro de navegação do Playwright → re-tentar o
|
|
28
|
+
BLOCO do item com backoff (D3; seguro só porque o item é idempotente).
|
|
29
|
+
- "modal": clique interceptado por overlay/dialog → dispensar o modal e
|
|
30
|
+
re-tentar a ação (D3; S2). Sinal conservador: só a frase de intercepção
|
|
31
|
+
de ponteiro do Playwright identifica isso com segurança.
|
|
32
|
+
- "unknown": qualquer outra exceção → saída honesta (S4).
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
from playwright.sync_api import Error as _PwError
|
|
36
|
+
from playwright.sync_api import TimeoutError as _PwTimeout
|
|
37
|
+
except Exception: # playwright ausente — degrada para desconhecido
|
|
38
|
+
return "unknown"
|
|
39
|
+
if isinstance(exc, _PwTimeout):
|
|
40
|
+
return "transient"
|
|
41
|
+
_msg = str(exc).lower()
|
|
42
|
+
if isinstance(exc, _PwError) and "intercepts pointer events" in _msg:
|
|
43
|
+
return "modal"
|
|
44
|
+
_transient_hints = ("timeout", "navigation", "net::", "err_", "connection reset")
|
|
45
|
+
if isinstance(exc, _PwError) and any(_h in _msg for _h in _transient_hints):
|
|
46
|
+
return "transient"
|
|
47
|
+
return "unknown"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _sessao_perdida(page, marcador_de_login=None) -> bool:
|
|
51
|
+
"""Sessão perdida = o elemento de login reaparece ou a URL
|
|
52
|
+
volta para /login (sinais determinísticos do S3 — sessão expirada).
|
|
53
|
+
|
|
54
|
+
``marcador_de_login`` devolve o locator do campo de senha do login (o ``main.py`` gerado
|
|
55
|
+
passa um ``lambda`` com o seletor gravado); é chamado aqui dentro, no mesmo ``try``."""
|
|
56
|
+
try:
|
|
57
|
+
if marcador_de_login is not None and marcador_de_login().is_visible():
|
|
58
|
+
return True
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
try:
|
|
62
|
+
return "/login" in (page.url or "").lower()
|
|
63
|
+
except Exception:
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_MODAL_SELECTORS = [
|
|
68
|
+
"[role=dialog]",
|
|
69
|
+
"[aria-modal=true]",
|
|
70
|
+
".modal",
|
|
71
|
+
".overlay",
|
|
72
|
+
".popup",
|
|
73
|
+
]
|
|
74
|
+
_MODAL_CLOSE_SELECTORS = [
|
|
75
|
+
"[role=dialog] button[aria-label*=fechar i]",
|
|
76
|
+
"[role=dialog] button[aria-label*=close i]",
|
|
77
|
+
"[role=dialog] button[class*=close]",
|
|
78
|
+
"[role=dialog] button[class*=fechar]",
|
|
79
|
+
".modal button[class*=close]",
|
|
80
|
+
".overlay button[class*=close]",
|
|
81
|
+
]
|
|
82
|
+
_MODAL_OK_TEXTS = ["OK", "Fechar", "Aceitar", "Continuar", "Close"]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _is_modal_present(page) -> bool:
|
|
86
|
+
"""S2: verifica se há um modal/overlay visível na página.
|
|
87
|
+
Conservador: só sinais claros de dialog/overlay (role, aria-modal, classes comuns)."""
|
|
88
|
+
for _sel in _MODAL_SELECTORS:
|
|
89
|
+
try:
|
|
90
|
+
if page.locator(_sel).first.is_visible(timeout=500):
|
|
91
|
+
return True
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _dismiss_modal(page) -> None:
|
|
98
|
+
"""S2: tenta dispensar um modal/overlay inesperado.
|
|
99
|
+
|
|
100
|
+
Estratégia conservadora (ordem de preferência):
|
|
101
|
+
1. Botão de fechar canônico (aria-label close/fechar ou class close/fechar).
|
|
102
|
+
2. Botão com texto OK/Fechar/Aceitar/Continuar/Close dentro do dialog.
|
|
103
|
+
3. Tecla Esc (último recurso — pode fechar dialogs nativos).
|
|
104
|
+
Levanta exceção se nenhuma estratégia funcionar (chamador decide).
|
|
105
|
+
"""
|
|
106
|
+
# 1. Botão de fechar por seletor canônico
|
|
107
|
+
for _cs in _MODAL_CLOSE_SELECTORS:
|
|
108
|
+
try:
|
|
109
|
+
_btn = page.locator(_cs).first
|
|
110
|
+
if _btn.is_visible(timeout=500):
|
|
111
|
+
_btn.click(timeout=2000)
|
|
112
|
+
return
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
# 2. Botão por texto dentro do dialog
|
|
116
|
+
for _txt in _MODAL_OK_TEXTS:
|
|
117
|
+
try:
|
|
118
|
+
_btn = (
|
|
119
|
+
page.locator("[role=dialog] button, .modal button")
|
|
120
|
+
.get_by_text(_txt, exact=True)
|
|
121
|
+
.first
|
|
122
|
+
)
|
|
123
|
+
if _btn.is_visible(timeout=500):
|
|
124
|
+
_btn.click(timeout=2000)
|
|
125
|
+
return
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
# 3. Esc como último recurso
|
|
129
|
+
try:
|
|
130
|
+
page.keyboard.press("Escape")
|
|
131
|
+
page.wait_for_timeout(300)
|
|
132
|
+
if not _is_modal_present(page):
|
|
133
|
+
return
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
raise RuntimeError("Não foi possível dispensar o modal")
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Relatório HTML da execução (``relatorio.html``) e o histórico (``data/historico.csv``).
|
|
2
|
+
|
|
3
|
+
Chamado ao fim de cada run do ``main.py``. Era ``runtime_templates/report_runtime.py`` no
|
|
4
|
+
backend, copiado como ``src/report.py`` do projeto gerado; desde a HU-29.1 o ``src/report.py``
|
|
5
|
+
gerado reexporta daqui.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import csv as _csv
|
|
12
|
+
import html as _html
|
|
13
|
+
import os as _os
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
# ── API pública ───────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def generate_report(
|
|
21
|
+
process_name: str,
|
|
22
|
+
step_descriptions: list[str],
|
|
23
|
+
steps_executed: int,
|
|
24
|
+
success: bool,
|
|
25
|
+
duration_s: float,
|
|
26
|
+
screenshot_paths: list[str] | None = None,
|
|
27
|
+
output_path: str = "relatorio.html",
|
|
28
|
+
failed_step: str = "",
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Gera relatorio.html e apensa ao historico.csv; erros sao silenciados."""
|
|
31
|
+
try:
|
|
32
|
+
if not failed_step and not success and steps_executed < len(step_descriptions):
|
|
33
|
+
failed_step = step_descriptions[steps_executed]
|
|
34
|
+
append_historico(
|
|
35
|
+
success=success,
|
|
36
|
+
duration_s=duration_s,
|
|
37
|
+
failed_step=failed_step,
|
|
38
|
+
steps_executed=steps_executed,
|
|
39
|
+
)
|
|
40
|
+
_write_report(
|
|
41
|
+
process_name=process_name,
|
|
42
|
+
step_descriptions=step_descriptions,
|
|
43
|
+
steps_executed=steps_executed,
|
|
44
|
+
success=success,
|
|
45
|
+
duration_s=duration_s,
|
|
46
|
+
screenshot_paths=screenshot_paths or [],
|
|
47
|
+
output_path=output_path,
|
|
48
|
+
)
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ── Internals ─────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _embed_image(path: str) -> str | None:
|
|
57
|
+
"""Retorna data-URI base64 da imagem ou None se não disponível."""
|
|
58
|
+
try:
|
|
59
|
+
data = Path(path).read_bytes()
|
|
60
|
+
ext = Path(path).suffix.lstrip(".").lower() or "png"
|
|
61
|
+
mime = {"jpg": "jpeg", "jpeg": "jpeg"}.get(ext, ext)
|
|
62
|
+
return f"data:image/{mime};base64,{base64.b64encode(data).decode()}"
|
|
63
|
+
except Exception:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def append_historico(
|
|
68
|
+
success: bool,
|
|
69
|
+
duration_s: float,
|
|
70
|
+
failed_step: str,
|
|
71
|
+
steps_executed: int,
|
|
72
|
+
csv_path: str = "data/historico.csv",
|
|
73
|
+
) -> dict:
|
|
74
|
+
"""Apende uma linha em data/historico.csv; cria com header se nao existir."""
|
|
75
|
+
_HEADER = ["timestamp", "status", "duracao_s", "passo_falhou", "passos_executados"]
|
|
76
|
+
_os.makedirs(_os.path.dirname(csv_path) or ".", exist_ok=True)
|
|
77
|
+
ts = datetime.now().isoformat(timespec="seconds")
|
|
78
|
+
status = "sucesso" if success else "falha"
|
|
79
|
+
row = [ts, status, f"{duration_s:.2f}", failed_step, str(steps_executed)]
|
|
80
|
+
needs_header = not _os.path.exists(csv_path)
|
|
81
|
+
with open(csv_path, "a", newline="", encoding="utf-8") as _f:
|
|
82
|
+
writer = _csv.writer(_f)
|
|
83
|
+
if needs_header:
|
|
84
|
+
writer.writerow(_HEADER)
|
|
85
|
+
writer.writerow(row)
|
|
86
|
+
try:
|
|
87
|
+
with open(csv_path, newline="", encoding="utf-8") as _f:
|
|
88
|
+
_rows = list(_csv.DictReader(_f))
|
|
89
|
+
total = len(_rows)
|
|
90
|
+
successes = sum(1 for r in _rows if r.get("status") == "sucesso")
|
|
91
|
+
rate = round(100 * successes / total) if total else 0
|
|
92
|
+
return {"total": total, "successes": successes, "failures": total - successes, "rate": rate}
|
|
93
|
+
except Exception:
|
|
94
|
+
return {
|
|
95
|
+
"total": 1,
|
|
96
|
+
"successes": int(success),
|
|
97
|
+
"failures": int(not success),
|
|
98
|
+
"rate": 100 * int(success),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _read_historico(csv_path: str = "data/historico.csv") -> tuple[list[dict], dict]:
|
|
103
|
+
"""Le historico.csv; retorna (ultimas 20 linhas, stats agregados)."""
|
|
104
|
+
try:
|
|
105
|
+
with open(csv_path, newline="", encoding="utf-8") as _f:
|
|
106
|
+
rows = list(_csv.DictReader(_f))
|
|
107
|
+
except FileNotFoundError:
|
|
108
|
+
return [], {"total": 0, "successes": 0, "failures": 0, "rate": 0}
|
|
109
|
+
total = len(rows)
|
|
110
|
+
successes = sum(1 for r in rows if r.get("status") == "sucesso")
|
|
111
|
+
rate = round(100 * successes / total) if total else 0
|
|
112
|
+
return rows[-20:], {
|
|
113
|
+
"total": total,
|
|
114
|
+
"successes": successes,
|
|
115
|
+
"failures": total - successes,
|
|
116
|
+
"rate": rate,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _history_section(csv_path: str = "data/historico.csv") -> str:
|
|
121
|
+
"""Gera secao HTML com historico de execucoes (ultimas 20 + agregados)."""
|
|
122
|
+
rows, stats = _read_historico(csv_path)
|
|
123
|
+
if not rows:
|
|
124
|
+
return (
|
|
125
|
+
"<h2>Historico de execucoes</h2>"
|
|
126
|
+
"<p style='color:#64748b;font-size:13px;margin-bottom:28px'>"
|
|
127
|
+
"Primeira execucao — sem historico anterior.</p>"
|
|
128
|
+
)
|
|
129
|
+
total = stats["total"]
|
|
130
|
+
rate = stats["rate"]
|
|
131
|
+
durs = [float(r.get("duracao_s") or 0) for r in rows]
|
|
132
|
+
avg_dur = sum(durs) / len(durs) if durs else 0.0
|
|
133
|
+
rate_cls = "ok-val" if rate >= 80 else ("teal-val" if rate >= 50 else "err-val")
|
|
134
|
+
cards = (
|
|
135
|
+
f"<div class='card'><div class='lbl'>Total de execucoes</div>"
|
|
136
|
+
f"<div class='val teal-val'>{total}</div></div>"
|
|
137
|
+
f"<div class='card'><div class='lbl'>Taxa de sucesso</div>"
|
|
138
|
+
f"<div class='val {rate_cls}'>{rate}%</div></div>"
|
|
139
|
+
f"<div class='card'><div class='lbl'>Duracao media</div>"
|
|
140
|
+
f"<div class='val teal-val'>{avg_dur:.1f}s</div></div>"
|
|
141
|
+
)
|
|
142
|
+
table_rows_html = []
|
|
143
|
+
for r in reversed(rows):
|
|
144
|
+
ts = _html.escape(r.get("timestamp", ""))
|
|
145
|
+
st = r.get("status", "")
|
|
146
|
+
badge = (
|
|
147
|
+
'<span class="badge ok">sucesso</span>'
|
|
148
|
+
if st == "sucesso"
|
|
149
|
+
else '<span class="badge err">falha</span>'
|
|
150
|
+
)
|
|
151
|
+
dur = _html.escape(str(r.get("duracao_s") or ""))
|
|
152
|
+
fp = _html.escape(r.get("passo_falhou") or "—")
|
|
153
|
+
pe = _html.escape(str(r.get("passos_executados") or ""))
|
|
154
|
+
table_rows_html.append(
|
|
155
|
+
f"<tr><td>{ts}</td><td>{badge}</td><td>{dur}s</td><td>{fp}</td><td>{pe}</td></tr>"
|
|
156
|
+
)
|
|
157
|
+
return (
|
|
158
|
+
"<h2>Historico de execucoes</h2>"
|
|
159
|
+
f"<div class='summary'>{cards}</div>"
|
|
160
|
+
"<table><thead><tr>"
|
|
161
|
+
"<th>Timestamp</th><th>Status</th><th>Duracao</th>"
|
|
162
|
+
"<th>Passo que falhou</th><th>Passos executados</th>"
|
|
163
|
+
"</tr></thead><tbody>" + "\n".join(table_rows_html) + "</tbody></table>"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _step_rows(step_descriptions: list[str], steps_executed: int, success: bool) -> str:
|
|
168
|
+
rows = []
|
|
169
|
+
for i, desc in enumerate(step_descriptions, start=1):
|
|
170
|
+
e = _html.escape(desc)
|
|
171
|
+
if i <= steps_executed:
|
|
172
|
+
badge = '<span class="badge ok">OK</span>'
|
|
173
|
+
elif not success and i == steps_executed + 1:
|
|
174
|
+
badge = '<span class="badge err">ERRO</span>'
|
|
175
|
+
else:
|
|
176
|
+
badge = '<span class="badge pend">Pendente</span>'
|
|
177
|
+
rows.append(f"<tr><td class='num'>{i}</td><td>{e}</td><td>{badge}</td></tr>")
|
|
178
|
+
return "\n".join(rows)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _screenshots_section(screenshot_paths: list[str]) -> str:
|
|
182
|
+
items = []
|
|
183
|
+
for p in screenshot_paths:
|
|
184
|
+
uri = _embed_image(p)
|
|
185
|
+
if not uri:
|
|
186
|
+
continue
|
|
187
|
+
name = _html.escape(Path(p).name)
|
|
188
|
+
items.append(
|
|
189
|
+
f'<div class="shot"><img src="{uri}" alt="{name}" loading="lazy">'
|
|
190
|
+
f'<div class="caption">{name}</div></div>'
|
|
191
|
+
)
|
|
192
|
+
if not items:
|
|
193
|
+
return ""
|
|
194
|
+
return '<h2>Evidências</h2><div class="shots">' + "".join(items) + "</div>"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
_CSS = """\
|
|
198
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
199
|
+
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px;
|
|
200
|
+
color:#1a1a1a;background:#fff;line-height:1.5}
|
|
201
|
+
.wrap{max-width:920px;margin:0 auto;padding:40px 24px}
|
|
202
|
+
.hdr{border-bottom:2px solid #0d9488;padding-bottom:18px;margin-bottom:28px}
|
|
203
|
+
.hdr h1{font-size:22px;font-weight:700;color:#0d9488}
|
|
204
|
+
.hdr .meta{color:#555;font-size:13px;margin-top:5px}
|
|
205
|
+
.summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));
|
|
206
|
+
gap:14px;margin-bottom:28px}
|
|
207
|
+
.card{border:1px solid #e2e8f0;border-radius:6px;padding:14px;text-align:center}
|
|
208
|
+
.card .lbl{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#64748b}
|
|
209
|
+
.card .val{font-size:26px;font-weight:700;margin-top:4px}
|
|
210
|
+
.ok-val{color:#16a34a} .err-val{color:#dc2626} .teal-val{color:#0d9488}
|
|
211
|
+
h2{font-size:15px;font-weight:600;margin-bottom:10px;color:#334155}
|
|
212
|
+
table{width:100%;border-collapse:collapse;margin-bottom:28px}
|
|
213
|
+
th{text-align:left;padding:8px 12px;font-size:11px;text-transform:uppercase;
|
|
214
|
+
letter-spacing:.05em;color:#64748b;border-bottom:2px solid #e2e8f0}
|
|
215
|
+
td{padding:9px 12px;border-bottom:1px solid #f1f5f9;font-size:13px}
|
|
216
|
+
td.num{width:40px;color:#94a3b8;text-align:right;padding-right:16px}
|
|
217
|
+
tr:last-child td{border-bottom:none}
|
|
218
|
+
.badge{display:inline-block;padding:2px 7px;border-radius:4px;
|
|
219
|
+
font-size:11px;font-weight:600;text-transform:uppercase}
|
|
220
|
+
.badge.ok{background:#dcfce7;color:#16a34a}
|
|
221
|
+
.badge.err{background:#fee2e2;color:#dc2626}
|
|
222
|
+
.badge.pend{background:#f1f5f9;color:#94a3b8}
|
|
223
|
+
.shots{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));
|
|
224
|
+
gap:14px;margin-bottom:28px}
|
|
225
|
+
.shot img{width:100%;border-radius:5px;border:1px solid #e2e8f0;display:block}
|
|
226
|
+
.caption{font-size:11px;color:#64748b;margin-top:4px}
|
|
227
|
+
.footer{color:#94a3b8;font-size:12px;text-align:center;margin-top:36px;
|
|
228
|
+
padding-top:16px;border-top:1px solid #e2e8f0}
|
|
229
|
+
.sched{background:#f0fdfa;border:1px solid #99f6e4;border-radius:6px;
|
|
230
|
+
padding:14px 18px;margin-bottom:28px;font-size:13px}
|
|
231
|
+
.sched h3{font-size:13px;font-weight:600;margin-bottom:8px;color:#0f766e}
|
|
232
|
+
.sched ul{margin:6px 0 6px 18px}
|
|
233
|
+
.sched li{margin-bottom:3px}
|
|
234
|
+
.sched code{background:#e0f2fe;padding:1px 5px;border-radius:3px;
|
|
235
|
+
font-family:monospace;font-size:12px}
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
_SCHEDULE_HTML = (
|
|
240
|
+
"<div class='sched'>"
|
|
241
|
+
"<h3>⏰ Agendar execucao recorrente</h3>"
|
|
242
|
+
"<p>Para executar esta automacao automaticamente, use os scripts incluidos:</p>"
|
|
243
|
+
"<ul>"
|
|
244
|
+
"<li><strong>macOS / Linux:</strong> <code>bash agendar.sh</code></li>"
|
|
245
|
+
"<li><strong>Windows:</strong> execute <code>agendar.bat</code></li>"
|
|
246
|
+
"</ul>"
|
|
247
|
+
"<p>Escolha diario, semanal ou horario. Logs em: <code>logs/agendamento.log</code></p>"
|
|
248
|
+
"<p><strong>Para remover:</strong> "
|
|
249
|
+
"macOS/Linux: <code>crontab -e</code> (apague a linha com rodar.sh) | "
|
|
250
|
+
"Windows: <code>schtasks /delete /tn RPA_AUTOMACAO_DIARIO /f</code> "
|
|
251
|
+
"ou <code>taskschd.msc</code></p>"
|
|
252
|
+
"</div>"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _write_report(
|
|
257
|
+
process_name: str,
|
|
258
|
+
step_descriptions: list[str],
|
|
259
|
+
steps_executed: int,
|
|
260
|
+
success: bool,
|
|
261
|
+
duration_s: float,
|
|
262
|
+
screenshot_paths: list[str],
|
|
263
|
+
output_path: str,
|
|
264
|
+
) -> None:
|
|
265
|
+
now = datetime.now().strftime("%d/%m/%Y %H:%M:%S")
|
|
266
|
+
n = len(step_descriptions)
|
|
267
|
+
steps_ok = steps_executed if success else steps_executed
|
|
268
|
+
status_label = "Concluido" if success else "Com falha"
|
|
269
|
+
status_cls = "ok-val" if success else "err-val"
|
|
270
|
+
dur = f"{duration_s:.1f}s"
|
|
271
|
+
e_name = _html.escape(process_name)
|
|
272
|
+
|
|
273
|
+
rows_html = _step_rows(step_descriptions, steps_executed, success)
|
|
274
|
+
shots_html = _screenshots_section(screenshot_paths)
|
|
275
|
+
history_html = _history_section()
|
|
276
|
+
|
|
277
|
+
page = (
|
|
278
|
+
"<!DOCTYPE html>"
|
|
279
|
+
f'<html lang="pt-BR"><head><meta charset="UTF-8">'
|
|
280
|
+
f'<meta name="viewport" content="width=device-width,initial-scale=1">'
|
|
281
|
+
f"<title>Relatorio — {e_name}</title>"
|
|
282
|
+
f"<style>{_CSS}</style>"
|
|
283
|
+
"</head><body><div class='wrap'>"
|
|
284
|
+
f"<div class='hdr'><h1>{e_name}</h1>"
|
|
285
|
+
f"<div class='meta'>Relatorio de Execucao · {_html.escape(now)}</div></div>"
|
|
286
|
+
"<div class='summary'>"
|
|
287
|
+
f"<div class='card'><div class='lbl'>Status</div>"
|
|
288
|
+
f"<div class='val {status_cls}'>{_html.escape(status_label)}</div></div>"
|
|
289
|
+
f"<div class='card'><div class='lbl'>Passos</div>"
|
|
290
|
+
f"<div class='val teal-val'>{steps_ok}/{n}</div></div>"
|
|
291
|
+
f"<div class='card'><div class='lbl'>Duracao</div>"
|
|
292
|
+
f"<div class='val teal-val'>{_html.escape(dur)}</div></div>"
|
|
293
|
+
"</div>"
|
|
294
|
+
"<h2>Passos executados</h2>"
|
|
295
|
+
"<table><thead><tr><th>#</th><th>Descricao</th><th>Status</th></tr></thead>"
|
|
296
|
+
f"<tbody>{rows_html}</tbody></table>"
|
|
297
|
+
f"{shots_html}"
|
|
298
|
+
f"{history_html}"
|
|
299
|
+
f"{_SCHEDULE_HTML}"
|
|
300
|
+
"<div class='footer'>Gerado pelo Shade One</div>"
|
|
301
|
+
"</div></body></html>"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
Path(output_path).write_text(page, encoding="utf-8")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: shade-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: O runtime dos robôs da ShadeOne: ledger por chave de negócio, recuperação, relatório e o protocolo com o executor.
|
|
5
|
+
Author: ShadeOne
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: loguru>=0.7.0
|
|
9
|
+
Requires-Dist: pydantic-settings>=2.0.0
|
|
10
|
+
Requires-Dist: pydantic>=2.0.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# shade-sdk
|
|
14
|
+
|
|
15
|
+
O runtime dos robôs da ShadeOne. O robô gerado pela plataforma importa este pacote, na versão
|
|
16
|
+
fixada pelo `requirements.lock` da imagem do sandbox, e o robô escrito à mão vai importar o
|
|
17
|
+
mesmo pacote (ADR 019, D1).
|
|
18
|
+
|
|
19
|
+
- `shade_sdk.runtime.ledger`: identidade do item por chave de negócio, o `incerto` gravado
|
|
20
|
+
antes de efetivar e o canal do registro com o executor.
|
|
21
|
+
- `shade_sdk.runtime.recovery`: classificação da falha, sessão perdida e modal inesperado.
|
|
22
|
+
- `shade_sdk.runtime.report`: relatório HTML da execução e o histórico.
|
|
23
|
+
- `shade_sdk.runtime.config`: `Settings` e a carga do `.env`.
|
|
24
|
+
- `shade_sdk.runtime.output`: a planilha de saída, com ou sem mascaramento de PII.
|
|
25
|
+
- `shade_sdk.runtime.protocolo`: o número e o formato do contrato robô ↔ executor.
|
|
26
|
+
|
|
27
|
+
Licença: Apache-2.0.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
shade_sdk/__init__.py,sha256=RXYux3ySNU2_Mx-rjzKi7s0j3e6VZuU6ldouSkbk0j8,271
|
|
2
|
+
shade_sdk/runtime/__init__.py,sha256=Q3XSeds7ABj4G_kNzSv8vs_kY-EkoejMcDa2D57QyWk,1170
|
|
3
|
+
shade_sdk/runtime/config.py,sha256=lV-jg4s7WJkaLZbQ-KLNiwX5pd_3YtmXhDWqHCiGwus,3148
|
|
4
|
+
shade_sdk/runtime/ledger.py,sha256=mAxbQCT7qF3zlEVd1PYOV4xoxEh7bu_TsuAzxMpOnRI,21658
|
|
5
|
+
shade_sdk/runtime/output.py,sha256=NFMZQwmh3hm5mjxud7n4gLz0nvY_BKVvYuabtewc6c8,5120
|
|
6
|
+
shade_sdk/runtime/protocolo.py,sha256=vHvdxrqRKByfoAV_aMmYThODt8j6DPlolOplkW8-NSo,2243
|
|
7
|
+
shade_sdk/runtime/recovery.py,sha256=I620_zkBQhkNVNVaBo164p-PHFRiz_JtihhaYCFokfQ,5065
|
|
8
|
+
shade_sdk/runtime/report.py,sha256=MspZLSEf2iG262_cQBpigy4ok0YV-4eQPtld-yIHNK0,12582
|
|
9
|
+
shade_sdk-0.1.0.dist-info/METADATA,sha256=Il8N2aoJZgxOQO1TZoyDs6YRBVz6XPn6bwDQCODoUaI,1205
|
|
10
|
+
shade_sdk-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
11
|
+
shade_sdk-0.1.0.dist-info/RECORD,,
|