hobots 1.8.1 → 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 +277 -7
- 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 +277 -7
- 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",
|
|
@@ -988,7 +1114,8 @@ var RunReporter = class {
|
|
|
988
1114
|
throwIfCanceled: () => {
|
|
989
1115
|
if (signal.aborted) throw runCanceledError();
|
|
990
1116
|
},
|
|
991
|
-
sleep: (ms) => cancelableSleep(ms, signal)
|
|
1117
|
+
sleep: (ms) => cancelableSleep(ms, signal),
|
|
1118
|
+
attach: (image, options) => this.attach(image, options)
|
|
992
1119
|
};
|
|
993
1120
|
}
|
|
994
1121
|
context;
|
|
@@ -1001,6 +1128,14 @@ var RunReporter = class {
|
|
|
1001
1128
|
abortController = new AbortController();
|
|
1002
1129
|
flushIntervalMs;
|
|
1003
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;
|
|
1004
1139
|
get isDead() {
|
|
1005
1140
|
return this.dead;
|
|
1006
1141
|
}
|
|
@@ -1019,6 +1154,8 @@ var RunReporter = class {
|
|
|
1019
1154
|
this.dead = true;
|
|
1020
1155
|
this.clearFlushTimer();
|
|
1021
1156
|
this.buffer = [];
|
|
1157
|
+
const waiting = this.uploadQueue.splice(0, this.uploadQueue.length);
|
|
1158
|
+
for (const resume of waiting) resume();
|
|
1022
1159
|
}
|
|
1023
1160
|
/**
|
|
1024
1161
|
* Drena o buffer pendente para o PATCH único do exit path (chame ANTES de kill,
|
|
@@ -1036,15 +1173,22 @@ var RunReporter = class {
|
|
|
1036
1173
|
this.push(level, scrubbed, true);
|
|
1037
1174
|
}
|
|
1038
1175
|
/** Bufferiza uma entrada de log sem eco no terminal (cap, truncate, flush). */
|
|
1039
|
-
push(level, message, alreadyScrubbed = false) {
|
|
1040
|
-
|
|
1176
|
+
push(level, message, alreadyScrubbed = false, attachments) {
|
|
1177
|
+
const ts = nowISO();
|
|
1178
|
+
if (this.dead) return ts;
|
|
1041
1179
|
const scrubbed = alreadyScrubbed ? message : scrubSecrets(message);
|
|
1042
|
-
this.buffer.push({
|
|
1180
|
+
this.buffer.push({
|
|
1181
|
+
ts,
|
|
1182
|
+
level,
|
|
1183
|
+
message: truncate(scrubbed, MAX_LOG_MESSAGE_CHARS),
|
|
1184
|
+
...attachments && attachments.length > 0 ? { attachments } : {}
|
|
1185
|
+
});
|
|
1043
1186
|
if (this.buffer.length >= FLUSH_AT_BUFFERED) {
|
|
1044
1187
|
this.flushLogs();
|
|
1045
1188
|
} else {
|
|
1046
1189
|
this.scheduleFlush();
|
|
1047
1190
|
}
|
|
1191
|
+
return ts;
|
|
1048
1192
|
}
|
|
1049
1193
|
scheduleFlush() {
|
|
1050
1194
|
if (this.timer) return;
|
|
@@ -1112,6 +1256,130 @@ var RunReporter = class {
|
|
|
1112
1256
|
finalize() {
|
|
1113
1257
|
return this.chain;
|
|
1114
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
|
+
}
|
|
1115
1383
|
enqueue(update) {
|
|
1116
1384
|
if (this.dead) {
|
|
1117
1385
|
this.salvageLogs(update.logs);
|
|
@@ -1421,11 +1689,13 @@ var TaskAgent = class {
|
|
|
1421
1689
|
this.logger.debug(`executando run ${run.id} (${run.task})`);
|
|
1422
1690
|
try {
|
|
1423
1691
|
await runWithRunContext({ runId: run.id, tx }, () => this.invokeWithTimeout(handler, run, reporter));
|
|
1692
|
+
await reporter.finalizeAttachments();
|
|
1424
1693
|
tx.setStatus("ok");
|
|
1425
1694
|
tx.finish();
|
|
1426
1695
|
reporter.reportSuccess();
|
|
1427
1696
|
this.logger.debug(`run ${run.id} conclu\xEDda`);
|
|
1428
1697
|
} catch (error) {
|
|
1698
|
+
await reporter.finalizeAttachments();
|
|
1429
1699
|
tx.setStatus("error");
|
|
1430
1700
|
tx.finish();
|
|
1431
1701
|
reporter.reportFailure(toRunError(error));
|