hobots 1.3.2 → 1.3.4

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/README.md CHANGED
@@ -48,7 +48,7 @@ Hobots.init({
48
48
  });
49
49
 
50
50
  Hobots.register('processar-nfe', async (params, ctx) => {
51
- ctx.log('info', 'começando…');
51
+ ctx.log.info('começando…');
52
52
  return { processadas: 12 };
53
53
  });
54
54
 
@@ -235,9 +235,9 @@ Hobots.init({
235
235
  });
236
236
 
237
237
  Hobots.register('processar-nfe', async (params, ctx) => {
238
- ctx.log('info', `processando lote ${params.lote}…`);
238
+ ctx.log.info(`processando lote ${params.lote}…`);
239
239
  if (ctx.signal.aborted) return; // cancelamento cooperativo
240
- ctx.log('info', '12 notas processadas');
240
+ ctx.log.info('12 notas processadas');
241
241
  });
242
242
 
243
243
  Hobots.start(); // começa o polling e MANTÉM O PROCESSO VIVO
@@ -277,7 +277,7 @@ interface RunContext {
277
277
  ```
278
278
 
279
279
  - **`params`** — os dados do formulário configurado no app (JSON validado). Tipável: `Hobots.register<{ lote: number }>(...)`.
280
- - **`ctx.log(level, message)`** — logs enviados **incrementalmente** ao app (PATCHes com throttle), visíveis na run quase em tempo real.
280
+ - **`ctx.log.<level>(message)`** — logs enviados **incrementalmente** ao app (PATCHes com throttle), visíveis na run quase em tempo real.
281
281
  - **`ctx.signal`** — `AbortSignal` que dispara quando a run é **cancelada no app**, em **timeout** ou no **shutdown**.
282
282
  - **Retorno** — **ignorado**: o SDK não captura nem envia o valor retornado pelo handler (resultados podem conter dados sensíveis e não são coletados).
283
283
  - **Erro lançado** — a run é reportada como `failed` (mensagem cap 10k, stack 20k).
@@ -292,7 +292,7 @@ Uma run cancelada no app **não mata** o handler — o SDK apenas dispara `ctx.s
292
292
  Hobots.register('processar-nfe', async (params, ctx) => {
293
293
  for (const nota of params.notas) {
294
294
  if (ctx.signal.aborted) {
295
- ctx.log('warning', 'cancelamento detectado — parando');
295
+ ctx.log.warn('cancelamento detectado — parando');
296
296
  return;
297
297
  }
298
298
  await processar(nota);
package/dist/index.cjs CHANGED
@@ -111,7 +111,7 @@ function parseStacktrace(stack) {
111
111
 
112
112
  // src/utils.ts
113
113
  var SDK_NAME = "hobots";
114
- var SDK_VERSION = true ? "1.3.2" : "0.0.0-dev";
114
+ var SDK_VERSION = true ? "1.3.3" : "0.0.0-dev";
115
115
  function isNode() {
116
116
  return typeof process !== "undefined" && typeof process.versions?.node === "string";
117
117
  }
@@ -433,28 +433,61 @@ var Heartbeat = class {
433
433
  }
434
434
  };
435
435
 
436
+ // src/internal-handlers.ts
437
+ var INTERNAL = /* @__PURE__ */ Symbol.for("hobots.sdk.internalListener");
438
+ function markInternal(listener) {
439
+ listener[INTERNAL] = true;
440
+ return listener;
441
+ }
442
+ function externalListenerCount(event) {
443
+ const listeners = process.listeners(event);
444
+ return listeners.filter((listener) => !listener[INTERNAL]).length;
445
+ }
446
+ var emergencyTasks = [];
447
+ function addEmergencyTask(task) {
448
+ emergencyTasks.push(task);
449
+ }
450
+ async function awaitEmergencyTasks(capMs) {
451
+ if (emergencyTasks.length === 0) return;
452
+ await Promise.race([Promise.allSettled(emergencyTasks), sleep(capMs)]);
453
+ }
454
+
436
455
  // src/instrument.ts
456
+ var EMERGENCY_REPORT_CAP_MS = 3e3;
437
457
  var installed = false;
458
+ function hasInstalledGlobalHandlers() {
459
+ return installed;
460
+ }
438
461
  function installGlobalHandlers(client2, getScope) {
439
462
  if (installed) return;
440
463
  installed = true;
441
464
  if (isNode()) {
442
- process.on("uncaughtException", (error) => {
443
- client2.captureException(error, { level: "fatal", tags: { mechanism: "uncaughtException" } }, getScope());
444
- void client2.flush(2e3).then(() => {
445
- if (process.listenerCount("uncaughtException") <= 1) {
446
- console.error(error);
447
- process.exit(1);
448
- }
449
- });
450
- });
451
- process.on("unhandledRejection", (reason) => {
452
- client2.captureException(reason, { level: "error", tags: { mechanism: "unhandledRejection" } }, getScope());
453
- void client2.flush(2e3);
454
- });
455
- process.on("beforeExit", () => {
456
- void client2.flush(2e3);
457
- });
465
+ process.on(
466
+ "uncaughtException",
467
+ markInternal((error) => {
468
+ client2.captureException(error, { level: "fatal", tags: { mechanism: "uncaughtException" } }, getScope());
469
+ void client2.flush(2e3).then(async () => {
470
+ if (externalListenerCount("uncaughtException") === 0) {
471
+ await awaitEmergencyTasks(EMERGENCY_REPORT_CAP_MS);
472
+ console.error(error);
473
+ process.exit(1);
474
+ }
475
+ });
476
+ })
477
+ );
478
+ process.on(
479
+ "unhandledRejection",
480
+ markInternal((reason) => {
481
+ client2.captureException(reason, { level: "error", tags: { mechanism: "unhandledRejection" } }, getScope());
482
+ void client2.flush(2e3);
483
+ })
484
+ );
485
+ process.on(
486
+ "beforeExit",
487
+ markInternal(() => {
488
+ void client2.flush(2e3);
489
+ })
490
+ );
458
491
  return;
459
492
  }
460
493
  if (typeof window !== "undefined") {
@@ -750,25 +783,76 @@ var ManagerApi = class {
750
783
  await sleep(delay);
751
784
  }
752
785
  }
786
+ /**
787
+ * Um único PATCH com timeout curto — exit path (o processo está morrendo, o retry
788
+ * ladder do updateRun é proibido aqui). Status HTTP é ignorado: 409/404 significa
789
+ * que a run já foi cancelada/finalizada, o que é aceitável nesse caminho.
790
+ */
791
+ async updateRunOnce(runId, update, timeoutMs = 2e3) {
792
+ await fetch(`${this.baseUrl}/runs/${encodeURIComponent(runId)}`, {
793
+ method: "PATCH",
794
+ headers: this.headers(),
795
+ body: JSON.stringify(update),
796
+ signal: AbortSignal.timeout(timeoutMs)
797
+ });
798
+ }
753
799
  };
754
800
 
755
801
  // src/manager/context.ts
802
+ function runCanceledError() {
803
+ return Object.assign(new Error("execu\xE7\xE3o cancelada"), { name: "RunCanceled" });
804
+ }
805
+ function cancelableSleep(ms, signal) {
806
+ return new Promise((resolve, reject) => {
807
+ if (signal.aborted) {
808
+ reject(runCanceledError());
809
+ return;
810
+ }
811
+ const onAbort = () => {
812
+ clearTimeout(timer);
813
+ reject(runCanceledError());
814
+ };
815
+ const timer = setTimeout(() => {
816
+ signal.removeEventListener("abort", onAbort);
817
+ resolve();
818
+ }, ms);
819
+ signal.addEventListener("abort", onAbort, { once: true });
820
+ });
821
+ }
756
822
  var DEFAULT_LOG_FLUSH_INTERVAL_MS = 2e3;
823
+ var DEFAULT_CANCEL_CHECK_INTERVAL_MS = 5e3;
824
+ var MIN_CANCEL_CHECK_INTERVAL_MS = 1e3;
825
+ var MAX_CANCEL_CHECK_INTERVAL_MS = 15e3;
757
826
  var MAX_LOG_MESSAGE_CHARS = 1e4;
758
827
  var MAX_LOGS_PER_RUN = 500;
759
828
  var MAX_LOGS_PER_PATCH = 100;
760
829
  var FLUSH_AT_BUFFERED = 20;
761
830
  var RunReporter = class {
762
- constructor(api, runId, task, logger, flushIntervalMs) {
831
+ constructor(api, runId, task, logger, flushIntervalMs, cancelCheckIntervalMs) {
763
832
  this.api = api;
764
833
  this.runId = runId;
765
834
  this.logger = logger;
766
835
  this.flushIntervalMs = flushIntervalMs ?? DEFAULT_LOG_FLUSH_INTERVAL_MS;
836
+ this.cancelCheckIntervalMs = Math.min(
837
+ MAX_CANCEL_CHECK_INTERVAL_MS,
838
+ Math.max(MIN_CANCEL_CHECK_INTERVAL_MS, cancelCheckIntervalMs ?? DEFAULT_CANCEL_CHECK_INTERVAL_MS)
839
+ );
840
+ const log = {
841
+ debug: (message) => this.log("debug", message),
842
+ info: (message) => this.log("info", message),
843
+ warn: (message) => this.log("warning", message),
844
+ error: (message) => this.log("error", message)
845
+ };
846
+ const signal = this.abortController.signal;
767
847
  this.context = {
768
848
  runId,
769
849
  task,
770
- log: (level, message) => this.log(level, message),
771
- signal: this.abortController.signal
850
+ log,
851
+ signal,
852
+ throwIfCanceled: () => {
853
+ if (signal.aborted) throw runCanceledError();
854
+ },
855
+ sleep: (ms) => cancelableSleep(ms, signal)
772
856
  };
773
857
  }
774
858
  context;
@@ -777,16 +861,31 @@ var RunReporter = class {
777
861
  droppedCount = 0;
778
862
  dead = false;
779
863
  chain = Promise.resolve();
864
+ pendingUpdates = 0;
780
865
  timer;
866
+ watchTimer;
781
867
  abortController = new AbortController();
782
868
  flushIntervalMs;
869
+ cancelCheckIntervalMs;
783
870
  get isDead() {
784
871
  return this.dead;
785
872
  }
786
873
  /** Aborta o ctx.signal sem matar o reporter (timeout/shutdown ainda reportam o terminal). */
787
874
  abort() {
875
+ this.stopCancelWatch();
788
876
  if (!this.abortController.signal.aborted) this.abortController.abort();
789
877
  }
878
+ /**
879
+ * Mata o reporter de vez: aborta o signal e suprime todo reporte futuro.
880
+ * Usado no exit path — depois que o agente reportou AgentTerminated por fora
881
+ * da fila, um handler zumbi não pode reenviar status por cima.
882
+ */
883
+ kill() {
884
+ this.abort();
885
+ this.dead = true;
886
+ this.clearFlushTimer();
887
+ this.buffer = [];
888
+ }
790
889
  log(level, message) {
791
890
  if (this.dead) return;
792
891
  if (this.loggedCount >= MAX_LOGS_PER_RUN) {
@@ -841,22 +940,45 @@ var RunReporter = class {
841
940
  async reportRunning() {
842
941
  this.enqueue({ status: "running" });
843
942
  await this.chain;
943
+ if (!this.dead) this.startCancelWatch();
844
944
  return !this.dead;
845
945
  }
846
946
  reportSuccess() {
947
+ this.stopCancelWatch();
847
948
  this.clearFlushTimer();
848
949
  this.enqueue({ status: "succeeded", logs: this.takeLogs() });
849
950
  }
850
951
  reportFailure(error) {
952
+ this.stopCancelWatch();
851
953
  this.clearFlushTimer();
852
954
  this.enqueue({ status: "failed", error, logs: this.takeLogs() });
853
955
  }
956
+ /**
957
+ * Heartbeat de cancelamento: enquanto o handler roda, um PATCH 'running' vazio
958
+ * periódico faz o servidor responder 409 se a run foi cancelada no app — o que
959
+ * mata o reporter e dispara ctx.signal, mesmo em handlers que nunca logam.
960
+ */
961
+ startCancelWatch() {
962
+ if (this.watchTimer) return;
963
+ this.watchTimer = setInterval(() => {
964
+ if (this.dead || this.pendingUpdates > 0 || this.buffer.length > 0) return;
965
+ this.enqueue({ status: "running" });
966
+ }, this.cancelCheckIntervalMs);
967
+ unrefTimer(this.watchTimer);
968
+ }
969
+ stopCancelWatch() {
970
+ if (this.watchTimer) {
971
+ clearInterval(this.watchTimer);
972
+ this.watchTimer = void 0;
973
+ }
974
+ }
854
975
  /** Aguarda a fila de PATCHes esvaziar (chame depois do terminal). */
855
976
  finalize() {
856
977
  return this.chain;
857
978
  }
858
979
  enqueue(update) {
859
980
  if (this.dead) return;
981
+ this.pendingUpdates++;
860
982
  this.chain = this.chain.then(async () => {
861
983
  if (this.dead) return;
862
984
  const payload = update.logs && update.logs.length === 0 ? { ...update, logs: void 0 } : update;
@@ -865,6 +987,7 @@ var RunReporter = class {
865
987
  } catch (error) {
866
988
  if (error instanceof ManagerApiError && (error.status === 409 || error.status === 404)) {
867
989
  this.dead = true;
990
+ this.stopCancelWatch();
868
991
  this.abort();
869
992
  this.buffer = [];
870
993
  this.logger.warn(`run ${this.runId} cancelada/removida no servidor \u2014 reporte suprimido`);
@@ -872,10 +995,56 @@ var RunReporter = class {
872
995
  }
873
996
  this.logger.error(`update da run ${this.runId} perdido:`, error);
874
997
  }
998
+ }).finally(() => {
999
+ this.pendingUpdates--;
875
1000
  });
876
1001
  }
877
1002
  };
878
1003
 
1004
+ // src/manager/shutdown.ts
1005
+ var WATCHED_SIGNALS = ["SIGINT", "SIGTERM"];
1006
+ function installShutdownHandlers(agent2) {
1007
+ if (!isNode()) return () => {
1008
+ };
1009
+ let signaled = false;
1010
+ const signalHandlers = /* @__PURE__ */ new Map();
1011
+ for (const signal of WATCHED_SIGNALS) {
1012
+ const handler = markInternal(() => {
1013
+ if (externalListenerCount(signal) > 0) return;
1014
+ if (signaled) return;
1015
+ signaled = true;
1016
+ const reraise = () => {
1017
+ process.removeListener(signal, handler);
1018
+ process.kill(process.pid, signal);
1019
+ };
1020
+ if (agent2.activeCount === 0) {
1021
+ reraise();
1022
+ return;
1023
+ }
1024
+ const report = agent2.reportAgentTerminated(`o processo do agente recebeu ${signal} durante a execu\xE7\xE3o`);
1025
+ addEmergencyTask(report);
1026
+ void report.finally(reraise);
1027
+ });
1028
+ process.on(signal, handler);
1029
+ signalHandlers.set(signal, handler);
1030
+ }
1031
+ const onFatal = markInternal((error) => {
1032
+ const report = agent2.activeCount > 0 ? agent2.reportAgentTerminated("o processo do agente morreu com um erro n\xE3o tratado durante a execu\xE7\xE3o") : Promise.resolve();
1033
+ addEmergencyTask(report);
1034
+ if (!hasInstalledGlobalHandlers() && externalListenerCount("uncaughtException") === 0) {
1035
+ void report.finally(() => {
1036
+ console.error(error);
1037
+ process.exit(1);
1038
+ });
1039
+ }
1040
+ });
1041
+ process.on("uncaughtException", onFatal);
1042
+ return () => {
1043
+ for (const [signal, handler] of signalHandlers) process.removeListener(signal, handler);
1044
+ process.removeListener("uncaughtException", onFatal);
1045
+ };
1046
+ }
1047
+
879
1048
  // src/manager/agent.ts
880
1049
  var DEFAULT_POLL_INTERVAL_MS = 5e3;
881
1050
  var MIN_POLL_INTERVAL_MS = 1e3;
@@ -898,6 +1067,7 @@ var ManagerAgent = class {
898
1067
  this.instanceId = options.instanceId;
899
1068
  this.taskTimeoutMs = options.taskTimeoutMs;
900
1069
  this.logFlushIntervalMs = options.logFlushIntervalMs;
1070
+ this.cancelCheckIntervalMs = options.cancelCheckIntervalMs;
901
1071
  this.logger.debug("agente configurado", { baseUrl: config.managerBaseUrl });
902
1072
  }
903
1073
  logger;
@@ -907,6 +1077,7 @@ var ManagerAgent = class {
907
1077
  instanceId;
908
1078
  taskTimeoutMs;
909
1079
  logFlushIntervalMs;
1080
+ cancelCheckIntervalMs;
910
1081
  state = "idle";
911
1082
  timer;
912
1083
  pollAbort;
@@ -914,6 +1085,7 @@ var ManagerAgent = class {
914
1085
  consecutiveAuthFailures = 0;
915
1086
  activeRuns = /* @__PURE__ */ new Set();
916
1087
  reporters = /* @__PURE__ */ new Set();
1088
+ uninstallShutdown;
917
1089
  get activeCount() {
918
1090
  return this.activeRuns.size;
919
1091
  }
@@ -926,6 +1098,9 @@ var ManagerAgent = class {
926
1098
  throw new Error("[hobots-manager] nenhuma task registrada \u2014 chame register() antes de start()");
927
1099
  }
928
1100
  this.state = "started";
1101
+ if (this.options.installExitHandlers !== false && !this.uninstallShutdown) {
1102
+ this.uninstallShutdown = installShutdownHandlers(this);
1103
+ }
929
1104
  this.logger.debug(`polling iniciado (tasks: ${[...this.registry.keys()].join(", ")})`);
930
1105
  this.schedule(0);
931
1106
  }
@@ -942,11 +1117,32 @@ var ManagerAgent = class {
942
1117
  const finished = timeoutMs > 0 ? await Promise.race([all, sleep(timeoutMs).then(() => false)]) : false;
943
1118
  if (!finished && this.activeRuns.size > 0) {
944
1119
  this.logger.warn(`${this.activeRuns.size} run(s) ainda em execu\xE7\xE3o ap\xF3s ${timeoutMs}ms \u2014 abandonando a espera`);
945
- for (const reporter of this.reporters) reporter.abort();
1120
+ await this.reportAgentTerminated("agente encerrado durante a execu\xE7\xE3o (shutdown excedeu o tempo de espera)");
946
1121
  }
1122
+ this.uninstallShutdown?.();
1123
+ this.uninstallShutdown = void 0;
947
1124
  this.state = "idle";
948
1125
  return finished;
949
1126
  }
1127
+ /**
1128
+ * Best-effort do exit path: reporta 'failed'/AgentTerminated para as runs ativas
1129
+ * antes do processo morrer, fora da fila do reporter e com timeout curto. Runs já
1130
+ * canceladas no app (reporter morto) são puladas; erros de rede são engolidos —
1131
+ * o sweep server-side é o backstop.
1132
+ */
1133
+ async reportAgentTerminated(message, timeoutMs = 2500) {
1134
+ const pending = [];
1135
+ for (const reporter of this.reporters) {
1136
+ if (reporter.isDead) continue;
1137
+ reporter.kill();
1138
+ pending.push(
1139
+ this.api.updateRunOnce(reporter.context.runId, { status: "failed", error: { type: "AgentTerminated", message } }, timeoutMs).catch(() => {
1140
+ })
1141
+ );
1142
+ }
1143
+ if (pending.length === 0) return;
1144
+ await Promise.race([Promise.allSettled(pending), sleep(timeoutMs)]);
1145
+ }
950
1146
  /** Timer intencionalmente NÃO unref'd: o agente é a razão do processo existir. */
951
1147
  schedule(delayMs) {
952
1148
  if (this.state !== "started" || this.timer) return;
@@ -1013,7 +1209,7 @@ var ManagerAgent = class {
1013
1209
  this.activeRuns.add(promise);
1014
1210
  }
1015
1211
  async executeRun(run) {
1016
- const reporter = new RunReporter(this.api, run.id, run.task, this.logger, this.logFlushIntervalMs);
1212
+ const reporter = new RunReporter(this.api, run.id, run.task, this.logger, this.logFlushIntervalMs, this.cancelCheckIntervalMs);
1017
1213
  this.reporters.add(reporter);
1018
1214
  try {
1019
1215
  const handler = this.registry.get(run.task);