create-panal-agent 0.10.0 → 0.12.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 +18 -0
- package/dist/i18n.js +20 -10
- package/dist/index.js +8 -5
- package/package.json +2 -2
- package/template/_package.json +1 -1
- package/template/src/agent.ts +188 -101
- package/template/src/memoria.ts +136 -0
- package/template/src/reintento.ts +68 -0
- package/template/src/server.ts +311 -5
package/template/src/server.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:ht
|
|
|
25
25
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
26
26
|
import { join } from 'node:path';
|
|
27
27
|
import {
|
|
28
|
+
MAX_FILE_BYTES,
|
|
28
29
|
appendFilesManifest,
|
|
29
30
|
assertCanServe,
|
|
30
31
|
buildQuote,
|
|
@@ -32,6 +33,8 @@ import {
|
|
|
32
33
|
LoopDetected,
|
|
33
34
|
MAINNET_ADDRESSES,
|
|
34
35
|
monad,
|
|
36
|
+
matchAttachment,
|
|
37
|
+
parseAttachmentsManifest,
|
|
35
38
|
parseEnvelope,
|
|
36
39
|
parsePaymentHeader,
|
|
37
40
|
permitNonce,
|
|
@@ -39,6 +42,7 @@ import {
|
|
|
39
42
|
sanitizeFileName,
|
|
40
43
|
TaskStatus,
|
|
41
44
|
verifyAndSettle,
|
|
45
|
+
type AttachedFile,
|
|
42
46
|
type CallEnvelope,
|
|
43
47
|
type DeliveredFile,
|
|
44
48
|
type PermitDomain,
|
|
@@ -47,8 +51,9 @@ import { privateKeyToAccount } from 'viem/accounts';
|
|
|
47
51
|
import { isAddress, keccak256, parseEther, toBytes, verifyMessage } from 'viem';
|
|
48
52
|
import type { Address } from 'viem';
|
|
49
53
|
import { handleTask } from './agent.js';
|
|
50
|
-
import type { TaskContext, TaskFile, TaskResult } from './agent.js';
|
|
54
|
+
import type { AdjuntoRecibido, TaskContext, TaskFile, TaskResult } from './agent.js';
|
|
51
55
|
import { arrancarVigilante } from './vigilante.js';
|
|
56
|
+
import { historialParaElModelo, recordarTurno, type Turno } from './memoria.js';
|
|
52
57
|
|
|
53
58
|
const PORT = Number(process.env.PORT ?? 8787);
|
|
54
59
|
const DATA_DIR = process.env.DATA_DIR ?? './data';
|
|
@@ -193,6 +198,14 @@ mkdirSync(DATA_DIR, { recursive: true });
|
|
|
193
198
|
const resultPath = (taskId: bigint) => join(DATA_DIR, `result-${taskId}.txt`);
|
|
194
199
|
/** Carpeta de los archivos de una tarea. Una por tarea, para no mezclarlas. */
|
|
195
200
|
const filesDir = (taskId: bigint) => join(DATA_DIR, 'files', taskId.toString());
|
|
201
|
+
/**
|
|
202
|
+
* Carpeta de lo que MANDA el cliente, separada de lo que entrega el agente.
|
|
203
|
+
*
|
|
204
|
+
* Mezclarlas sería servir por `/files/:id/:name` un archivo que subió el
|
|
205
|
+
* cliente como si fuera parte de la entrega, con su hash anclado y todo. No lo
|
|
206
|
+
* es: son las dos direcciones del mismo mecanismo y no se tocan.
|
|
207
|
+
*/
|
|
208
|
+
const inboxDir = (taskId: bigint) => join(DATA_DIR, 'inbox', taskId.toString());
|
|
196
209
|
|
|
197
210
|
function saveResult(taskId: bigint, text: string): void {
|
|
198
211
|
writeFileSync(resultPath(taskId), text, 'utf8');
|
|
@@ -271,6 +284,76 @@ function normalizarSalida(salida: TaskResult): { text: string; files: TaskFile[]
|
|
|
271
284
|
return { text: salida.text, files: salida.files ?? [] };
|
|
272
285
|
}
|
|
273
286
|
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
// Adjuntos: lo que el cliente manda CON el encargo
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
//
|
|
291
|
+
// El brief queda cerrado al contratar —el escrow ancla su keccak256 y más
|
|
292
|
+
// abajo se rechaza cualquier texto que no lo dé—, así que una foto no puede
|
|
293
|
+
// viajar dentro. Lo que viaja dentro es su HASH, anunciado en un bloque
|
|
294
|
+
// `[panal-attach/1]`. Los bytes suben después, por `POST /upload/:taskId`.
|
|
295
|
+
//
|
|
296
|
+
// De ahí sale la única regla que hay que recordar aquí: SÓLO SE ESCRIBE LO QUE
|
|
297
|
+
// EL ENCARGO ANUNCIÓ. Cualquier otro byte se rechaza sin llegar al disco. El
|
|
298
|
+
// número de una tarea es público, y sin esa guarda tu agente sería un almacén
|
|
299
|
+
// gratis para cualquiera que sepa contar.
|
|
300
|
+
|
|
301
|
+
const adjuntoPath = (taskId: bigint, nombre: string) => join(inboxDir(taskId), nombre);
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Repasa qué adjuntos anunciados están ya en disco y cuáles faltan.
|
|
305
|
+
*
|
|
306
|
+
* El hash se comprueba AL LEER y no sólo al escribir. Entre las dos cosas hay
|
|
307
|
+
* un disco, a veces un reinicio y a veces un volumen que se vuelve a montar; y
|
|
308
|
+
* un trabajo hecho a partir de un archivo corrupto es peor que un trabajo sin
|
|
309
|
+
* hacer, porque se entrega y se ancla.
|
|
310
|
+
*/
|
|
311
|
+
function repasarAdjuntos(
|
|
312
|
+
taskId: bigint,
|
|
313
|
+
brief: string,
|
|
314
|
+
): { recibidos: AdjuntoRecibido[]; faltan: AttachedFile[] } {
|
|
315
|
+
const recibidos: AdjuntoRecibido[] = [];
|
|
316
|
+
const faltan: AttachedFile[] = [];
|
|
317
|
+
|
|
318
|
+
for (const anunciado of parseAttachmentsManifest(brief)) {
|
|
319
|
+
let bytes: Buffer;
|
|
320
|
+
try {
|
|
321
|
+
bytes = readFileSync(adjuntoPath(taskId, anunciado.name));
|
|
322
|
+
} catch {
|
|
323
|
+
faltan.push(anunciado);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (!matchAttachment([anunciado], bytes, anunciado.name)) {
|
|
327
|
+
console.error(`[panal] #${taskId} el adjunto "${anunciado.name}" en disco no da su hash: se pide de nuevo`);
|
|
328
|
+
faltan.push(anunciado);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
recibidos.push({
|
|
332
|
+
name: anunciado.name,
|
|
333
|
+
...(anunciado.mime ? { mime: anunciado.mime } : {}),
|
|
334
|
+
bytes: new Uint8Array(bytes),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
return { recibidos, faltan };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Escribe un adjunto ya verificado. */
|
|
341
|
+
function guardarAdjunto(taskId: bigint, nombre: string, bytes: Uint8Array): void {
|
|
342
|
+
mkdirSync(inboxDir(taskId), { recursive: true });
|
|
343
|
+
writeFileSync(adjuntoPath(taskId, nombre), bytes);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* El sobre de una tarea que espera adjuntos.
|
|
348
|
+
*
|
|
349
|
+
* Cuando el encargo viene de otro agente y trae adjuntos, entre el brief y la
|
|
350
|
+
* última subida hay un rato en el que no se puede trabajar. El sobre lleva el
|
|
351
|
+
* presupuesto y el camino de la cadena, y perderlo significaría reanudar sin
|
|
352
|
+
* ellos. En memoria a propósito: si el proceso muere, la cadena que lo trajo
|
|
353
|
+
* murió con él, y reanudar sin sobre es exactamente lo que hace el vigilante.
|
|
354
|
+
*/
|
|
355
|
+
const sobrePendiente = new Map<string, CallEnvelope>();
|
|
356
|
+
|
|
274
357
|
/** Tareas que se están procesando ahora mismo: evita trabajar dos veces. */
|
|
275
358
|
const inFlight = new Set<string>();
|
|
276
359
|
|
|
@@ -415,7 +498,14 @@ async function credencialValida(
|
|
|
415
498
|
* necesita ver en los logs.
|
|
416
499
|
*/
|
|
417
500
|
function contexto(
|
|
418
|
-
base: {
|
|
501
|
+
base: {
|
|
502
|
+
taskId: bigint | null;
|
|
503
|
+
client: string;
|
|
504
|
+
amount: bigint;
|
|
505
|
+
deadline: bigint;
|
|
506
|
+
adjuntos: AdjuntoRecibido[];
|
|
507
|
+
historial: Turno[];
|
|
508
|
+
},
|
|
419
509
|
sobre: CallEnvelope | null,
|
|
420
510
|
): TaskContext {
|
|
421
511
|
return {
|
|
@@ -453,10 +543,39 @@ async function work(taskId: bigint, brief: string, sobre: CallEnvelope | null):
|
|
|
453
543
|
// Lo PRIMERO, antes de trabajar: si el proceso muere a mitad, esto es lo
|
|
454
544
|
// único que permite retomarlo. Guardarlo después sería guardarlo nunca.
|
|
455
545
|
saveBrief(taskId, brief);
|
|
546
|
+
|
|
547
|
+
// Si el encargo anuncia adjuntos, no se empieza hasta tenerlos todos.
|
|
548
|
+
//
|
|
549
|
+
// La guarda va AQUÍ y no en la ruta HTTP porque el vigilante también llama
|
|
550
|
+
// a `work` —al retomar una tarea tras un reinicio— y ahí no hay petición
|
|
551
|
+
// que mirar. Sin esto, un agente que se reinicia entre el brief y la
|
|
552
|
+
// subida se pondría a trabajar sin la foto, entregaría lo que pudiera y
|
|
553
|
+
// anclaría ese resultado a medias en la cadena.
|
|
554
|
+
const { recibidos, faltan } = repasarAdjuntos(taskId, brief);
|
|
555
|
+
if (faltan.length > 0) {
|
|
556
|
+
console.log(
|
|
557
|
+
`[panal] #${taskId} en espera de ${faltan.length} adjunto(s): ${faltan.map((f) => f.name).join(', ')}`,
|
|
558
|
+
);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (recibidos.length > 0) console.log(`[panal] #${taskId} con ${recibidos.length} adjunto(s) del cliente`);
|
|
562
|
+
|
|
456
563
|
const task = await leerTarea(taskId);
|
|
457
564
|
const salida = await handleTask(
|
|
458
565
|
brief,
|
|
459
|
-
contexto(
|
|
566
|
+
contexto(
|
|
567
|
+
{
|
|
568
|
+
taskId,
|
|
569
|
+
client: task.client,
|
|
570
|
+
amount: task.amount,
|
|
571
|
+
deadline: task.deadline,
|
|
572
|
+
adjuntos: recibidos,
|
|
573
|
+
// Un encargo del escrow no arrastra conversación: se paga, se
|
|
574
|
+
// entrega una vez y se aprueba. La memoria es de los chats.
|
|
575
|
+
historial: [],
|
|
576
|
+
},
|
|
577
|
+
sobre,
|
|
578
|
+
),
|
|
460
579
|
);
|
|
461
580
|
|
|
462
581
|
// Tu handleTask puede devolver un texto a secas —lo normal— o un texto con
|
|
@@ -563,6 +682,25 @@ function json(res: ServerResponse, status: number, body: unknown): void {
|
|
|
563
682
|
res.end(JSON.stringify(body));
|
|
564
683
|
}
|
|
565
684
|
|
|
685
|
+
/**
|
|
686
|
+
* El cuerpo en crudo, con su propio tope.
|
|
687
|
+
*
|
|
688
|
+
* Va aparte de `readBody` a propósito: los 256 KB de MAX_BODY protegen las
|
|
689
|
+
* rutas de texto y tienen que seguir siendo pequeños. Una foto no cabe ahí, y
|
|
690
|
+
* subirle el tope a todas las rutas para que quepa sería abrir la puerta que
|
|
691
|
+
* ese límite cierra.
|
|
692
|
+
*/
|
|
693
|
+
async function readBodyBytes(req: IncomingMessage, max: number): Promise<Buffer> {
|
|
694
|
+
const chunks: Buffer[] = [];
|
|
695
|
+
let total = 0;
|
|
696
|
+
for await (const chunk of req) {
|
|
697
|
+
total += (chunk as Buffer).length;
|
|
698
|
+
if (total > max) throw new Error(`cuerpo demasiado grande (tope ${max} bytes)`);
|
|
699
|
+
chunks.push(chunk as Buffer);
|
|
700
|
+
}
|
|
701
|
+
return Buffer.concat(chunks);
|
|
702
|
+
}
|
|
703
|
+
|
|
566
704
|
async function readBody(req: IncomingMessage): Promise<string> {
|
|
567
705
|
const chunks: Buffer[] = [];
|
|
568
706
|
let total = 0;
|
|
@@ -744,9 +882,15 @@ const server = createServer((req, res) => {
|
|
|
744
882
|
res.setHeader('access-control-allow-methods', 'GET, POST, OPTIONS');
|
|
745
883
|
// Las credenciales van en cabeceras propias, y esas NO son simples: sin
|
|
746
884
|
// declararlas aquí el navegador bloquea la descarga en el preflight.
|
|
885
|
+
//
|
|
886
|
+
// Cada cabecera nueva hay que añadirla A ESTA LISTA. Se olvidó con
|
|
887
|
+
// `x-panal-filename` al añadir los adjuntos, y el efecto es de los que
|
|
888
|
+
// no se ven leyendo el código: el servidor está bien, la ruta está bien,
|
|
889
|
+
// y el navegador se niega a hacer la petición sin dejar rastro en el log
|
|
890
|
+
// del agente.
|
|
747
891
|
res.setHeader(
|
|
748
892
|
'access-control-allow-headers',
|
|
749
|
-
'content-type, x-panal-address, x-panal-signature, x-panal-expira, x-payment, x-payment-payer',
|
|
893
|
+
'content-type, x-panal-address, x-panal-signature, x-panal-expira, x-panal-filename, x-payment, x-payment-payer',
|
|
750
894
|
);
|
|
751
895
|
res.setHeader('access-control-max-age', '86400');
|
|
752
896
|
res.writeHead(204).end();
|
|
@@ -789,6 +933,15 @@ const server = createServer((req, res) => {
|
|
|
789
933
|
body: `{"brief": string (máx. ${MAX_BRIEF_CHARS} chars), "address": "0x…", "signature": "0x…"}`,
|
|
790
934
|
maxBriefChars: MAX_BRIEF_CHARS,
|
|
791
935
|
},
|
|
936
|
+
postAttachment: {
|
|
937
|
+
method: 'POST',
|
|
938
|
+
path: '/upload/:taskId',
|
|
939
|
+
signMessage: 'Panal brief #<taskId> (la MISMA firma que el encargo, no hace falta otra)',
|
|
940
|
+
body: 'los bytes en crudo; el nombre en la cabecera X-Panal-Filename',
|
|
941
|
+
howTo:
|
|
942
|
+
'anuncia cada adjunto en el brief con un bloque [panal-attach/1] ANTES de contratar, y sube los bytes aquí después. Sólo se aceptan los que el encargo anuncie.',
|
|
943
|
+
maxAttachmentBytes: MAX_FILE_BYTES,
|
|
944
|
+
},
|
|
792
945
|
getResult: {
|
|
793
946
|
method: 'GET',
|
|
794
947
|
path: '/result/:taskId',
|
|
@@ -888,7 +1041,23 @@ const server = createServer((req, res) => {
|
|
|
888
1041
|
try {
|
|
889
1042
|
const salida = await handleTask(
|
|
890
1043
|
prompt,
|
|
891
|
-
contexto(
|
|
1044
|
+
contexto(
|
|
1045
|
+
{
|
|
1046
|
+
taskId: null,
|
|
1047
|
+
client: leido.payment.payer,
|
|
1048
|
+
amount: cobro.amount,
|
|
1049
|
+
deadline: 0n,
|
|
1050
|
+
// Una llamada x402 es una pregunta y una respuesta: no hay tarea
|
|
1051
|
+
// donde anclar un adjunto, así que tampoco hay adjuntos.
|
|
1052
|
+
adjuntos: [],
|
|
1053
|
+
// Lo que ya se habló con ESTA persona. Quién es lo dice el pago:
|
|
1054
|
+
// firmó un permiso y el cobro se ejecutó en la cadena, así que
|
|
1055
|
+
// nadie puede continuar la conversación de otro sin pagar como
|
|
1056
|
+
// él. Por eso no hace falta autenticar nada aquí.
|
|
1057
|
+
historial: historialParaElModelo(DATA_DIR, leido.payment.payer),
|
|
1058
|
+
},
|
|
1059
|
+
sobre,
|
|
1060
|
+
),
|
|
892
1061
|
);
|
|
893
1062
|
// En una llamada x402 no hay tarea, así que no hay nada que anclar ni
|
|
894
1063
|
// ninguna firma con la que proteger una descarga: los archivos no
|
|
@@ -903,6 +1072,12 @@ const server = createServer((req, res) => {
|
|
|
903
1072
|
}
|
|
904
1073
|
res.setHeader('x-payment-tx', cobro.txHash);
|
|
905
1074
|
json(res, 200, { answer, paid: { txHash: cobro.txHash, amount: cobro.amount.toString(), asset: X402_TOKEN } });
|
|
1075
|
+
|
|
1076
|
+
// El turno se guarda AQUÍ, con las dos mitades y sólo si hubo
|
|
1077
|
+
// respuesta. Guardarlo antes de trabajar dejaría preguntas sin
|
|
1078
|
+
// contestar en la memoria, y la siguiente vez el modelo leería una
|
|
1079
|
+
// conversación en la que él se quedó callado.
|
|
1080
|
+
recordarTurno(DATA_DIR, leido.payment.payer, { pregunta: prompt, respuesta: answer, cuando: Date.now() });
|
|
906
1081
|
} catch (err) {
|
|
907
1082
|
console.error(`[x402] cobrado pero falló al responder: ${err instanceof Error ? err.message : err}`);
|
|
908
1083
|
json(res, 502, {
|
|
@@ -1006,12 +1181,143 @@ const server = createServer((req, res) => {
|
|
|
1006
1181
|
return;
|
|
1007
1182
|
}
|
|
1008
1183
|
|
|
1184
|
+
// El encargo se guarda YA, antes de contestar: la subida que viene
|
|
1185
|
+
// detrás lo necesita en disco para saber qué bytes puede aceptar.
|
|
1186
|
+
saveBrief(taskId, body.brief);
|
|
1187
|
+
const { faltan } = repasarAdjuntos(taskId, body.brief);
|
|
1188
|
+
if (faltan.length > 0) {
|
|
1189
|
+
// No es un error: es la otra mitad del encargo, que aún viene de
|
|
1190
|
+
// camino. Se contesta exactamente qué se espera para que el cliente lo
|
|
1191
|
+
// suba sin tener que adivinarlo.
|
|
1192
|
+
if (sobre) sobrePendiente.set(taskId.toString(), sobre);
|
|
1193
|
+
json(res, 202, {
|
|
1194
|
+
ok: true,
|
|
1195
|
+
faltanAdjuntos: faltan.map((f) => ({ name: f.name, size: f.size, hash: f.hash })),
|
|
1196
|
+
subirA: `/upload/${taskId}`,
|
|
1197
|
+
});
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1009
1201
|
json(res, 202, { ok: true });
|
|
1010
1202
|
// Sin await: el cliente no debería esperar a que termines de trabajar.
|
|
1011
1203
|
void work(taskId, body.brief, sobre);
|
|
1012
1204
|
return;
|
|
1013
1205
|
}
|
|
1014
1206
|
|
|
1207
|
+
// ---- El cliente sube los adjuntos que su encargo anunció ----------------
|
|
1208
|
+
//
|
|
1209
|
+
// Se firma UNA vez, con el mismo `Panal brief #<id>` que abrió el encargo.
|
|
1210
|
+
// Pedir una firma por archivo sería pedirle tres popups a alguien que ya
|
|
1211
|
+
// pagó, y no compraría nada: lo que decide qué entra no es la firma, es el
|
|
1212
|
+
// manifiesto que la cadena ya cubre.
|
|
1213
|
+
const subida = /^\/upload\/(\d+)$/.exec(url.pathname);
|
|
1214
|
+
if (subida && req.method === 'POST') {
|
|
1215
|
+
const taskId = BigInt(subida[1]!);
|
|
1216
|
+
/** Rechaza vaciando el cuerpo: si no, el cliente ve un reset en vez del motivo. */
|
|
1217
|
+
const rechazar = (status: number, cuerpo: unknown): void => {
|
|
1218
|
+
req.resume();
|
|
1219
|
+
json(res, status, cuerpo);
|
|
1220
|
+
};
|
|
1221
|
+
|
|
1222
|
+
// Lo local primero, que no cuesta ni RPC ni ancho de banda.
|
|
1223
|
+
const brief = loadBrief(taskId);
|
|
1224
|
+
if (!brief) {
|
|
1225
|
+
rechazar(409, { error: 'manda antes el encargo a POST /brief/' + taskId });
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
const anunciados = parseAttachmentsManifest(brief);
|
|
1229
|
+
if (anunciados.length === 0) {
|
|
1230
|
+
rechazar(409, { error: 'ese encargo no anuncia ningún adjunto' });
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
const cred = credencialesDe(req, url);
|
|
1235
|
+
if (!cred.address || !cred.signature) {
|
|
1236
|
+
rechazar(400, { error: 'faltan address y signature (cabeceras x-panal-address / x-panal-signature)' });
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
const task = await leerTarea(taskId);
|
|
1241
|
+
if (task.worker.toLowerCase() !== account.address.toLowerCase()) {
|
|
1242
|
+
rechazar(403, { error: 'esa tarea no es de este agente' });
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
if (task.status !== TaskStatus.Open) {
|
|
1246
|
+
rechazar(409, { error: `la tarea está ${TaskStatus[task.status]}` });
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
if (cred.address.toLowerCase() !== task.client.toLowerCase()) {
|
|
1250
|
+
rechazar(403, { error: 'solo el cliente de la tarea puede subirle adjuntos' });
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
if (!(await signedBy(briefSignMessage(taskId), cred.signature, task.client))) {
|
|
1254
|
+
rechazar(401, { error: 'la firma no es del cliente de esta tarea' });
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// Nada puede pesar más que el mayor de los adjuntos anunciados: el
|
|
1259
|
+
// tamaño va DENTRO del manifiesto, o sea dentro de lo que la cadena
|
|
1260
|
+
// cubre. Se mira antes de leer para no tragarse los bytes de nadie.
|
|
1261
|
+
const tope = Math.min(MAX_FILE_BYTES, Math.max(...anunciados.map((f) => f.size)));
|
|
1262
|
+
const declarado = Number(req.headers['content-length'] ?? 0);
|
|
1263
|
+
if (declarado > tope) {
|
|
1264
|
+
rechazar(413, { error: `ese archivo son ${declarado} bytes y el mayor que anunciaste mide ${tope}` });
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
let bytes: Buffer;
|
|
1269
|
+
try {
|
|
1270
|
+
bytes = await readBodyBytes(req, tope);
|
|
1271
|
+
} catch (err) {
|
|
1272
|
+
json(res, 413, { error: err instanceof Error ? err.message : 'cuerpo demasiado grande' });
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// La guarda. Se busca por hash, así que el nombre que venga en la
|
|
1277
|
+
// cabecera no decide nada: sólo desempata si el mismo archivo se
|
|
1278
|
+
// adjuntó dos veces.
|
|
1279
|
+
// El nombre viene percent-encoded: una cabecera HTTP no admite
|
|
1280
|
+
// caracteres fuera de latin-1, y «recibo ñ.png» es un nombre normal.
|
|
1281
|
+
let nombre: string | undefined;
|
|
1282
|
+
const cabecera = req.headers['x-panal-filename'];
|
|
1283
|
+
if (typeof cabecera === 'string') {
|
|
1284
|
+
try {
|
|
1285
|
+
nombre = decodeURIComponent(cabecera);
|
|
1286
|
+
} catch {
|
|
1287
|
+
nombre = cabecera;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
const anunciado = matchAttachment(anunciados, bytes, nombre);
|
|
1291
|
+
if (!anunciado) {
|
|
1292
|
+
json(res, 403, {
|
|
1293
|
+
error: 'esos bytes no son ninguno de los adjuntos que anuncia el encargo',
|
|
1294
|
+
esperados: anunciados.map((f) => ({ name: f.name, size: f.size, hash: f.hash })),
|
|
1295
|
+
});
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
guardarAdjunto(taskId, anunciado.name, bytes);
|
|
1300
|
+
const { faltan: pendientes } = repasarAdjuntos(taskId, brief);
|
|
1301
|
+
console.log(
|
|
1302
|
+
`[panal] #${taskId} adjunto "${anunciado.name}" recibido (${bytes.byteLength} bytes) · faltan ${pendientes.length}`,
|
|
1303
|
+
);
|
|
1304
|
+
|
|
1305
|
+
json(res, 202, {
|
|
1306
|
+
ok: true,
|
|
1307
|
+
guardado: anunciado.name,
|
|
1308
|
+
faltanAdjuntos: pendientes.map((f) => ({ name: f.name, size: f.size, hash: f.hash })),
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
// Con el último adjunto ya se puede trabajar. El encargo estaba en
|
|
1312
|
+
// espera desde que llegó; esto es lo que lo suelta.
|
|
1313
|
+
if (pendientes.length === 0) {
|
|
1314
|
+
const sobreGuardado = sobrePendiente.get(taskId.toString()) ?? null;
|
|
1315
|
+
sobrePendiente.delete(taskId.toString());
|
|
1316
|
+
void work(taskId, brief, sobreGuardado);
|
|
1317
|
+
}
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1015
1321
|
// ---- El cliente recoge su resultado -------------------------------------
|
|
1016
1322
|
const match = /^\/result\/(\d+)$/.exec(url.pathname);
|
|
1017
1323
|
if (match && req.method === 'GET') {
|