@noeva/analytics-core 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,7 +3,27 @@
3
3
  Le versioni del pacchetto che i repository dei progetti installano (ADR 0005). Prima di
4
4
  `analytics upgrade` si legge cosa cambia fra la versione installata e la nuova.
5
5
 
6
- ## 0.5.1 — 12 novembre 2026
6
+ ## 0.5.3 — 14 novembre 2026
7
+
8
+ - **`noeva-snapshot giro`**: un giro solo, senza la coda, per i server dove gira gia'
9
+ il worker NOEVA-ETL (ADR 0004, punto 4). Il processo lo
10
+ prende NOEVA-ETL e lancia questo con il connettore `processo-esterno`: log su stderr,
11
+ su stdout solo l'esito in JSON, exit code diverso da zero se non si e' pubblicato,
12
+ con il motivo nell'ultima riga di stderr. `noeva-snapshot` passa alla 0.3.0.
13
+ - `installa.ps1 -ConNoevaEtl`: installa e verifica come sempre, fa il primo giro con
14
+ `giro` e non registra l'attivita' pianificata (la toglie, se c'era da prima: due
15
+ worker si contenderebbero i processi del workspace). Con `-SoloControllo` controlla
16
+ che l'attivita' non ci sia.
17
+ - Per averli serve un rilascio dell'eseguibile degli snapshot del progetto
18
+ (`rilascio-windows.yml`, `cosa=snapshot`).
19
+
20
+ ## 0.5.2 — 12 novembre 2026
21
+
22
+ La 0.5.1 non e' mai arrivata sul registro (npm l'ha accettata e poi non l'ha resa
23
+ disponibile): la 0.5.2 porta lo stesso contenuto, piu' il README del pacchetto senza il
24
+ nome di un cliente e `test/nomi.test.js` che controlla anche README.md e package.json,
25
+ che npm pubblica sempre.
26
+
7
27
 
8
28
  - Il servizio MCP dei PC (`noeva-<id>.exe`) non si blocca piu' su una query lenta
9
29
  (11/11/2026). Gli strumenti di Wren erano sincroni e giravano nell'event loop:
package/README.md CHANGED
@@ -5,7 +5,7 @@ i moduli di pagina, gli strumenti dello snapshot, il kit dell'agente e il modell
5
5
  repository. La forma e' quella di `@noeva/etl-core` (noeva-etl, ADR-0093 e 0094).
6
6
 
7
7
  La fonte resta `lib/`: `prepara.js` ne copia in `contenuto/` (gitignorata) i file che il
8
- pacchetto porta, a ogni `npm pack` e a ogni prova. Questo README non va nel pacchetto.
8
+ pacchetto porta, a ogni `npm pack` e a ogni prova. Questo README npm lo pubblica comunque, come la pagina del pacchetto.
9
9
 
10
10
  ```bash
11
11
  node lib/core/prepara.js && node --test lib/core/test/*.test.js # le prove, anche in CI
@@ -13,9 +13,10 @@ cd lib/core && npm pack --dry-run # cosa pubbli
13
13
  python3 lib/rilascio/estrai-progetto.py <id> <cartella vuota> # un progetto nel suo repository
14
14
  ```
15
15
 
16
- - **Il pacchetto non nomina clienti**: `test/nomi.test.js` lo prova contro il registro.
17
- Un commento in `lib/pagina` o `lib/snapshot` che cita un cliente arriva a tutti.
18
- - **La CI del repository nuovo puo' non partire**: con OIKI (6/11/2026) GitHub ha registrato
16
+ - **Il pacchetto non nomina clienti**: `test/nomi.test.js` lo prova contro il registro, anche
17
+ su questo README, che npm pubblica sempre. Un commento in `lib/pagina` o `lib/snapshot`
18
+ che cita un cliente arriva a tutti.
19
+ - **La CI del repository nuovo puo' non partire**: con il primo progetto estratto (6/11/2026) GitHub ha registrato
19
20
  `ci.yml`, arrivato col push che crea il repository, ma non partiva su push e PR, solo a mano.
20
21
  Si e' sbloccato al primo push di un commit che cambiava `ci.yml`, anche su un branch di
21
22
  prova poi cancellato. Dopo il primo push si controlla che la PR abbia il check `verifica`.
@@ -11,6 +11,9 @@ del worker noeva-etl; il server parla solo in uscita, verso NOEVA.
11
11
  noeva-snapshot ascolta --config worker.json resta in ascolto (servizio)
12
12
  noeva-snapshot ascolta --config worker.json --una-volta esegue cio' che c'e' in coda ed esce
13
13
  (l'attivita' pianificata di Windows)
14
+ noeva-snapshot giro --config worker.json --pipeline <nome>
15
+ un giro solo, senza coda: lo lancia il worker NOEVA-ETL dove
16
+ il server ha anche altre pipeline ETL (vedi giro())
14
17
 
15
18
  La chiave NOEVA del worker sta in NOEVA_API_KEY, mai nel file: e' la chiave di un
16
19
  utente di servizio membro del workspace. worker.json (percorsi relativi al file):
@@ -25,6 +28,7 @@ quando c'e' un giro da fare.
25
28
  """
26
29
  from __future__ import annotations
27
30
 
31
+ import contextlib
28
32
  import datetime as dt
29
33
  import json
30
34
  import os
@@ -122,39 +126,65 @@ def riassunto(report: dict | None) -> dict:
122
126
  }
123
127
 
124
128
 
129
+ def cartella_del_giro(conf: dict, progetto: str, etichetta: str) -> pathlib.Path:
130
+ return conf["lavoro"] / progetto / f"{dt.datetime.now().strftime('%Y%m%dT%H%M%S')}-{etichetta[:8]}"
131
+
132
+
133
+ def genera_e_pubblica(p: dict, conf: dict, chiave: str, workspace: str, cartella: pathlib.Path, *,
134
+ esporta_fn=esporta_pacchetto, pubblica_fn=pubblica.pubblica_versione,
135
+ biglietti_fn=pubblica.biglietto_dall_api, dopo_export=None) -> tuple[dict, dict | None]:
136
+ """Il cuore di un giro, con o senza coda: snapshot, biglietto, pubblicazione.
137
+ Restituisce l'esito e il report del giro; solleva se qualcosa non va (allora
138
+ non si e' pubblicato niente, o la pubblicazione e' a meta' e il puntatore no)."""
139
+ progetto = p["progetto"]
140
+ dati, report_path = cartella / "dati", cartella / "giro.json"
141
+ dati.mkdir(parents=True, exist_ok=True)
142
+ inizio = time.time()
143
+ esporta_fn(p["pacchetto"], p["connessione"], dati, report_path)
144
+ report = json.loads(report_path.read_text(encoding="utf-8")) if report_path.is_file() else None
145
+ if dopo_export:
146
+ dopo_export()
147
+ da = f"{socket.gethostname()} noeva-snapshot"
148
+ esito = pubblica_fn(
149
+ progetto, dati, conf["contenitore"].rstrip("/"),
150
+ lambda versione: biglietti_fn(conf["api"], chiave, workspace, progetto, versione, da),
151
+ stampa=log)
152
+ uscita = {
153
+ "progetto": progetto, "versione": esito["versione"], "data_del_dato": esito.get("data_del_dato"),
154
+ "file": len(esito.get("file", [])), "byte": esito.get("byte"),
155
+ "secondi": round(time.time() - inizio, 1), **riassunto(report),
156
+ }
157
+ return uscita, report
158
+
159
+
160
+ def report_dalla_cartella(cartella: pathlib.Path) -> dict | None:
161
+ """Il report di un giro fallito, se l'export e' arrivato a scriverlo."""
162
+ f = cartella / "giro.json"
163
+ try:
164
+ return json.loads(f.read_text(encoding="utf-8")) if f.is_file() else None
165
+ except ValueError:
166
+ return None
167
+
168
+
125
169
  def esegui(lavoro: dict, conf: dict, api: ApiNoeva, *, esporta_fn=esporta_pacchetto,
126
170
  pubblica_fn=pubblica.pubblica_versione, biglietti_fn=pubblica.biglietto_dall_api) -> bool:
127
- """Un giro: snapshot, biglietto, pubblicazione, esito sul processo. True se pubblicato."""
171
+ """Un giro preso dalla coda: snapshot, biglietto, pubblicazione, esito sul processo. True se pubblicato."""
128
172
  processo, pipeline = lavoro["processId"], lavoro["pipeline"]
129
173
  p = conf["pipeline"][pipeline]
130
174
  progetto = p["progetto"]
131
- cartella = conf["lavoro"] / progetto / f"{dt.datetime.now().strftime('%Y%m%dT%H%M%S')}-{processo[:8]}"
132
- dati, report_path = cartella / "dati", cartella / "giro.json"
133
- dati.mkdir(parents=True, exist_ok=True)
175
+ cartella = cartella_del_giro(conf, progetto, processo)
134
176
  log(f"{pipeline}: processo {processo}, giro in {cartella}")
135
177
  inizio = time.time()
136
- report = None
137
178
  try:
138
179
  api.avanza(processo, status="running", progress=5)
139
- esporta_fn(p["pacchetto"], p["connessione"], dati, report_path)
140
- report = json.loads(report_path.read_text(encoding="utf-8")) if report_path.is_file() else None
141
- api.avanza(processo, status="running", progress=70)
142
-
143
- da = f"{socket.gethostname()} noeva-snapshot"
144
- esito = pubblica_fn(
145
- progetto, dati, conf["contenitore"].rstrip("/"),
146
- lambda versione: biglietti_fn(conf["api"], api.chiave, api.workspace, progetto, versione, da),
147
- stampa=log)
148
- uscita = {
149
- "progetto": progetto, "versione": esito["versione"], "data_del_dato": esito.get("data_del_dato"),
150
- "file": len(esito.get("file", [])), "byte": esito.get("byte"),
151
- "secondi": round(time.time() - inizio, 1), **riassunto(report),
152
- }
180
+ uscita, report = genera_e_pubblica(
181
+ p, conf, api.chiave, api.workspace, cartella, esporta_fn=esporta_fn, pubblica_fn=pubblica_fn,
182
+ biglietti_fn=biglietti_fn, dopo_export=lambda: api.avanza(processo, status="running", progress=70))
153
183
  testo_report = json.dumps(report, default=str) if report else ""
154
184
  if report and len(testo_report) <= MAX_REPORT_BYTE:
155
185
  uscita["giro"] = report
156
186
  api.avanza(processo, status="completed", progress=100, outputData=uscita, completedAt=adesso())
157
- log(f"{pipeline}: pubblicata {esito['versione']} in {uscita['secondi']}s")
187
+ log(f"{pipeline}: pubblicata {uscita['versione']} in {uscita['secondi']}s")
158
188
  return True
159
189
  except (Exception, SystemExit) as e: # noqa: BLE001 - qualunque errore chiude il processo come fallito
160
190
  messaggio = str(e).strip() or e.__class__.__name__
@@ -162,7 +192,7 @@ def esegui(lavoro: dict, conf: dict, api: ApiNoeva, *, esporta_fn=esporta_pacche
162
192
  try:
163
193
  api.avanza(processo, status="failed", errorMessage=messaggio[:2000],
164
194
  outputData={"progetto": progetto, "secondi": round(time.time() - inizio, 1),
165
- **riassunto(report)},
195
+ **riassunto(report_dalla_cartella(cartella))},
166
196
  completedAt=adesso())
167
197
  except Exception as e2: # noqa: BLE001 - l'API giu': il processo resta running, lo si vede da NOEVA
168
198
  log(f"{pipeline}: non riesco a scrivere l'esito sul processo: {e2}")
@@ -171,6 +201,61 @@ def esegui(lavoro: dict, conf: dict, api: ApiNoeva, *, esporta_fn=esporta_pacche
171
201
  pulisci(conf["lavoro"] / progetto)
172
202
 
173
203
 
204
+ def giro(argv: list[str], *, esporta_fn=esporta_pacchetto, pubblica_fn=pubblica.pubblica_versione,
205
+ biglietti_fn=pubblica.biglietto_dall_api) -> int:
206
+ """`noeva-snapshot giro --config worker.json --pipeline <nome>`: un giro solo, senza la coda.
207
+
208
+ E' il comando che lancia il worker NOEVA-ETL dove sullo stesso server girano
209
+ anche altre pipeline ETL (ADR 0004, punto 4): il processo lo prende lui, con il
210
+ suo claim, e chiama questo con il connettore `processo-esterno` (ADR-0096 di
211
+ noeva-etl). Il contratto e' quello del connettore: i log vanno su stderr, su
212
+ stdout solo l'esito come un unico documento JSON, e l'exit code dice se si e'
213
+ pubblicato. La chiave sta in NOEVA_API_KEY e la password del database nella
214
+ variabile di connessione.json, passate dal worker per nome."""
215
+ def opzione(nome: str) -> str:
216
+ if nome not in argv or argv.index(nome) + 1 >= len(argv):
217
+ raise SystemExit(f"giro: serve {nome}")
218
+ return argv[argv.index(nome) + 1]
219
+
220
+ conf = leggi_config(pathlib.Path(opzione("--config")))
221
+ pipeline = opzione("--pipeline")
222
+ if pipeline not in conf["pipeline"]:
223
+ raise SystemExit(f"giro: {pipeline} non e' in worker.json ({', '.join(sorted(conf['pipeline']))})")
224
+ chiave = os.environ.get("NOEVA_API_KEY")
225
+ if not chiave:
226
+ raise SystemExit("manca NOEVA_API_KEY: la chiave NOEVA dell'utente di servizio del worker")
227
+ p = conf["pipeline"][pipeline]
228
+ cartella = cartella_del_giro(conf, p["progetto"], "giro")
229
+
230
+ # Durante il giro stdout e' stderr, anche per il motore e i driver che scrivono
231
+ # direttamente sul descrittore 1: su stdout resta solo il JSON finale.
232
+ sys.stdout.flush()
233
+ stdout_vero = os.dup(1)
234
+ os.dup2(2, 1)
235
+ uscita = None
236
+ try:
237
+ with contextlib.redirect_stdout(sys.stderr):
238
+ log(f"{pipeline}: giro lanciato dal worker, in {cartella}")
239
+ try:
240
+ uscita, _ = genera_e_pubblica(p, conf, chiave, conf["workspace"], cartella,
241
+ esporta_fn=esporta_fn, pubblica_fn=pubblica_fn,
242
+ biglietti_fn=biglietti_fn)
243
+ uscita["cartella"] = str(cartella)
244
+ log(f"{pipeline}: pubblicata {uscita['versione']} in {uscita['secondi']}s")
245
+ except (Exception, SystemExit) as e: # noqa: BLE001 - l'ultima riga di stderr e' il motivo
246
+ log(f"{pipeline}: FALLITO, {str(e).strip() or e.__class__.__name__}")
247
+ finally:
248
+ pulisci(conf["lavoro"] / p["progetto"])
249
+ finally:
250
+ sys.stderr.flush()
251
+ os.dup2(stdout_vero, 1)
252
+ os.close(stdout_vero)
253
+ if uscita is None:
254
+ return 1
255
+ print(json.dumps(uscita, default=str), flush=True)
256
+ return 0
257
+
258
+
174
259
  def pulisci(cartella_progetto: pathlib.Path) -> None:
175
260
  giri = sorted((d for d in cartella_progetto.iterdir() if d.is_dir()), key=lambda d: d.name)
176
261
  for vecchio in giri[:-GIRI_TENUTI]:
@@ -12,6 +12,7 @@ da `./aggiorna.sh snapshot`.
12
12
  [--senza-controlli]
13
13
  noeva-snapshot pubblica <id> <dir dati> --url <contenitore>
14
14
  noeva-snapshot ascolta --config <worker.json> [--una-volta] [--intervallo <s>]
15
+ noeva-snapshot giro --config <worker.json> --pipeline <nome> (lo lancia NOEVA-ETL: JSON su stdout)
15
16
  noeva-snapshot verifica --pacchetto <dir> --connessione <file.json>
16
17
  noeva-snapshot versione
17
18
  """
@@ -25,7 +26,7 @@ import ascolta
25
26
  import esporta
26
27
  import pubblica
27
28
 
28
- VERSIONE = "0.2.1"
29
+ VERSIONE = "0.3.0"
29
30
 
30
31
 
31
32
  def _opzione(argv: list[str], nome: str) -> str | None:
@@ -97,10 +98,12 @@ def main(argv: list[str]) -> int:
97
98
  return verifica(resto)
98
99
  if comando == "ascolta":
99
100
  return ascolta.main(["ascolta", *resto])
101
+ if comando == "giro":
102
+ return ascolta.giro(resto)
100
103
  if comando == "versione":
101
104
  print(json.dumps({"runner": VERSIONE, "python": sys.version.split()[0]}))
102
105
  return 0
103
- raise SystemExit(f"comando sconosciuto: {comando} (esporta, pubblica, ascolta, verifica, versione)")
106
+ raise SystemExit(f"comando sconosciuto: {comando} (esporta, pubblica, ascolta, giro, verifica, versione)")
104
107
 
105
108
 
106
109
  if __name__ == "__main__":
@@ -3,6 +3,8 @@
3
3
  #
4
4
  # powershell -ExecutionPolicy Bypass -File .\installa.ps1 installa o aggiorna
5
5
  # powershell -ExecutionPolicy Bypass -File .\installa.ps1 -SoloControllo controlla e basta (dopo un riavvio)
6
+ # ... -ConNoevaEtl dove sul server gira gia' il worker NOEVA-ETL (ADR 0004, punto 4): i giri
7
+ # li prende lui e lancia `noeva-snapshot giro`; nessuna attivita' pianificata
6
8
  #
7
9
  # Si lancia dalla cartella estratta dallo zip, ENTRANDO IN WINDOWS COME L'UTENTE CHE FARA'
8
10
  # GIRARE IL WORKER: le chiavi vanno nelle sue variabili d'ambiente. La copia in
@@ -12,7 +14,7 @@
12
14
  # pianificata prende il posto (ogni {{minuti}} minuti, ascolta --una-volta).
13
15
  # Nessun segreto finisce a schermo o nel file dell'esito.
14
16
 
15
- param([switch]$SoloControllo)
17
+ param([switch]$SoloControllo, [switch]$ConNoevaEtl)
16
18
 
17
19
  $ErrorActionPreference = 'Stop'
18
20
  try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch { }
@@ -266,12 +268,24 @@ else {
266
268
  }
267
269
 
268
270
  # 6. Il primo giro ------------------------------------------------------------------------------
269
- if ($SoloControllo) {
271
+ if ($SoloControllo -and $ConNoevaEtl) {
272
+ Esito 'ultimo giro' 'saltato' "i giri li lancia NOEVA-ETL: l'esito sta nei Processi del workspace ($pipeline)"
273
+ } elseif ($SoloControllo) {
270
274
  $log = Join-Path $destinazione 'lavoro\ascolta.log'
271
275
  if (Test-Path $log) {
272
276
  $ultima = Get-Content $log -Tail 40 | Where-Object { $_ -match 'pubblicata|FALLITO' } | Select-Object -Last 1
273
277
  Esito 'ultimo giro' $(if ($ultima -match 'FALLITO') { 'NO' } elseif ($ultima) { 'ok' } else { 'avviso' }) $(if ($ultima) { $ultima.Trim() } else { 'nessun giro ancora in ascolta.log' })
274
278
  } else { Esito 'ultimo giro' 'avviso' 'ascolta.log non c''e'' ancora' }
279
+ } elseif ($ConNoevaEtl) {
280
+ Passo '6. Il primo giro, adesso'
281
+ Write-Host ' Lo stesso comando che lancera'' NOEVA-ETL, qui davanti e senza passare dalla coda.'
282
+ if (Chiedi 'Faccio partire adesso il primo giro (circa 10 minuti)?') {
283
+ $g = EseguiDalVivo $exe @('giro', '--config', (Join-Path $destinazione 'worker.json'), '--pipeline', $pipeline)
284
+ $pubblicata = [regex]::Match($g.Testo, ': pubblicata (\S+) in')
285
+ $fallito = @($g.Testo -split "`n" | Where-Object { $_ -match 'FALLITO' }) | Select-Object -First 1
286
+ if ($g.Codice -eq 0 -and $pubblicata.Success) { Esito 'primo giro' 'ok' "pubblicata la versione $($pubblicata.Groups[1].Value)" }
287
+ else { Esito 'primo giro' 'NO' $(if ($fallito) { $fallito.Trim() } else { "uscita $($g.Codice)" }) }
288
+ } else { Esito 'primo giro' 'saltato' 'lo fara'' NOEVA-ETL alla prossima schedulazione' }
275
289
  } else {
276
290
  Passo '6. Il primo giro, adesso'
277
291
  if (Chiedi 'Faccio partire adesso il primo giro (circa 4 minuti)?') {
@@ -299,7 +313,16 @@ if ($SoloControllo) {
299
313
 
300
314
  # 7. L'attivita' pianificata --------------------------------------------------------------------
301
315
  Passo "7. L'attivita' pianificata '$attivita'"
302
- if ($SoloControllo) {
316
+ if ($ConNoevaEtl) {
317
+ # Con NOEVA-ETL sul server un secondo worker in ascolto si contenderebbe i processi
318
+ # con lui: l'attivita' non deve esserci, e se c'e' da un'installazione di prima si toglie.
319
+ $t = Get-ScheduledTask -TaskName $attivita -ErrorAction SilentlyContinue
320
+ if ($t -and $SoloControllo) { Esito 'attivita''' 'NO' 'c''e'' ancora: con NOEVA-ETL va tolta (rilancia senza -SoloControllo)' }
321
+ elseif ($t) {
322
+ Unregister-ScheduledTask -TaskName $attivita -Confirm:$false
323
+ Esito 'attivita''' 'ok' "tolta: i giri di $pipeline li lancia NOEVA-ETL"
324
+ } else { Esito 'attivita''' 'ok' "nessuna: i giri di $pipeline li lancia NOEVA-ETL" }
325
+ } elseif ($SoloControllo) {
303
326
  $t = Get-ScheduledTask -TaskName $attivita -ErrorAction SilentlyContinue
304
327
  if (-not $t) { Esito 'attivita''' 'NO' 'non installata: rilancia senza -SoloControllo' }
305
328
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noeva/analytics-core",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Gli strumenti di un progetto NOEVA Analytics nel suo repository: moduli di pagina, snapshot, verifica dello standard e kit dell'agente (ADR 0005).",
5
5
  "type": "module",
6
6
  "bin": {