hobots 4.1.0 → 4.2.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 +31 -0
- package/dist/index.cjs +355 -8
- 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 +355 -8
- package/dist/index.js.map +1 -1
- package/dist/protocol.cjs.map +1 -1
- package/dist/protocol.d.cts +39 -1
- package/dist/protocol.d.ts +39 -1
- package/docs/api-reference.md +2 -0
- package/docs/protocol.md +10 -0
- package/docs/runs.md +30 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -110,6 +110,7 @@ A opção passada em `register()`/`start()` tem precedência sobre a env — út
|
|
|
110
110
|
| `hobots.retry(fn, { retries = 3, delayMs = 1_000, shouldRetry? })` | async-retry ciente do cancelamento da execução. `delayMs` aceita função `(tentativa) => ms`. |
|
|
111
111
|
| `hobots.sleep(ms)` | Espera **cancelável**: rejeita com `RunCanceled` se a execução for cancelada. |
|
|
112
112
|
| `hobots.signal` · `hobots.throwIfCanceled()` | Cancelamento cooperativo manual, para controle fino. |
|
|
113
|
+
| `hobots.files.list()` · `.get(campo)` · `.download(refOuCampo, { dest }?)` · `.downloadAll(campo)` · `.read(refOuCampo)` | **Arquivos dos campos de upload** do formulário: baixa para o disco (ou para a memória) e consome na origem — ver [Arquivos do formulário](#arquivos-do-formulário). `isFileRef(valor)` reconhece um arquivo nos params. |
|
|
113
114
|
| `hobots.runId` · `hobots.params` | Identidade da execução atual. |
|
|
114
115
|
| `hobots.register(...)` · `hobots.start(...)` · `hobots.close(timeoutMs?)` | Ciclo de vida do processo. |
|
|
115
116
|
|
|
@@ -117,6 +118,35 @@ O handler também recebe o mesmo contexto como 2º argumento (`(params, ctx) =>
|
|
|
117
118
|
|
|
118
119
|
---
|
|
119
120
|
|
|
121
|
+
## Arquivos do formulário
|
|
122
|
+
|
|
123
|
+
Um campo de **upload** no formulário do agente chega nos `params` como um `FileRef` — `{ id, name, size, contentType, storage }` — ou uma lista deles (campo "vários arquivos"). O bot não precisa saber de onde o arquivo veio: `hobots.files.download` traz para o disco desta máquina e devolve o caminho.
|
|
124
|
+
|
|
125
|
+
```js
|
|
126
|
+
import { hobots } from 'hobots';
|
|
127
|
+
|
|
128
|
+
hobots.register(async () => {
|
|
129
|
+
// campo único: o caminho do arquivo baixado (pasta temporária da execução)
|
|
130
|
+
const planilha = await hobots.files.download('planilha');
|
|
131
|
+
await importarPlanilha(planilha);
|
|
132
|
+
|
|
133
|
+
// campo "vários arquivos": um caminho por arquivo, na ordem, na pasta escolhida
|
|
134
|
+
const notas = await hobots.files.downloadAll('notas', { dest: './entrada' });
|
|
135
|
+
for (const nota of notas) await lancarNota(nota);
|
|
136
|
+
|
|
137
|
+
// ou direto em memória (Buffer) — a cópia temporária é apagada
|
|
138
|
+
const contrato = await hobots.files.read('contrato');
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- **Cada arquivo é baixado uma vez.** O download consome a origem: enviado pela Central (`storage: 's3'`), o SDK pede uma URL temporária, baixa em streaming, confere o tamanho e **só então** confirma — o servidor apaga o arquivo na hora; uma segunda tentativa recebe `arquivo "x" já baixado ou expirado` (`TasksApiError` com `status` 410). Escolhido no Studio (`storage: 'local'`), o arquivo nunca sai do computador: o SDK copia de `$HOBOTS_LOCAL_FILES_DIR/<id>/` e apaga a pasta de origem — num bot de outro computador (sem a env ou sem a pasta), o erro é `arquivo "x" enviado pelo Studio de outro computador ou já consumido`.
|
|
143
|
+
- **Falha não perde o arquivo:** rede caiu, tamanho divergente, execução cancelada — nada é confirmado e o `download` pode ser tentado de novo (ex.: dentro de `hobots.retry`).
|
|
144
|
+
- **Onde o arquivo vai parar:** sem `dest`, numa pasta temporária **da execução, apagada quando ela termina** — guarde o que precisar além disso com `dest`. `dest` pasta (existente ou terminada em `/`) recebe o nome original; outro caminho vira o nome do arquivo final.
|
|
145
|
+
- `hobots.files.list()` lista todos os arquivos dos params; `hobots.files.get('campo')` devolve o `FileRef` (ou a lista); `download` aceita o nome do campo ou o próprio `FileRef` (um item da lista, por exemplo). Para campo com vários arquivos use `downloadAll` (o `download('campo')` lança, apontando para ele).
|
|
146
|
+
- Respeita o cancelamento: uma execução cancelada no meio do download lança `RunCanceled` e não consome o arquivo.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
120
150
|
## Cancelamento automático
|
|
121
151
|
|
|
122
152
|
Uma execução cancelada no app **não mata** o handler — o SDK dispara `hobots.signal` e o próximo ponto de parada lança **`RunCanceled`**:
|
|
@@ -189,6 +219,7 @@ Sem handler próprio de sinal, um `SIGINT`/`SIGTERM` (ou erro fatal) com execuç
|
|
|
189
219
|
|
|
190
220
|
## Changelog resumido
|
|
191
221
|
|
|
222
|
+
- **4.2.0** — **arquivos do formulário**: `hobots.files` (`list`, `get`, `download`, `downloadAll`, `read`) e `isFileRef`; tipos `FileRef`, `FileStorage`, `FileDownloadResponse` em `hobots/protocol`.
|
|
192
223
|
- **4.0.0** — **o agente é a própria tarefa** (breaking): `register(handler, options?)`/`start(handler, options?)` **sem slug**; **uma chamada por processo** (a segunda lança); `hobots.task`/`ctx.task` removidos; tipos `TaskHandler`→`RunHandler`, `TaskOptions`→`RunOptions`, `TaskItemsOptions`→`ItemsOptions`; wire: `InstanceInfo.mode` (`on_demand` \| `always_on`) e `InstanceInfo.items` no lugar de `tasks`/`task_items`, endpoints em `/v1/runs/*`; eco no terminal `[hobots] INFO …`. Guia de migração no [CHANGELOG](./CHANGELOG.md).
|
|
193
224
|
- **3.4.0** — `hobots.attach()` removido; o print vai junto do evento: `hobots.image(...)` no último argumento de `log.*`, `{ image }` em itens e steps.
|
|
194
225
|
- **3.1.0** — `hobots.log.*` aceita vários argumentos (como `console.log`); `retries` inválido (`NaN`) cai no default em vez de tentar para sempre; 429 de itens e 429/501/503 de anexo são definitivos (sem esperar a ladder — `quota`/`unsupported` passam a funcionar de fato); timeout **por requisição** nos anexos; truncamento nos limites do servidor (tag 512, fingerprint 10×255, nome de transaction 500, 200 steps, `exception.value` 8192, `filename` 1024); PNG acima de 20 000 px não declara dimensões; PATCH terminal nunca passa de 100 logs.
|
package/dist/index.cjs
CHANGED
|
@@ -35,6 +35,7 @@ __export(src_exports, {
|
|
|
35
35
|
default: () => src_default,
|
|
36
36
|
hobots: () => hobots,
|
|
37
37
|
image: () => image2,
|
|
38
|
+
isFileRef: () => isFileRef2,
|
|
38
39
|
register: () => register2,
|
|
39
40
|
retry: () => retry,
|
|
40
41
|
start: () => start2
|
|
@@ -96,7 +97,7 @@ function scrubSecrets(text) {
|
|
|
96
97
|
|
|
97
98
|
// src/utils.ts
|
|
98
99
|
var SDK_NAME = "hobots";
|
|
99
|
-
var SDK_VERSION = true ? "4.
|
|
100
|
+
var SDK_VERSION = true ? "4.2.0" : "0.0.0-dev";
|
|
100
101
|
function isNode() {
|
|
101
102
|
return typeof process !== "undefined" && typeof process.versions?.node === "string";
|
|
102
103
|
}
|
|
@@ -178,13 +179,13 @@ async function fetchWithRetry(send, options) {
|
|
|
178
179
|
} catch (error) {
|
|
179
180
|
retriable = error;
|
|
180
181
|
}
|
|
182
|
+
const failure = retriable instanceof Error ? retriable : new Error(String(retriable));
|
|
181
183
|
const base = delays[attempt];
|
|
182
|
-
if (base === void 0)
|
|
183
|
-
throw retriable instanceof Error ? retriable : new Error(String(retriable));
|
|
184
|
-
}
|
|
184
|
+
if (base === void 0 || options.signal?.aborted) throw failure;
|
|
185
185
|
const delay = serverDelay ?? jitter(base);
|
|
186
186
|
options.logger.warn(`${options.label}: falha (${String(retriable)}), retry em ${delay}ms`);
|
|
187
|
-
await sleep(delay);
|
|
187
|
+
await (options.signal ? abortableSleep(delay, options.signal) : sleep(delay));
|
|
188
|
+
if (options.signal?.aborted) throw failure;
|
|
188
189
|
}
|
|
189
190
|
}
|
|
190
191
|
var MAX_RETRY_AFTER_MS = 6e4;
|
|
@@ -896,6 +897,7 @@ var ATTACHMENT_TERMINAL_STATUSES = [429, 501, 503];
|
|
|
896
897
|
var ITEMS_TERMINAL_STATUSES = [429];
|
|
897
898
|
var UPLOAD_TIMEOUT_MS = 3e4;
|
|
898
899
|
var UPLOAD_RETRY_DELAYS_MS = [1e3, 4e3];
|
|
900
|
+
var FILE_RETRY_DELAYS_MS = [1e3, 4e3];
|
|
899
901
|
var TasksApiError = class extends Error {
|
|
900
902
|
constructor(message, status) {
|
|
901
903
|
super(message);
|
|
@@ -1083,7 +1085,13 @@ var TasksApi = class {
|
|
|
1083
1085
|
signal: timeoutSignal
|
|
1084
1086
|
})
|
|
1085
1087
|
),
|
|
1086
|
-
{
|
|
1088
|
+
{
|
|
1089
|
+
logger: this.logger,
|
|
1090
|
+
label: `anexo da run ${runId}`,
|
|
1091
|
+
noRetryStatuses: ATTACHMENT_TERMINAL_STATUSES,
|
|
1092
|
+
// run abortada (timeout, 409, kill): o create em voo rejeita por abort e não entra na ladder
|
|
1093
|
+
signal
|
|
1094
|
+
}
|
|
1087
1095
|
);
|
|
1088
1096
|
if (!response.ok) {
|
|
1089
1097
|
throw new TasksApiError(`cria\xE7\xE3o de anexo rejeitada (HTTP ${response.status})`, response.status);
|
|
@@ -1117,7 +1125,7 @@ var TasksApi = class {
|
|
|
1117
1125
|
signal: timeoutSignal
|
|
1118
1126
|
})
|
|
1119
1127
|
),
|
|
1120
|
-
{ delays: UPLOAD_RETRY_DELAYS_MS, logger: this.logger, label: "upload de anexo" }
|
|
1128
|
+
{ delays: UPLOAD_RETRY_DELAYS_MS, logger: this.logger, label: "upload de anexo", signal }
|
|
1121
1129
|
);
|
|
1122
1130
|
if (!response.ok) {
|
|
1123
1131
|
throw new TasksApiError(`upload de anexo rejeitado (HTTP ${response.status})`, response.status);
|
|
@@ -1139,6 +1147,74 @@ var TasksApi = class {
|
|
|
1139
1147
|
throw new TasksApiError(`confirma\xE7\xE3o de anexo rejeitada (HTTP ${response.status})`, response.status);
|
|
1140
1148
|
}
|
|
1141
1149
|
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Pede a URL pré-assinada de GET de um arquivo do formulário. 404 (não existe nesta run)
|
|
1152
|
+
* e 410 (já baixado/expirado) viram `TasksApiError` com o status — quem chama traduz.
|
|
1153
|
+
*/
|
|
1154
|
+
async requestFileDownload(runId, fileId, signal) {
|
|
1155
|
+
const response = await fetchWithRetry(
|
|
1156
|
+
() => withRequestTimeout(
|
|
1157
|
+
UPDATE_TIMEOUT_MS,
|
|
1158
|
+
signal,
|
|
1159
|
+
(timeoutSignal) => fetch(`${this.baseUrl}/${encodeURIComponent(runId)}/files/${encodeURIComponent(fileId)}/download`, {
|
|
1160
|
+
redirect: "error",
|
|
1161
|
+
method: "POST",
|
|
1162
|
+
headers: this.headers(),
|
|
1163
|
+
signal: timeoutSignal
|
|
1164
|
+
})
|
|
1165
|
+
),
|
|
1166
|
+
{ delays: FILE_RETRY_DELAYS_MS, logger: this.logger, label: `download do arquivo ${fileId}`, signal }
|
|
1167
|
+
);
|
|
1168
|
+
if (!response.ok) {
|
|
1169
|
+
throw new TasksApiError(`download de arquivo rejeitado (HTTP ${response.status})`, response.status);
|
|
1170
|
+
}
|
|
1171
|
+
const body = await response.json().catch(() => null);
|
|
1172
|
+
if (typeof body?.url !== "string") {
|
|
1173
|
+
throw new TasksApiError("resposta inv\xE1lida do download de arquivo (url ausente)", 0);
|
|
1174
|
+
}
|
|
1175
|
+
return {
|
|
1176
|
+
url: body.url,
|
|
1177
|
+
expires_in: typeof body.expires_in === "number" ? body.expires_in : 0,
|
|
1178
|
+
name: typeof body.name === "string" ? body.name : "",
|
|
1179
|
+
size: typeof body.size === "number" ? body.size : -1,
|
|
1180
|
+
content_type: typeof body.content_type === "string" ? body.content_type : ""
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* GET do binário direto no S3 — deliberadamente sem `authorization` (a autorização
|
|
1185
|
+
* está na assinatura da URL; mandar o client secret para fora da API seria vazamento).
|
|
1186
|
+
* Devolve a Response com o corpo ainda não lido: quem chama faz o streaming para o disco.
|
|
1187
|
+
*/
|
|
1188
|
+
async fetchFile(url, signal) {
|
|
1189
|
+
const response = await fetchWithRetry(() => fetch(url, { redirect: "error", method: "GET", signal }), {
|
|
1190
|
+
delays: FILE_RETRY_DELAYS_MS,
|
|
1191
|
+
logger: this.logger,
|
|
1192
|
+
label: "download de arquivo",
|
|
1193
|
+
signal
|
|
1194
|
+
});
|
|
1195
|
+
if (!response.ok) {
|
|
1196
|
+
throw new TasksApiError(`download de arquivo falhou (HTTP ${response.status})`, response.status);
|
|
1197
|
+
}
|
|
1198
|
+
return response;
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Confirma o download: o servidor apaga o objeto do S3 na hora. Idempotente (204);
|
|
1202
|
+
* 404/410 também significam "não há mais nada a apagar" e contam como confirmado.
|
|
1203
|
+
*/
|
|
1204
|
+
async confirmFileDownload(runId, fileId) {
|
|
1205
|
+
const response = await fetchWithRetry(
|
|
1206
|
+
() => fetch(`${this.baseUrl}/${encodeURIComponent(runId)}/files/${encodeURIComponent(fileId)}`, {
|
|
1207
|
+
redirect: "error",
|
|
1208
|
+
method: "DELETE",
|
|
1209
|
+
headers: this.headers(),
|
|
1210
|
+
signal: AbortSignal.timeout(UPDATE_TIMEOUT_MS)
|
|
1211
|
+
}),
|
|
1212
|
+
{ delays: FILE_RETRY_DELAYS_MS, logger: this.logger, label: `confirma\xE7\xE3o do arquivo ${fileId}` }
|
|
1213
|
+
);
|
|
1214
|
+
if (!response.ok && response.status !== 404 && response.status !== 410) {
|
|
1215
|
+
throw new TasksApiError(`confirma\xE7\xE3o de download rejeitada (HTTP ${response.status})`, response.status);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1142
1218
|
};
|
|
1143
1219
|
|
|
1144
1220
|
// src/tasks/backoff.ts
|
|
@@ -1273,6 +1349,255 @@ async function eachItem(list, handler, options, sink) {
|
|
|
1273
1349
|
return result;
|
|
1274
1350
|
}
|
|
1275
1351
|
|
|
1352
|
+
// src/tasks/files.ts
|
|
1353
|
+
var LOCAL_FILES_DIR_ENV = "HOBOTS_LOCAL_FILES_DIR";
|
|
1354
|
+
var FILE_DOWNLOAD_IDLE_TIMEOUT_MS = 6e4;
|
|
1355
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1356
|
+
function isFileRef(value) {
|
|
1357
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1358
|
+
const ref = value;
|
|
1359
|
+
return typeof ref.id === "string" && typeof ref.name === "string" && typeof ref.size === "number" && typeof ref.contentType === "string" && (ref.storage === "s3" || ref.storage === "local");
|
|
1360
|
+
}
|
|
1361
|
+
function fileField(params, field) {
|
|
1362
|
+
if (!Object.prototype.hasOwnProperty.call(params, field)) return void 0;
|
|
1363
|
+
const value = params[field];
|
|
1364
|
+
if (isFileRef(value)) return value;
|
|
1365
|
+
if (Array.isArray(value) && value.every(isFileRef)) return value;
|
|
1366
|
+
return void 0;
|
|
1367
|
+
}
|
|
1368
|
+
function collectFileRefs(params) {
|
|
1369
|
+
const refs = [];
|
|
1370
|
+
for (const field of Object.keys(params)) {
|
|
1371
|
+
const value = fileField(params, field);
|
|
1372
|
+
if (value !== void 0) refs.push(...Array.isArray(value) ? value : [value]);
|
|
1373
|
+
}
|
|
1374
|
+
return refs;
|
|
1375
|
+
}
|
|
1376
|
+
function safeFileName(name, fallback) {
|
|
1377
|
+
const cleaned = name.replace(/^.*[\\/]/, "").replace(/[\u0000-\u001f<>:"|?*]/g, "_").trim();
|
|
1378
|
+
return cleaned === "" || cleaned === "." || cleaned === ".." ? fallback : cleaned;
|
|
1379
|
+
}
|
|
1380
|
+
function isInsideDir(root, candidate, sep) {
|
|
1381
|
+
const base = root.endsWith(sep) ? root : root + sep;
|
|
1382
|
+
return candidate.startsWith(base) && candidate.length > base.length;
|
|
1383
|
+
}
|
|
1384
|
+
function unavailableLocal(ref) {
|
|
1385
|
+
return new Error(`arquivo "${ref.name}" enviado pelo Studio de outro computador ou j\xE1 consumido`);
|
|
1386
|
+
}
|
|
1387
|
+
function errorCode(error) {
|
|
1388
|
+
return error?.code;
|
|
1389
|
+
}
|
|
1390
|
+
var RunFileStore = class {
|
|
1391
|
+
constructor(deps) {
|
|
1392
|
+
this.deps = deps;
|
|
1393
|
+
this.files = {
|
|
1394
|
+
list: () => collectFileRefs(deps.params),
|
|
1395
|
+
get: (field) => fileField(deps.params, field),
|
|
1396
|
+
// async: erro de uso (campo sem arquivo, valor inválido) chega como Promise rejeitada, nunca síncrono
|
|
1397
|
+
download: async (refOrField, options) => this.download(this.single(refOrField), options?.dest),
|
|
1398
|
+
downloadAll: async (field, options) => this.downloadAll(field, options?.dest),
|
|
1399
|
+
read: async (refOrField) => this.read(this.single(refOrField))
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
deps;
|
|
1403
|
+
files;
|
|
1404
|
+
tempDir;
|
|
1405
|
+
/** Apaga a pasta temporária da execução (cópias que o bot não levou para `dest`). Nunca lança. */
|
|
1406
|
+
async cleanup() {
|
|
1407
|
+
const pending = this.tempDir;
|
|
1408
|
+
this.tempDir = void 0;
|
|
1409
|
+
if (!pending) return;
|
|
1410
|
+
try {
|
|
1411
|
+
const { rm } = await import("fs/promises");
|
|
1412
|
+
await rm(await pending, { recursive: true, force: true });
|
|
1413
|
+
} catch {
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
field(field) {
|
|
1417
|
+
const value = fileField(this.deps.params, field);
|
|
1418
|
+
if (value === void 0) throw new Error(`campo "${field}" n\xE3o tem arquivo nos params`);
|
|
1419
|
+
return value;
|
|
1420
|
+
}
|
|
1421
|
+
single(refOrField) {
|
|
1422
|
+
if (typeof refOrField === "string") {
|
|
1423
|
+
const value = this.field(refOrField);
|
|
1424
|
+
if (Array.isArray(value)) {
|
|
1425
|
+
throw new Error(`campo "${refOrField}" tem v\xE1rios arquivos \u2014 use downloadAll("${refOrField}") ou passe um item da lista`);
|
|
1426
|
+
}
|
|
1427
|
+
return value;
|
|
1428
|
+
}
|
|
1429
|
+
if (!isFileRef(refOrField)) throw new TypeError("esperado um FileRef ou o nome de um campo de arquivo");
|
|
1430
|
+
return refOrField;
|
|
1431
|
+
}
|
|
1432
|
+
async downloadAll(field, dest) {
|
|
1433
|
+
const value = this.field(field);
|
|
1434
|
+
const refs = Array.isArray(value) ? value : [value];
|
|
1435
|
+
const paths = [];
|
|
1436
|
+
for (const ref of refs) paths.push(await this.download(ref, dest, true));
|
|
1437
|
+
return paths;
|
|
1438
|
+
}
|
|
1439
|
+
async read(ref) {
|
|
1440
|
+
const { readFile, rm } = await import("fs/promises");
|
|
1441
|
+
const { dirname } = await import("path");
|
|
1442
|
+
const path = await this.download(ref, void 0);
|
|
1443
|
+
try {
|
|
1444
|
+
return await readFile(path);
|
|
1445
|
+
} finally {
|
|
1446
|
+
await rm(dirname(path), { recursive: true, force: true });
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
async download(ref, dest, destIsDir = false) {
|
|
1450
|
+
this.checkCanceled();
|
|
1451
|
+
const target = await this.resolveTarget(ref, dest, destIsDir);
|
|
1452
|
+
if (ref.storage === "local") await this.copyLocal(ref, target);
|
|
1453
|
+
else await this.downloadS3(ref, target);
|
|
1454
|
+
return target;
|
|
1455
|
+
}
|
|
1456
|
+
checkCanceled() {
|
|
1457
|
+
if (this.deps.signal.aborted) throw runCanceledError();
|
|
1458
|
+
}
|
|
1459
|
+
runTempDir() {
|
|
1460
|
+
this.tempDir ??= (async () => {
|
|
1461
|
+
const { mkdtemp } = await import("fs/promises");
|
|
1462
|
+
const { tmpdir } = await import("os");
|
|
1463
|
+
const { join } = await import("path");
|
|
1464
|
+
return mkdtemp(join(tmpdir(), "hobots-files-"));
|
|
1465
|
+
})();
|
|
1466
|
+
return this.tempDir;
|
|
1467
|
+
}
|
|
1468
|
+
async resolveTarget(ref, dest, destIsDir) {
|
|
1469
|
+
const { mkdir, mkdtemp, stat } = await import("fs/promises");
|
|
1470
|
+
const { dirname, join, resolve: resolve2 } = await import("path");
|
|
1471
|
+
const name = safeFileName(ref.name, "arquivo");
|
|
1472
|
+
if (dest === void 0) {
|
|
1473
|
+
return join(await mkdtemp(join(await this.runTempDir(), "f-")), name);
|
|
1474
|
+
}
|
|
1475
|
+
const isDir = destIsDir || dest.endsWith("/") || dest.endsWith("\\") || (await stat(dest).catch(() => void 0))?.isDirectory() === true;
|
|
1476
|
+
if (isDir) {
|
|
1477
|
+
await mkdir(dest, { recursive: true });
|
|
1478
|
+
return resolve2(dest, name);
|
|
1479
|
+
}
|
|
1480
|
+
await mkdir(dirname(resolve2(dest)), { recursive: true });
|
|
1481
|
+
return resolve2(dest);
|
|
1482
|
+
}
|
|
1483
|
+
/** storage 's3': URL pré-assinada → GET em streaming → confere o tamanho → só então DELETE. */
|
|
1484
|
+
async downloadS3(ref, target) {
|
|
1485
|
+
const { api, runId, signal } = this.deps;
|
|
1486
|
+
let expected;
|
|
1487
|
+
let url;
|
|
1488
|
+
try {
|
|
1489
|
+
const info = await api.requestFileDownload(runId, ref.id, signal);
|
|
1490
|
+
expected = info.size >= 0 ? info.size : ref.size;
|
|
1491
|
+
url = info.url;
|
|
1492
|
+
} catch (error) {
|
|
1493
|
+
throw this.translate(error, ref);
|
|
1494
|
+
}
|
|
1495
|
+
const { open, rename, rm } = await import("fs/promises");
|
|
1496
|
+
const part = `${target}.part`;
|
|
1497
|
+
const idleTimeoutMs = this.deps.idleTimeoutMs ?? FILE_DOWNLOAD_IDLE_TIMEOUT_MS;
|
|
1498
|
+
const controller = new AbortController();
|
|
1499
|
+
let timedOut = false;
|
|
1500
|
+
let timer;
|
|
1501
|
+
const arm = () => {
|
|
1502
|
+
clearTimeout(timer);
|
|
1503
|
+
timer = setTimeout(() => {
|
|
1504
|
+
timedOut = true;
|
|
1505
|
+
controller.abort();
|
|
1506
|
+
}, idleTimeoutMs);
|
|
1507
|
+
};
|
|
1508
|
+
const onCancel = () => controller.abort();
|
|
1509
|
+
signal.addEventListener("abort", onCancel, { once: true });
|
|
1510
|
+
try {
|
|
1511
|
+
arm();
|
|
1512
|
+
const response = await api.fetchFile(url, controller.signal);
|
|
1513
|
+
const handle = await open(part, "w");
|
|
1514
|
+
let written = 0;
|
|
1515
|
+
try {
|
|
1516
|
+
const reader = response.body?.getReader();
|
|
1517
|
+
const stopReading = () => void reader?.cancel().catch(() => void 0);
|
|
1518
|
+
controller.signal.addEventListener("abort", stopReading, { once: true });
|
|
1519
|
+
for (; ; ) {
|
|
1520
|
+
const chunk = reader ? await reader.read() : { done: true, value: void 0 };
|
|
1521
|
+
if (chunk.done) break;
|
|
1522
|
+
arm();
|
|
1523
|
+
written += chunk.value.byteLength;
|
|
1524
|
+
if (written > expected) {
|
|
1525
|
+
stopReading();
|
|
1526
|
+
break;
|
|
1527
|
+
}
|
|
1528
|
+
await handle.write(chunk.value);
|
|
1529
|
+
}
|
|
1530
|
+
} finally {
|
|
1531
|
+
await handle.close();
|
|
1532
|
+
}
|
|
1533
|
+
if (written !== expected) {
|
|
1534
|
+
throw new Error(`tamanho divergente no download de "${ref.name}": recebidos ${written} bytes, esperados ${expected}`);
|
|
1535
|
+
}
|
|
1536
|
+
await rename(part, target);
|
|
1537
|
+
} catch (error) {
|
|
1538
|
+
await rm(part, { force: true });
|
|
1539
|
+
if (signal.aborted) throw runCanceledError();
|
|
1540
|
+
if (timedOut) {
|
|
1541
|
+
throw new Error(`download de "${ref.name}" parado h\xE1 ${idleTimeoutMs / 1e3}s \u2014 tente de novo`);
|
|
1542
|
+
}
|
|
1543
|
+
throw this.translate(error, ref);
|
|
1544
|
+
} finally {
|
|
1545
|
+
clearTimeout(timer);
|
|
1546
|
+
signal.removeEventListener("abort", onCancel);
|
|
1547
|
+
}
|
|
1548
|
+
try {
|
|
1549
|
+
await api.confirmFileDownload(runId, ref.id);
|
|
1550
|
+
} catch (error) {
|
|
1551
|
+
this.deps.warn(`arquivo "${ref.name}" baixado, mas a confirma\xE7\xE3o ao servidor falhou: ${String(error)}`);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
/** storage 'local': copia o único arquivo de `$HOBOTS_LOCAL_FILES_DIR/<id>/` e apaga a pasta de origem. */
|
|
1555
|
+
async copyLocal(ref, target) {
|
|
1556
|
+
const base = process.env[LOCAL_FILES_DIR_ENV];
|
|
1557
|
+
if (!base) throw unavailableLocal(ref);
|
|
1558
|
+
const { copyFile, readdir, rename, rm, stat } = await import("fs/promises");
|
|
1559
|
+
const { join, resolve: resolve2, sep } = await import("path");
|
|
1560
|
+
const root = resolve2(base);
|
|
1561
|
+
const folder = resolve2(root, ref.id);
|
|
1562
|
+
if (!isInsideDir(root, folder, sep) || !UUID_RE.test(ref.id)) {
|
|
1563
|
+
throw new Error(`id de arquivo local inv\xE1lido: ${JSON.stringify(ref.id)}`);
|
|
1564
|
+
}
|
|
1565
|
+
let names;
|
|
1566
|
+
try {
|
|
1567
|
+
names = (await readdir(folder, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name);
|
|
1568
|
+
} catch (error) {
|
|
1569
|
+
if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") throw unavailableLocal(ref);
|
|
1570
|
+
throw error;
|
|
1571
|
+
}
|
|
1572
|
+
if (names.length === 0) throw unavailableLocal(ref);
|
|
1573
|
+
if (names.length > 1) throw new Error(`pasta do arquivo "${ref.name}" tem ${names.length} arquivos \u2014 esperado 1`);
|
|
1574
|
+
this.checkCanceled();
|
|
1575
|
+
const part = `${target}.part`;
|
|
1576
|
+
try {
|
|
1577
|
+
await copyFile(join(folder, names[0]), part);
|
|
1578
|
+
const { size } = await stat(part);
|
|
1579
|
+
if (size !== ref.size) {
|
|
1580
|
+
throw new Error(`tamanho divergente na c\xF3pia de "${ref.name}": copiados ${size} bytes, esperados ${ref.size}`);
|
|
1581
|
+
}
|
|
1582
|
+
await rename(part, target);
|
|
1583
|
+
} catch (error) {
|
|
1584
|
+
await rm(part, { force: true });
|
|
1585
|
+
throw error;
|
|
1586
|
+
}
|
|
1587
|
+
await rm(folder, { recursive: true, force: true });
|
|
1588
|
+
}
|
|
1589
|
+
translate(error, ref) {
|
|
1590
|
+
if (this.deps.signal.aborted) return runCanceledError();
|
|
1591
|
+
if (error instanceof TasksApiError && error.status === 410) {
|
|
1592
|
+
return new TasksApiError(`arquivo "${ref.name}" j\xE1 baixado ou expirado`, 410);
|
|
1593
|
+
}
|
|
1594
|
+
if (error instanceof TasksApiError && error.status === 404) {
|
|
1595
|
+
return new TasksApiError(`arquivo "${ref.name}" n\xE3o encontrado nesta execu\xE7\xE3o`, 404);
|
|
1596
|
+
}
|
|
1597
|
+
return error;
|
|
1598
|
+
}
|
|
1599
|
+
};
|
|
1600
|
+
|
|
1276
1601
|
// src/retry.ts
|
|
1277
1602
|
var DEFAULT_RETRIES = 3;
|
|
1278
1603
|
var DEFAULT_DELAY_MS = 1e3;
|
|
@@ -1511,12 +1836,14 @@ var RunReporter = class {
|
|
|
1511
1836
|
);
|
|
1512
1837
|
const signal = this.abortController.signal;
|
|
1513
1838
|
const sleep2 = (ms) => cancelableSleep(ms, signal);
|
|
1839
|
+
this.fileStore = new RunFileStore({ api, runId, params, signal, warn: (message) => this.log("warning", message) });
|
|
1514
1840
|
this.context = {
|
|
1515
1841
|
runId,
|
|
1516
1842
|
params,
|
|
1517
1843
|
log,
|
|
1518
1844
|
items,
|
|
1519
1845
|
progress,
|
|
1846
|
+
files: this.fileStore.files,
|
|
1520
1847
|
transaction: tx,
|
|
1521
1848
|
flush: (timeoutMs) => this.flush(timeoutMs),
|
|
1522
1849
|
signal,
|
|
@@ -1571,6 +1898,12 @@ var RunReporter = class {
|
|
|
1571
1898
|
progressDirty = false;
|
|
1572
1899
|
progressTimer;
|
|
1573
1900
|
progressWarned = false;
|
|
1901
|
+
/** Arquivos do formulário desta run (`ctx.files`) — a pasta temporária é apagada no fim. */
|
|
1902
|
+
fileStore;
|
|
1903
|
+
/** Apaga as cópias de arquivo que ficaram na pasta temporária da run. Nunca lança. */
|
|
1904
|
+
cleanupFiles() {
|
|
1905
|
+
return this.fileStore.cleanup();
|
|
1906
|
+
}
|
|
1574
1907
|
get isDead() {
|
|
1575
1908
|
return this.dead;
|
|
1576
1909
|
}
|
|
@@ -2433,6 +2766,7 @@ async function executeRun(input) {
|
|
|
2433
2766
|
}
|
|
2434
2767
|
await reporter.finalize();
|
|
2435
2768
|
} finally {
|
|
2769
|
+
await reporter.cleanupFiles();
|
|
2436
2770
|
tracker.remove(reporter);
|
|
2437
2771
|
}
|
|
2438
2772
|
}
|
|
@@ -2888,7 +3222,9 @@ function resolveRunOptions(options, mode, logger) {
|
|
|
2888
3222
|
}
|
|
2889
3223
|
function instanceInfo(rt) {
|
|
2890
3224
|
return {
|
|
3225
|
+
/* v8 ignore start -- defensivo: o boot define o instanceId antes de qualquer poll/criação de run */
|
|
2891
3226
|
instance_id: rt.instanceId ?? "unknown",
|
|
3227
|
+
/* v8 ignore stop */
|
|
2892
3228
|
version: SDK_VERSION,
|
|
2893
3229
|
host: hostname(),
|
|
2894
3230
|
// ambiente da instância: junto do heartbeat, é como o servidor sabe onde o robô roda
|
|
@@ -3065,6 +3401,14 @@ var fallbackItems = {
|
|
|
3065
3401
|
each: (list, handler, options = {}) => eachItem(list, handler, options, fallbackEachSink)
|
|
3066
3402
|
};
|
|
3067
3403
|
var fallbackProgress = { total: noop, advance: noop, set: noop, current: 0, snapshot: null };
|
|
3404
|
+
var outsideRunFiles = () => Promise.reject(new Error("hobots.files s\xF3 baixa arquivos dentro de uma execu\xE7\xE3o (handler do register()/start())"));
|
|
3405
|
+
var fallbackFiles = {
|
|
3406
|
+
list: () => [],
|
|
3407
|
+
get: () => void 0,
|
|
3408
|
+
download: outsideRunFiles,
|
|
3409
|
+
downloadAll: outsideRunFiles,
|
|
3410
|
+
read: outsideRunFiles
|
|
3411
|
+
};
|
|
3068
3412
|
var neverAborts = new AbortController().signal;
|
|
3069
3413
|
var detachedTransaction;
|
|
3070
3414
|
function resolve(member) {
|
|
@@ -3086,6 +3430,7 @@ var hobots = Object.defineProperties({}, {
|
|
|
3086
3430
|
log: getter("log", () => fallbackLog),
|
|
3087
3431
|
items: getter("items", () => fallbackItems),
|
|
3088
3432
|
progress: getter("progress", () => fallbackProgress),
|
|
3433
|
+
files: getter("files", () => fallbackFiles),
|
|
3089
3434
|
transaction: getter("transaction", () => detachedTransaction ??= new Transaction(void 0, "hobots.detached")),
|
|
3090
3435
|
signal: getter("signal", () => neverAborts),
|
|
3091
3436
|
throwIfCanceled: method(() => resolve("throwIfCanceled")?.throwIfCanceled()),
|
|
@@ -3106,7 +3451,8 @@ var hobots = Object.defineProperties({}, {
|
|
|
3106
3451
|
}),
|
|
3107
3452
|
close: method((timeoutMs) => close(timeoutMs)),
|
|
3108
3453
|
captureException: method((error, hint) => captureException(error, hint)),
|
|
3109
|
-
image: method(image)
|
|
3454
|
+
image: method(image),
|
|
3455
|
+
isFileRef: method(isFileRef)
|
|
3110
3456
|
});
|
|
3111
3457
|
function installGlobalHobots() {
|
|
3112
3458
|
if (globalThis.hobots === hobots) return;
|
|
@@ -3118,6 +3464,7 @@ var close2 = hobots.close;
|
|
|
3118
3464
|
var captureException2 = hobots.captureException;
|
|
3119
3465
|
var retry = hobots.retry;
|
|
3120
3466
|
var image2 = hobots.image;
|
|
3467
|
+
var isFileRef2 = hobots.isFileRef;
|
|
3121
3468
|
|
|
3122
3469
|
// src/index.ts
|
|
3123
3470
|
var src_default = hobots;
|