hobots 1.8.0 → 1.9.0
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 +33 -0
- package/dist/index.cjs +278 -25
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +44 -3
- package/dist/index.d.ts +44 -3
- package/dist/index.js +278 -25
- package/dist/index.js.map +1 -1
- package/dist/protocol.cjs.map +1 -1
- package/dist/protocol.d.cts +41 -1
- package/dist/protocol.d.ts +41 -1
- package/docs/api-reference.md +27 -1
- package/docs/concepts.md +17 -0
- package/docs/getting-started.md +1 -0
- package/docs/protocol.md +60 -3
- package/docs/tasks.md +69 -0
- package/package.json +1 -1
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Breadcrumb, EventUser, Severity, EventPayload, TransactionPayload, HeartbeatPayload, StepStatus, RunLogLevel, StepData } from './protocol.cjs';
|
|
2
|
-
export { AgentInfo, Envelope, EnvelopeItem, ExceptionData, PollRequest, PollResponse, RunDescriptor, RunError, RunLogEntry, RunStatus, RunUpdateRequest, RunUpdateResponse, RunUpdateStatus, StackFrame } from './protocol.cjs';
|
|
1
|
+
import { Breadcrumb, EventUser, Severity, EventPayload, TransactionPayload, HeartbeatPayload, StepStatus, RunLogLevel, StepData, RunAttachmentContentType } from './protocol.cjs';
|
|
2
|
+
export { AgentInfo, Envelope, EnvelopeItem, ExceptionData, PollRequest, PollResponse, RunAttachmentConfirmRequest, RunAttachmentConfirmResponse, RunAttachmentCreateRequest, RunAttachmentCreateResponse, RunDescriptor, RunError, RunLogEntry, RunStatus, RunUpdateRequest, RunUpdateResponse, RunUpdateStatus, StackFrame } from './protocol.cjs';
|
|
3
3
|
|
|
4
4
|
/** API oficial da Hobots — o SDK sempre fala com ela em produção. */
|
|
5
5
|
declare const DEFAULT_BASE_URL = "https://api2.hobots.app";
|
|
@@ -123,6 +123,31 @@ declare class Transaction {
|
|
|
123
123
|
finish(): void;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
interface AttachOptions {
|
|
127
|
+
/** Legenda curta, exibida sob a miniatura no app. Passa pelo scrub de segredos. */
|
|
128
|
+
caption?: string;
|
|
129
|
+
/** Nível da linha de log que carrega o anexo. Default: 'info' */
|
|
130
|
+
level?: RunLogLevel;
|
|
131
|
+
/** Texto da linha de log. Default: a legenda, ou 'captura de tela' */
|
|
132
|
+
message?: string;
|
|
133
|
+
/** Informe quando os magic bytes não bastarem (o SDK sempre prefere o que detectar). */
|
|
134
|
+
contentType?: RunAttachmentContentType;
|
|
135
|
+
}
|
|
136
|
+
type AttachFailureReason =
|
|
137
|
+
/** A instalação do Hobots não tem anexos habilitados (ou a API é anterior ao recurso) */
|
|
138
|
+
'unsupported' | 'too-large' | 'quota' | 'read-failed'
|
|
139
|
+
/** Formato não reconhecido ou fora de PNG/JPEG/WEBP */
|
|
140
|
+
| 'unsupported-format' | 'upload-failed' | 'canceled';
|
|
141
|
+
interface AttachResult {
|
|
142
|
+
/** Id do anexo, o mesmo referenciado na linha de log */
|
|
143
|
+
id: string;
|
|
144
|
+
ok: boolean;
|
|
145
|
+
/** Motivo do insucesso — para diagnóstico; `attach` nunca lança */
|
|
146
|
+
reason?: AttachFailureReason;
|
|
147
|
+
}
|
|
148
|
+
/** Aceita tudo que o handler pode ter em mãos: bytes ou um caminho de arquivo (só Node). */
|
|
149
|
+
type AttachInput = Uint8Array | ArrayBuffer | string;
|
|
150
|
+
|
|
126
151
|
/** Opções de tasks dentro de `init({ tasks: ... })`. Credencial (clientSecret), identidade (instanceId) e debug vêm do init. */
|
|
127
152
|
interface TasksOptions {
|
|
128
153
|
/** Intervalo entre polls sem trabalho. Default: 5_000 (mínimo 1_000) */
|
|
@@ -177,6 +202,22 @@ interface RunContext {
|
|
|
177
202
|
throwIfCanceled(): void;
|
|
178
203
|
/** Sleep cancelável: rejeita com `RunCanceled` se ctx.signal disparar antes de ms. */
|
|
179
204
|
sleep(ms: number): Promise<void>;
|
|
205
|
+
/**
|
|
206
|
+
* Anexa uma imagem (print da tela) a uma linha de log da execução — a evidência
|
|
207
|
+
* visual aparece como miniatura junto do log, na aba "logs do agente".
|
|
208
|
+
*
|
|
209
|
+
* Aceita bytes (`Uint8Array`/`ArrayBuffer`/`Buffer`) ou um caminho de arquivo
|
|
210
|
+
* (só em Node). A linha de log é registrada imediatamente; o upload roda em
|
|
211
|
+
* segundo plano e **nunca bloqueia nem lança** — falhas viram um aviso no log da
|
|
212
|
+
* run e um `{ ok: false, reason }` no retorno, que pode ser ignorado com `void`.
|
|
213
|
+
*
|
|
214
|
+
* PNG, JPEG ou WEBP, até 5 MB, no máximo 20 anexos por execução.
|
|
215
|
+
*
|
|
216
|
+
* ```ts
|
|
217
|
+
* await ctx.attach(await page.screenshot(), { caption: 'portal após o login' });
|
|
218
|
+
* ```
|
|
219
|
+
*/
|
|
220
|
+
attach(image: AttachInput, options?: AttachOptions): Promise<AttachResult>;
|
|
180
221
|
}
|
|
181
222
|
/** O retorno do handler é ignorado — nada além de status/logs/erro é enviado ao servidor. */
|
|
182
223
|
type TaskHandler<P = Record<string, unknown>> = (params: P, ctx: RunContext) => void | Promise<void>;
|
|
@@ -267,4 +308,4 @@ declare function close(timeoutMs?: number): Promise<boolean>;
|
|
|
267
308
|
|
|
268
309
|
declare function scrubSecrets(text: string): string;
|
|
269
310
|
|
|
270
|
-
export { Breadcrumb, type BreadcrumbInput, type CaptureHint, Client, DEFAULT_BASE_URL, EventPayload, EventUser, type HeartbeatOptions, HeartbeatPayload, type InitOptions, type RunContext, RunLogLevel, Scope, Severity, Step, StepData, type TaskHandler, type TasksOptions, Transaction, TransactionPayload, addBreadcrumb, captureException, captureMessage, close, flush, getClient, init, register, scrubSecrets, setContext, setExtra, setTag, setTags, setUser, start, startTransaction, stop, withScope };
|
|
311
|
+
export { type AttachFailureReason, type AttachOptions, type AttachResult, Breadcrumb, type BreadcrumbInput, type CaptureHint, Client, DEFAULT_BASE_URL, EventPayload, EventUser, type HeartbeatOptions, HeartbeatPayload, type InitOptions, RunAttachmentContentType, type RunContext, RunLogLevel, Scope, Severity, Step, StepData, type TaskHandler, type TasksOptions, Transaction, TransactionPayload, addBreadcrumb, captureException, captureMessage, close, flush, getClient, init, register, scrubSecrets, setContext, setExtra, setTag, setTags, setUser, start, startTransaction, stop, withScope };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Breadcrumb, EventUser, Severity, EventPayload, TransactionPayload, HeartbeatPayload, StepStatus, RunLogLevel, StepData } from './protocol.js';
|
|
2
|
-
export { AgentInfo, Envelope, EnvelopeItem, ExceptionData, PollRequest, PollResponse, RunDescriptor, RunError, RunLogEntry, RunStatus, RunUpdateRequest, RunUpdateResponse, RunUpdateStatus, StackFrame } from './protocol.js';
|
|
1
|
+
import { Breadcrumb, EventUser, Severity, EventPayload, TransactionPayload, HeartbeatPayload, StepStatus, RunLogLevel, StepData, RunAttachmentContentType } from './protocol.js';
|
|
2
|
+
export { AgentInfo, Envelope, EnvelopeItem, ExceptionData, PollRequest, PollResponse, RunAttachmentConfirmRequest, RunAttachmentConfirmResponse, RunAttachmentCreateRequest, RunAttachmentCreateResponse, RunDescriptor, RunError, RunLogEntry, RunStatus, RunUpdateRequest, RunUpdateResponse, RunUpdateStatus, StackFrame } from './protocol.js';
|
|
3
3
|
|
|
4
4
|
/** API oficial da Hobots — o SDK sempre fala com ela em produção. */
|
|
5
5
|
declare const DEFAULT_BASE_URL = "https://api2.hobots.app";
|
|
@@ -123,6 +123,31 @@ declare class Transaction {
|
|
|
123
123
|
finish(): void;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
interface AttachOptions {
|
|
127
|
+
/** Legenda curta, exibida sob a miniatura no app. Passa pelo scrub de segredos. */
|
|
128
|
+
caption?: string;
|
|
129
|
+
/** Nível da linha de log que carrega o anexo. Default: 'info' */
|
|
130
|
+
level?: RunLogLevel;
|
|
131
|
+
/** Texto da linha de log. Default: a legenda, ou 'captura de tela' */
|
|
132
|
+
message?: string;
|
|
133
|
+
/** Informe quando os magic bytes não bastarem (o SDK sempre prefere o que detectar). */
|
|
134
|
+
contentType?: RunAttachmentContentType;
|
|
135
|
+
}
|
|
136
|
+
type AttachFailureReason =
|
|
137
|
+
/** A instalação do Hobots não tem anexos habilitados (ou a API é anterior ao recurso) */
|
|
138
|
+
'unsupported' | 'too-large' | 'quota' | 'read-failed'
|
|
139
|
+
/** Formato não reconhecido ou fora de PNG/JPEG/WEBP */
|
|
140
|
+
| 'unsupported-format' | 'upload-failed' | 'canceled';
|
|
141
|
+
interface AttachResult {
|
|
142
|
+
/** Id do anexo, o mesmo referenciado na linha de log */
|
|
143
|
+
id: string;
|
|
144
|
+
ok: boolean;
|
|
145
|
+
/** Motivo do insucesso — para diagnóstico; `attach` nunca lança */
|
|
146
|
+
reason?: AttachFailureReason;
|
|
147
|
+
}
|
|
148
|
+
/** Aceita tudo que o handler pode ter em mãos: bytes ou um caminho de arquivo (só Node). */
|
|
149
|
+
type AttachInput = Uint8Array | ArrayBuffer | string;
|
|
150
|
+
|
|
126
151
|
/** Opções de tasks dentro de `init({ tasks: ... })`. Credencial (clientSecret), identidade (instanceId) e debug vêm do init. */
|
|
127
152
|
interface TasksOptions {
|
|
128
153
|
/** Intervalo entre polls sem trabalho. Default: 5_000 (mínimo 1_000) */
|
|
@@ -177,6 +202,22 @@ interface RunContext {
|
|
|
177
202
|
throwIfCanceled(): void;
|
|
178
203
|
/** Sleep cancelável: rejeita com `RunCanceled` se ctx.signal disparar antes de ms. */
|
|
179
204
|
sleep(ms: number): Promise<void>;
|
|
205
|
+
/**
|
|
206
|
+
* Anexa uma imagem (print da tela) a uma linha de log da execução — a evidência
|
|
207
|
+
* visual aparece como miniatura junto do log, na aba "logs do agente".
|
|
208
|
+
*
|
|
209
|
+
* Aceita bytes (`Uint8Array`/`ArrayBuffer`/`Buffer`) ou um caminho de arquivo
|
|
210
|
+
* (só em Node). A linha de log é registrada imediatamente; o upload roda em
|
|
211
|
+
* segundo plano e **nunca bloqueia nem lança** — falhas viram um aviso no log da
|
|
212
|
+
* run e um `{ ok: false, reason }` no retorno, que pode ser ignorado com `void`.
|
|
213
|
+
*
|
|
214
|
+
* PNG, JPEG ou WEBP, até 5 MB, no máximo 20 anexos por execução.
|
|
215
|
+
*
|
|
216
|
+
* ```ts
|
|
217
|
+
* await ctx.attach(await page.screenshot(), { caption: 'portal após o login' });
|
|
218
|
+
* ```
|
|
219
|
+
*/
|
|
220
|
+
attach(image: AttachInput, options?: AttachOptions): Promise<AttachResult>;
|
|
180
221
|
}
|
|
181
222
|
/** O retorno do handler é ignorado — nada além de status/logs/erro é enviado ao servidor. */
|
|
182
223
|
type TaskHandler<P = Record<string, unknown>> = (params: P, ctx: RunContext) => void | Promise<void>;
|
|
@@ -267,4 +308,4 @@ declare function close(timeoutMs?: number): Promise<boolean>;
|
|
|
267
308
|
|
|
268
309
|
declare function scrubSecrets(text: string): string;
|
|
269
310
|
|
|
270
|
-
export { Breadcrumb, type BreadcrumbInput, type CaptureHint, Client, DEFAULT_BASE_URL, EventPayload, EventUser, type HeartbeatOptions, HeartbeatPayload, type InitOptions, type RunContext, RunLogLevel, Scope, Severity, Step, StepData, type TaskHandler, type TasksOptions, Transaction, TransactionPayload, addBreadcrumb, captureException, captureMessage, close, flush, getClient, init, register, scrubSecrets, setContext, setExtra, setTag, setTags, setUser, start, startTransaction, stop, withScope };
|
|
311
|
+
export { type AttachFailureReason, type AttachOptions, type AttachResult, Breadcrumb, type BreadcrumbInput, type CaptureHint, Client, DEFAULT_BASE_URL, EventPayload, EventUser, type HeartbeatOptions, HeartbeatPayload, type InitOptions, RunAttachmentContentType, type RunContext, RunLogLevel, Scope, Severity, Step, StepData, type TaskHandler, type TasksOptions, Transaction, TransactionPayload, addBreadcrumb, captureException, captureMessage, close, flush, getClient, init, register, scrubSecrets, setContext, setExtra, setTag, setTags, setUser, start, startTransaction, stop, withScope };
|
package/dist/index.js
CHANGED
|
@@ -103,7 +103,7 @@ function scrubSecrets(text) {
|
|
|
103
103
|
|
|
104
104
|
// src/utils.ts
|
|
105
105
|
var SDK_NAME = "hobots";
|
|
106
|
-
var SDK_VERSION = true ? "1.
|
|
106
|
+
var SDK_VERSION = true ? "1.9.0" : "0.0.0-dev";
|
|
107
107
|
function isNode() {
|
|
108
108
|
return typeof process !== "undefined" && typeof process.versions?.node === "string";
|
|
109
109
|
}
|
|
@@ -270,6 +270,7 @@ var REQUEST_TIMEOUT_MS = 1e4;
|
|
|
270
270
|
var DEFAULT_FLUSH_INTERVAL_MS = 2e3;
|
|
271
271
|
var DEFAULT_MAX_BATCH_SIZE = 10;
|
|
272
272
|
var DEFAULT_MAX_QUEUE_SIZE = 200;
|
|
273
|
+
var MAX_ITEMS_PER_ENVELOPE = 50;
|
|
273
274
|
var Transport = class {
|
|
274
275
|
endpoint;
|
|
275
276
|
key;
|
|
@@ -319,7 +320,15 @@ var Transport = class {
|
|
|
319
320
|
this.timer = void 0;
|
|
320
321
|
}
|
|
321
322
|
if (this.buffer.length === 0) return Promise.resolve();
|
|
322
|
-
const
|
|
323
|
+
const taken = this.buffer.splice(0, this.buffer.length);
|
|
324
|
+
const sends = [];
|
|
325
|
+
for (let offset = 0; offset < taken.length; offset += MAX_ITEMS_PER_ENVELOPE) {
|
|
326
|
+
sends.push(this.send(taken.slice(offset, offset + MAX_ITEMS_PER_ENVELOPE)));
|
|
327
|
+
}
|
|
328
|
+
return Promise.all(sends).then(() => void 0);
|
|
329
|
+
}
|
|
330
|
+
/** Registra o envio em `this.pending` para que `flush()` aguarde todos os envelopes. */
|
|
331
|
+
send(items) {
|
|
323
332
|
const envelope = {
|
|
324
333
|
sdk: { name: SDK_NAME, version: SDK_VERSION },
|
|
325
334
|
sent_at: nowISO(),
|
|
@@ -351,7 +360,7 @@ var Transport = class {
|
|
|
351
360
|
this.logger.debug(`envelope enviado (${envelope.items.length} itens)`);
|
|
352
361
|
return;
|
|
353
362
|
}
|
|
354
|
-
this.logger.error(`envelope rejeitado (HTTP ${response.status}), descartado`);
|
|
363
|
+
this.logger.error(`envelope rejeitado (HTTP ${response.status}), descartado (${envelope.items.length} itens)`);
|
|
355
364
|
}
|
|
356
365
|
/** Envia o que estiver na fila e aguarda envios em andamento (até timeoutMs). */
|
|
357
366
|
async flush(timeoutMs = 5e3) {
|
|
@@ -824,6 +833,8 @@ var Transaction = class {
|
|
|
824
833
|
// src/tasks/api.ts
|
|
825
834
|
var POLL_TIMEOUT_MS = 3e4;
|
|
826
835
|
var UPDATE_TIMEOUT_MS = 1e4;
|
|
836
|
+
var UPLOAD_TIMEOUT_MS = 3e4;
|
|
837
|
+
var UPLOAD_RETRY_DELAYS_MS = [1e3, 4e3];
|
|
827
838
|
var TasksApiError = class extends Error {
|
|
828
839
|
constructor(message, status) {
|
|
829
840
|
super(message);
|
|
@@ -918,8 +929,123 @@ var TasksApi = class {
|
|
|
918
929
|
signal: AbortSignal.timeout(timeoutMs)
|
|
919
930
|
});
|
|
920
931
|
}
|
|
932
|
+
/**
|
|
933
|
+
* Registra o anexo e obtém a URL presignada de upload. 404/501/503 significam
|
|
934
|
+
* "esta instalação não tem anexos" — o RunReporter degrada em silêncio a partir daí.
|
|
935
|
+
*/
|
|
936
|
+
async createAttachment(runId, request, signal) {
|
|
937
|
+
const { signal: timeoutSignal, cleanup } = withTimeoutSignal(UPDATE_TIMEOUT_MS, signal);
|
|
938
|
+
let response;
|
|
939
|
+
try {
|
|
940
|
+
response = await fetchWithRetry(
|
|
941
|
+
() => fetch(`${this.baseUrl}/runs/${encodeURIComponent(runId)}/attachments`, {
|
|
942
|
+
method: "POST",
|
|
943
|
+
headers: this.headers(),
|
|
944
|
+
body: JSON.stringify(request),
|
|
945
|
+
signal: timeoutSignal
|
|
946
|
+
}),
|
|
947
|
+
{ logger: this.logger, label: `anexo da run ${runId}` }
|
|
948
|
+
);
|
|
949
|
+
} finally {
|
|
950
|
+
cleanup();
|
|
951
|
+
}
|
|
952
|
+
if (!response.ok) {
|
|
953
|
+
throw new TasksApiError(`cria\xE7\xE3o de anexo rejeitada (HTTP ${response.status})`, response.status);
|
|
954
|
+
}
|
|
955
|
+
const body = await response.json();
|
|
956
|
+
if (typeof body?.upload_url !== "string") {
|
|
957
|
+
throw new TasksApiError("resposta inv\xE1lida do anexo (upload_url ausente)", 0);
|
|
958
|
+
}
|
|
959
|
+
return {
|
|
960
|
+
id: body.id ?? request.id,
|
|
961
|
+
upload_url: body.upload_url,
|
|
962
|
+
headers: body.headers ?? { "content-type": request.content_type },
|
|
963
|
+
expires_in: body.expires_in ?? 900
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* PUT do binário direto no S3. Deliberadamente sem `authorization`: a autorização
|
|
968
|
+
* está na assinatura da URL, e mandar o client secret para fora da API seria vazamento.
|
|
969
|
+
*/
|
|
970
|
+
async uploadAttachment(url, headers, body, signal) {
|
|
971
|
+
const { signal: timeoutSignal, cleanup } = withTimeoutSignal(UPLOAD_TIMEOUT_MS, signal);
|
|
972
|
+
let response;
|
|
973
|
+
try {
|
|
974
|
+
response = await fetchWithRetry(
|
|
975
|
+
() => fetch(url, {
|
|
976
|
+
method: "PUT",
|
|
977
|
+
headers,
|
|
978
|
+
// BodyInit não aceita Uint8Array<ArrayBufferLike> em todos os lib.dom; o buffer é o mesmo
|
|
979
|
+
body,
|
|
980
|
+
signal: timeoutSignal
|
|
981
|
+
}),
|
|
982
|
+
{ delays: UPLOAD_RETRY_DELAYS_MS, logger: this.logger, label: "upload de anexo" }
|
|
983
|
+
);
|
|
984
|
+
} finally {
|
|
985
|
+
cleanup();
|
|
986
|
+
}
|
|
987
|
+
if (!response.ok) {
|
|
988
|
+
throw new TasksApiError(`upload de anexo rejeitado (HTTP ${response.status})`, response.status);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
/** Marca o anexo como enviado (ou falho). Best-effort — a leitura não depende disso. */
|
|
992
|
+
async confirmAttachment(runId, attachmentId, request) {
|
|
993
|
+
const response = await fetch(
|
|
994
|
+
`${this.baseUrl}/runs/${encodeURIComponent(runId)}/attachments/${encodeURIComponent(attachmentId)}`,
|
|
995
|
+
{
|
|
996
|
+
method: "PATCH",
|
|
997
|
+
headers: this.headers(),
|
|
998
|
+
body: JSON.stringify(request),
|
|
999
|
+
signal: AbortSignal.timeout(UPDATE_TIMEOUT_MS)
|
|
1000
|
+
}
|
|
1001
|
+
);
|
|
1002
|
+
if (!response.ok) {
|
|
1003
|
+
throw new TasksApiError(`confirma\xE7\xE3o de anexo rejeitada (HTTP ${response.status})`, response.status);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
921
1006
|
};
|
|
922
1007
|
|
|
1008
|
+
// src/tasks/attachments.ts
|
|
1009
|
+
var MAX_ATTACHMENT_BYTES = 5242880;
|
|
1010
|
+
var MAX_ATTACHMENTS_PER_RUN = 20;
|
|
1011
|
+
var MAX_CONCURRENT_UPLOADS = 3;
|
|
1012
|
+
var MAX_CAPTION_CHARS = 200;
|
|
1013
|
+
var ATTACHMENT_DRAIN_MS = 15e3;
|
|
1014
|
+
async function readImageFile(path) {
|
|
1015
|
+
if (!isNode()) {
|
|
1016
|
+
throw new Error("caminho de arquivo s\xF3 \xE9 suportado em Node");
|
|
1017
|
+
}
|
|
1018
|
+
const { readFile } = await import("fs/promises");
|
|
1019
|
+
return new Uint8Array(await readFile(path));
|
|
1020
|
+
}
|
|
1021
|
+
function startsWith(bytes, signature, offset = 0) {
|
|
1022
|
+
if (bytes.length < offset + signature.length) return false;
|
|
1023
|
+
return signature.every((byte, index) => bytes[offset + index] === byte);
|
|
1024
|
+
}
|
|
1025
|
+
var PNG_MAGIC = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
1026
|
+
var JPEG_MAGIC = [255, 216, 255];
|
|
1027
|
+
var RIFF_MAGIC = [82, 73, 70, 70];
|
|
1028
|
+
var WEBP_MAGIC = [87, 69, 66, 80];
|
|
1029
|
+
function sniffContentType(bytes) {
|
|
1030
|
+
if (startsWith(bytes, PNG_MAGIC)) return "image/png";
|
|
1031
|
+
if (startsWith(bytes, JPEG_MAGIC)) return "image/jpeg";
|
|
1032
|
+
if (startsWith(bytes, RIFF_MAGIC) && startsWith(bytes, WEBP_MAGIC, 8)) return "image/webp";
|
|
1033
|
+
return void 0;
|
|
1034
|
+
}
|
|
1035
|
+
function readPngDimensions(bytes) {
|
|
1036
|
+
if (!startsWith(bytes, PNG_MAGIC) || bytes.length < 24) return void 0;
|
|
1037
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
1038
|
+
const width = view.getUint32(16);
|
|
1039
|
+
const height = view.getUint32(20);
|
|
1040
|
+
if (width === 0 || height === 0) return void 0;
|
|
1041
|
+
return { width, height };
|
|
1042
|
+
}
|
|
1043
|
+
async function toBytes(input) {
|
|
1044
|
+
if (typeof input === "string") return readImageFile(input);
|
|
1045
|
+
if (input instanceof Uint8Array) return input;
|
|
1046
|
+
return new Uint8Array(input);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
923
1049
|
// src/tasks/context.ts
|
|
924
1050
|
var LOG_LEVEL_COLOR = {
|
|
925
1051
|
debug: "gray",
|
|
@@ -958,7 +1084,6 @@ var DEFAULT_CANCEL_CHECK_INTERVAL_MS = 5e3;
|
|
|
958
1084
|
var MIN_CANCEL_CHECK_INTERVAL_MS = 1e3;
|
|
959
1085
|
var MAX_CANCEL_CHECK_INTERVAL_MS = 15e3;
|
|
960
1086
|
var MAX_LOG_MESSAGE_CHARS = 1e4;
|
|
961
|
-
var MAX_LOGS_PER_RUN = 500;
|
|
962
1087
|
var MAX_LOGS_PER_PATCH = 100;
|
|
963
1088
|
var FLUSH_AT_BUFFERED = 20;
|
|
964
1089
|
var RunReporter = class {
|
|
@@ -989,13 +1114,12 @@ var RunReporter = class {
|
|
|
989
1114
|
throwIfCanceled: () => {
|
|
990
1115
|
if (signal.aborted) throw runCanceledError();
|
|
991
1116
|
},
|
|
992
|
-
sleep: (ms) => cancelableSleep(ms, signal)
|
|
1117
|
+
sleep: (ms) => cancelableSleep(ms, signal),
|
|
1118
|
+
attach: (image, options) => this.attach(image, options)
|
|
993
1119
|
};
|
|
994
1120
|
}
|
|
995
1121
|
context;
|
|
996
1122
|
buffer = [];
|
|
997
|
-
loggedCount = 0;
|
|
998
|
-
droppedCount = 0;
|
|
999
1123
|
dead = false;
|
|
1000
1124
|
chain = Promise.resolve();
|
|
1001
1125
|
pendingUpdates = 0;
|
|
@@ -1004,6 +1128,14 @@ var RunReporter = class {
|
|
|
1004
1128
|
abortController = new AbortController();
|
|
1005
1129
|
flushIntervalMs;
|
|
1006
1130
|
cancelCheckIntervalMs;
|
|
1131
|
+
// Uploads de anexo vivem FORA de `this.chain`: a cadeia é um FIFO puro de
|
|
1132
|
+
// status/logs e não pode ficar bloqueada esperando um binário subir.
|
|
1133
|
+
pendingAttachments = /* @__PURE__ */ new Set();
|
|
1134
|
+
attachmentCount = 0;
|
|
1135
|
+
uploadSlots = MAX_CONCURRENT_UPLOADS;
|
|
1136
|
+
uploadQueue = [];
|
|
1137
|
+
/** Uma vez que a instalação recusa anexos, para de tentar (e de avisar) nesta run. */
|
|
1138
|
+
attachmentsUnsupported = false;
|
|
1007
1139
|
get isDead() {
|
|
1008
1140
|
return this.dead;
|
|
1009
1141
|
}
|
|
@@ -1022,6 +1154,8 @@ var RunReporter = class {
|
|
|
1022
1154
|
this.dead = true;
|
|
1023
1155
|
this.clearFlushTimer();
|
|
1024
1156
|
this.buffer = [];
|
|
1157
|
+
const waiting = this.uploadQueue.splice(0, this.uploadQueue.length);
|
|
1158
|
+
for (const resume of waiting) resume();
|
|
1025
1159
|
}
|
|
1026
1160
|
/**
|
|
1027
1161
|
* Drena o buffer pendente para o PATCH único do exit path (chame ANTES de kill,
|
|
@@ -1039,20 +1173,22 @@ var RunReporter = class {
|
|
|
1039
1173
|
this.push(level, scrubbed, true);
|
|
1040
1174
|
}
|
|
1041
1175
|
/** Bufferiza uma entrada de log sem eco no terminal (cap, truncate, flush). */
|
|
1042
|
-
push(level, message, alreadyScrubbed = false) {
|
|
1043
|
-
|
|
1176
|
+
push(level, message, alreadyScrubbed = false, attachments) {
|
|
1177
|
+
const ts = nowISO();
|
|
1178
|
+
if (this.dead) return ts;
|
|
1044
1179
|
const scrubbed = alreadyScrubbed ? message : scrubSecrets(message);
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1180
|
+
this.buffer.push({
|
|
1181
|
+
ts,
|
|
1182
|
+
level,
|
|
1183
|
+
message: truncate(scrubbed, MAX_LOG_MESSAGE_CHARS),
|
|
1184
|
+
...attachments && attachments.length > 0 ? { attachments } : {}
|
|
1185
|
+
});
|
|
1051
1186
|
if (this.buffer.length >= FLUSH_AT_BUFFERED) {
|
|
1052
1187
|
this.flushLogs();
|
|
1053
1188
|
} else {
|
|
1054
1189
|
this.scheduleFlush();
|
|
1055
1190
|
}
|
|
1191
|
+
return ts;
|
|
1056
1192
|
}
|
|
1057
1193
|
scheduleFlush() {
|
|
1058
1194
|
if (this.timer) return;
|
|
@@ -1069,16 +1205,7 @@ var RunReporter = class {
|
|
|
1069
1205
|
}
|
|
1070
1206
|
}
|
|
1071
1207
|
takeLogs() {
|
|
1072
|
-
|
|
1073
|
-
if (this.droppedCount > 0) {
|
|
1074
|
-
logs.push({
|
|
1075
|
-
ts: nowISO(),
|
|
1076
|
-
level: "warning",
|
|
1077
|
-
message: `${this.droppedCount} logs descartados (limite de ${MAX_LOGS_PER_RUN} por execu\xE7\xE3o)`
|
|
1078
|
-
});
|
|
1079
|
-
this.droppedCount = 0;
|
|
1080
|
-
}
|
|
1081
|
-
return logs;
|
|
1208
|
+
return this.buffer.splice(0, this.buffer.length);
|
|
1082
1209
|
}
|
|
1083
1210
|
/** Flush incremental: envia o buffer como status 'running' (em chunks). */
|
|
1084
1211
|
flushLogs() {
|
|
@@ -1129,6 +1256,130 @@ var RunReporter = class {
|
|
|
1129
1256
|
finalize() {
|
|
1130
1257
|
return this.chain;
|
|
1131
1258
|
}
|
|
1259
|
+
// ── Anexos ───────────────────────────────────────────────────────────────────
|
|
1260
|
+
/**
|
|
1261
|
+
* Registra a linha de log com o id do anexo (síncrono, na ordem certa) e dispara
|
|
1262
|
+
* o upload em segundo plano. Nunca lança: todo caminho de erro vira `{ ok: false }`
|
|
1263
|
+
* com um aviso no log da run.
|
|
1264
|
+
*/
|
|
1265
|
+
async attach(image, options = {}) {
|
|
1266
|
+
const id = uuid4();
|
|
1267
|
+
const level = options.level ?? "info";
|
|
1268
|
+
const caption = options.caption ? truncate(scrubSecrets(options.caption), MAX_CAPTION_CHARS) : void 0;
|
|
1269
|
+
const message = options.message ?? options.caption ?? "captura de tela";
|
|
1270
|
+
if (this.dead) return { id, ok: false, reason: "canceled" };
|
|
1271
|
+
if (this.attachmentsUnsupported) {
|
|
1272
|
+
this.log(level, message);
|
|
1273
|
+
return { id, ok: false, reason: "unsupported" };
|
|
1274
|
+
}
|
|
1275
|
+
if (this.attachmentCount >= MAX_ATTACHMENTS_PER_RUN) {
|
|
1276
|
+
this.log(level, message);
|
|
1277
|
+
this.log("warning", `anexo descartado: limite de ${MAX_ATTACHMENTS_PER_RUN} por execu\xE7\xE3o atingido`);
|
|
1278
|
+
return { id, ok: false, reason: "quota" };
|
|
1279
|
+
}
|
|
1280
|
+
this.attachmentCount++;
|
|
1281
|
+
const scrubbed = scrubSecrets(message);
|
|
1282
|
+
printRunLog(this.context.task, level, `${scrubbed} [imagem]`);
|
|
1283
|
+
const logTs = this.push(level, scrubbed, true, [id]);
|
|
1284
|
+
let settle;
|
|
1285
|
+
const result = new Promise((resolve) => {
|
|
1286
|
+
settle = resolve;
|
|
1287
|
+
});
|
|
1288
|
+
const work = this.uploadAttachment(id, image, { caption, logTs, contentType: options.contentType }).then((reason) => settle(reason ? { id, ok: false, reason } : { id, ok: true })).catch(() => settle({ id, ok: false, reason: "upload-failed" }));
|
|
1289
|
+
this.pendingAttachments.add(work);
|
|
1290
|
+
void work.finally(() => this.pendingAttachments.delete(work));
|
|
1291
|
+
return result;
|
|
1292
|
+
}
|
|
1293
|
+
/** Retorna o motivo da falha, ou undefined em sucesso. */
|
|
1294
|
+
async uploadAttachment(id, image, meta) {
|
|
1295
|
+
let bytes;
|
|
1296
|
+
try {
|
|
1297
|
+
bytes = await toBytes(image);
|
|
1298
|
+
} catch (error) {
|
|
1299
|
+
this.log("warning", `anexo n\xE3o p\xF4de ser lido: ${String(error)}`);
|
|
1300
|
+
return "read-failed";
|
|
1301
|
+
}
|
|
1302
|
+
const contentType = sniffContentType(bytes) ?? meta.contentType;
|
|
1303
|
+
if (!contentType) {
|
|
1304
|
+
this.log("warning", "anexo descartado: formato n\xE3o reconhecido (aceitos: PNG, JPEG, WEBP)");
|
|
1305
|
+
return "unsupported-format";
|
|
1306
|
+
}
|
|
1307
|
+
if (bytes.byteLength > MAX_ATTACHMENT_BYTES) {
|
|
1308
|
+
this.log(
|
|
1309
|
+
"warning",
|
|
1310
|
+
`anexo descartado: ${Math.round(bytes.byteLength / 1024)} KB excede o limite de ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB`
|
|
1311
|
+
);
|
|
1312
|
+
return "too-large";
|
|
1313
|
+
}
|
|
1314
|
+
const dimensions = readPngDimensions(bytes);
|
|
1315
|
+
const release = await this.acquireUploadSlot();
|
|
1316
|
+
try {
|
|
1317
|
+
if (this.abortController.signal.aborted || this.dead) return "canceled";
|
|
1318
|
+
const created = await this.api.createAttachment(
|
|
1319
|
+
this.runId,
|
|
1320
|
+
{
|
|
1321
|
+
id,
|
|
1322
|
+
content_type: contentType,
|
|
1323
|
+
size_bytes: bytes.byteLength,
|
|
1324
|
+
log_ts: meta.logTs,
|
|
1325
|
+
...meta.caption ? { caption: meta.caption } : {},
|
|
1326
|
+
...dimensions ?? {}
|
|
1327
|
+
},
|
|
1328
|
+
this.abortController.signal
|
|
1329
|
+
);
|
|
1330
|
+
await this.api.uploadAttachment(created.upload_url, created.headers, bytes, this.abortController.signal);
|
|
1331
|
+
await this.api.confirmAttachment(this.runId, id, { status: "uploaded", size_bytes: bytes.byteLength, ...dimensions ?? {} }).catch(() => {
|
|
1332
|
+
});
|
|
1333
|
+
return void 0;
|
|
1334
|
+
} catch (error) {
|
|
1335
|
+
if (this.isUnsupported(error)) {
|
|
1336
|
+
this.attachmentsUnsupported = true;
|
|
1337
|
+
this.log("warning", "anexos n\xE3o est\xE3o habilitados nesta instala\xE7\xE3o do Hobots \u2014 imagem n\xE3o enviada");
|
|
1338
|
+
return "unsupported";
|
|
1339
|
+
}
|
|
1340
|
+
if (error instanceof TasksApiError && error.status === 429) {
|
|
1341
|
+
this.log("warning", "anexo descartado: limite de anexos por execu\xE7\xE3o atingido no servidor");
|
|
1342
|
+
return "quota";
|
|
1343
|
+
}
|
|
1344
|
+
if (this.abortController.signal.aborted) return "canceled";
|
|
1345
|
+
this.log("warning", `falha ao enviar o anexo: ${String(error)}`);
|
|
1346
|
+
void this.api.confirmAttachment(this.runId, id, { status: "failed" }).catch(() => {
|
|
1347
|
+
});
|
|
1348
|
+
return "upload-failed";
|
|
1349
|
+
} finally {
|
|
1350
|
+
release();
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
/** 404 = API anterior ao recurso; 501/503 = instalação sem S3 configurado. */
|
|
1354
|
+
isUnsupported(error) {
|
|
1355
|
+
return error instanceof TasksApiError && (error.status === 404 || error.status === 501 || error.status === 503);
|
|
1356
|
+
}
|
|
1357
|
+
/** Semáforo simples: no máximo MAX_CONCURRENT_UPLOADS binários em voo por run. */
|
|
1358
|
+
acquireUploadSlot() {
|
|
1359
|
+
const release = () => {
|
|
1360
|
+
const next = this.uploadQueue.shift();
|
|
1361
|
+
if (next) next();
|
|
1362
|
+
else this.uploadSlots++;
|
|
1363
|
+
};
|
|
1364
|
+
if (this.uploadSlots > 0) {
|
|
1365
|
+
this.uploadSlots--;
|
|
1366
|
+
return Promise.resolve(release);
|
|
1367
|
+
}
|
|
1368
|
+
return new Promise((resolve) => {
|
|
1369
|
+
this.uploadQueue.push(() => resolve(release));
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Espera os uploads pendentes antes do PATCH terminal, para a run não terminar
|
|
1374
|
+
* exibindo miniaturas em "enviando…". Capado: um upload lento não segura a run.
|
|
1375
|
+
*/
|
|
1376
|
+
async finalizeAttachments(timeoutMs = ATTACHMENT_DRAIN_MS) {
|
|
1377
|
+
if (this.pendingAttachments.size === 0) return;
|
|
1378
|
+
await Promise.race([
|
|
1379
|
+
Promise.allSettled([...this.pendingAttachments]),
|
|
1380
|
+
new Promise((resolve) => unrefTimer(setTimeout(resolve, timeoutMs)))
|
|
1381
|
+
]);
|
|
1382
|
+
}
|
|
1132
1383
|
enqueue(update) {
|
|
1133
1384
|
if (this.dead) {
|
|
1134
1385
|
this.salvageLogs(update.logs);
|
|
@@ -1438,11 +1689,13 @@ var TaskAgent = class {
|
|
|
1438
1689
|
this.logger.debug(`executando run ${run.id} (${run.task})`);
|
|
1439
1690
|
try {
|
|
1440
1691
|
await runWithRunContext({ runId: run.id, tx }, () => this.invokeWithTimeout(handler, run, reporter));
|
|
1692
|
+
await reporter.finalizeAttachments();
|
|
1441
1693
|
tx.setStatus("ok");
|
|
1442
1694
|
tx.finish();
|
|
1443
1695
|
reporter.reportSuccess();
|
|
1444
1696
|
this.logger.debug(`run ${run.id} conclu\xEDda`);
|
|
1445
1697
|
} catch (error) {
|
|
1698
|
+
await reporter.finalizeAttachments();
|
|
1446
1699
|
tx.setStatus("error");
|
|
1447
1700
|
tx.finish();
|
|
1448
1701
|
reporter.reportFailure(toRunError(error));
|