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 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 &mdash; 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>&#x23F0; 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) &nbsp;|&nbsp; "
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 &middot; {_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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any