supcoflow 0.7.0 → 0.8.1

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.
@@ -1,4 +1,6 @@
1
1
  import { resultadoDeFallo, SALIDA } from "../contrato.js";
2
+ import { agruparPuntos, contarBuenos, lineaDeLoRevisado, nombreDelPunto, puntoDeSesion, puntoDelRepo, TOTAL_DE_INSTALACION } from "../diagnostico.js";
3
+ import { leerRepoLocal } from "../repo-local.js";
2
4
  import { PLAZO_MINIMO_DE_SONDEO_MS, TRAMO_MAXIMO_MS } from "../sondeo.js";
3
5
  import { fallo, rutaDe } from "./configurar.js";
4
6
  import { proyectoDelRepo } from "./proyecto-del-repo.js";
@@ -6,6 +8,7 @@ import { proyectoDelRepo } from "./proyecto-del-repo.js";
6
8
  // gastar un pedido (ni guardar un intento que el motor va a rechazar); la regla de verdad es la del motor.
7
9
  const MAX_MENSAJE = 20_000;
8
10
  const ID_DE_PRUEBA = /^prb_[0-9a-f]{16}$/;
11
+ const TECHO = /^\d+(?:\.\d{1,4})?$/;
9
12
  const INTERVALO_DE_SEGUIMIENTO_MS = 5_000;
10
13
  const PLAZO_MAXIMO_DE_SONDEO_MS = 15_000;
11
14
  // La tabla de la prueba (brief M7, «los estados»): qué código sale en cada estado. Lo que no está acá es
@@ -16,7 +19,18 @@ const PIDEN_UNA_PERSONA = new Set([
16
19
  "issue_con_cierre_anomalo", "fallido", "cierre_sin_evidencia", "reclamado_sin_run", "arranque_incierto", "esperando_cupo", "sin_despacho",
17
20
  ]);
18
21
  const usd = (n) => `US$ ${n.toFixed(2)}`;
19
- const usdONull = (n) => (n === null ? "sin medir" : usd(n));
22
+ const usdONull = (n) => (n === null ? "unmeasured" : usd(n));
23
+ // La política como se dice en inglés; a la API siempre viaja la suya (`relevar` / `guardar`).
24
+ const POLITICA_EN_INGLES = { relevar: "investigate", guardar: "save" };
25
+ const enInglesLaPolitica = (p) => (p === null ? "none" : (POLITICA_EN_INGLES[p] ?? p));
26
+ const aLaPoliticaDeLaApi = (p) => p === "investigate" || p === "relevar" ? "relevar" : p === "save" || p === "guardar" ? "guardar" : null;
27
+ // El comando exacto para retomar un pedido guardado: con lo que se autorizó, no con lo que haya ahora. Un pedido SIN
28
+ // política (lo guardó la 0.7.0, o se pidió con el alias viejo) sólo se retoma por el alias: el comando canónico exige
29
+ // `--expect-policy` (3 falta_politica) y, con una política, ya no es el mismo pedido (4 intento_pendiente). No se inventa
30
+ // una política para él: cambiaría su cuerpo y su clave.
31
+ const comandoDeReintento = (i) => i.politicaEsperada === null
32
+ ? `supcoflow probar --autorizo-gasto --ceiling ${i.techoUsd}`
33
+ : `supcoflow test --authorize-spend --ceiling ${i.techoUsd} --expect-policy ${enInglesLaPolitica(i.politicaEsperada)}`;
20
34
  function datosDelDiagnostico(g) {
21
35
  return {
22
36
  listo: g.listo,
@@ -29,7 +43,27 @@ function datosDelDiagnostico(g) {
29
43
  prueba_abierta: g.pruebaAbierta,
30
44
  };
31
45
  }
32
- const lineasDePuntos = (g) => g.puntos.map((p) => ` [${p.ok ? "ok" : "falta"}] ${p.punto}: ${p.mensaje}`);
46
+ const lineasDePuntos = (puntos, nombre = (p) => p) => puntos.map((p) => ` [${p.ok ? "ok" : "missing"}] ${nombre(p.punto)}: ${p.mensaje}`);
47
+ // Lo que esta máquina puede o no hacer con el pedido de la prueba, visto ANTES del OK de gasto y sin escribir nada. Un
48
+ // prechequeo que falla es «no ok», nunca un error: avisar tarde es justo lo que esto evita.
49
+ async function revisarLaMaquina(d, proyectoId) {
50
+ try {
51
+ return await d.pruebas.revisar(proyectoId);
52
+ }
53
+ catch {
54
+ return { maquina: { ok: false, impedimentos: [{ codigo: "no_se_pudo_revisar", detalle: "I couldn't check whether this machine can save the test request." }] }, intentoPendiente: null };
55
+ }
56
+ }
57
+ const datosDeLaMaquina = (r) => ({ maquina: r.maquina });
58
+ const datosDelIntentoPendiente = (r) => ({
59
+ intento_pendiente: r.intentoPendiente === null ? null : { techo_usd: r.intentoPendiente.techoUsd, politica: r.intentoPendiente.politica },
60
+ });
61
+ const detalles = (r) => r.maquina.impedimentos.map((i) => i.detalle).join(" ");
62
+ function puntoDeLaMaquina(r) {
63
+ return r.maquina.ok
64
+ ? { punto: "maquina", ok: true, mensaje: "No obstacle found to saving the test request on this machine.", grupo: "prueba" }
65
+ : { punto: "maquina", ok: false, mensaje: `This machine can't save the test request yet: ${detalles(r)}`, grupo: "prueba" };
66
+ }
33
67
  export async function diagnosticar(d, flags) {
34
68
  const p = await proyectoDelRepo(d, flags);
35
69
  if (!p.ok)
@@ -41,33 +75,96 @@ export async function diagnosticar(d, flags) {
41
75
  catch (e) {
42
76
  return resultadoDeFallo(e);
43
77
  }
44
- const datos = { proyecto: { id: p.proyecto.id, repo: p.proyecto.repo }, ...datosDelDiagnostico(g) };
45
- const resumen = `Saldo del mes ${usdONull(g.saldoUsd)} de ${usdONull(g.topeUsd)}; una prueba autoriza hasta ${usd(g.techoUsd)}. Política: ${g.politica}.`;
46
- if (g.listo) {
47
- return { codigo: SALIDA.ok, json: { ok: true, ...datos }, texto: [`Todo listo para probar ${p.proyecto.repo}.`, ...lineasDePuntos(g), resumen].join("\n") };
78
+ // Los dos puntos que sólo la terminal puede ver: la llamada de arriba autenticó (si no, no se llega acá) y el
79
+ // `origin` de esta carpeta es el repo del proyecto. Sin git o sin origin no se adivina: el punto sale no ok.
80
+ let local;
81
+ try {
82
+ local = await leerRepoLocal(d.git, d.cwd);
83
+ }
84
+ catch {
85
+ local = { repo: null, raiz: null, motivo: "sin_git" };
48
86
  }
49
- const message = `Hay algo para arreglar antes de probar ${p.proyecto.repo}.`;
50
- return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: "no_listo", message, ...datos }, texto: [message, ...lineasDePuntos(g), resumen].join("\n") };
87
+ const agrupados = agruparPuntos(g, [puntoDeSesion(), puntoDelRepo(local, p.proyecto.repo)]);
88
+ const instalacion = agrupados.instalacion;
89
+ // El punto local de la prueba (1A.1): ¿esta máquina puede guardar el pedido? Si no, la prueba no entra, y se dice acá y
90
+ // no después del OK de gasto. No cuenta en el «N of 6» de la instalación.
91
+ const revision = await revisarLaMaquina(d, p.proyecto.id);
92
+ const prueba = [puntoDeLaMaquina(revision), ...agrupados.prueba];
93
+ const buenos = contarBuenos(instalacion);
94
+ const listo = g.listo && revision.maquina.ok && instalacion.every((x) => x.ok || !["sign-in", "repo"].includes(x.punto));
95
+ const datos = {
96
+ proyecto: { id: p.proyecto.id, repo: p.proyecto.repo },
97
+ ...datosDelDiagnostico(g),
98
+ listo,
99
+ puntos: [...instalacion, ...prueba],
100
+ instalacion: { ok: buenos, total: TOTAL_DE_INSTALACION, puntos: instalacion },
101
+ prueba: { listo: g.listo && revision.maquina.ok, puntos: prueba },
102
+ ...datosDeLaMaquina(revision),
103
+ ...datosDelIntentoPendiente(revision),
104
+ };
105
+ const resumen = `Monthly balance ${usdONull(g.saldoUsd)} of ${usdONull(g.topeUsd)}; a test authorizes up to ${usd(g.techoUsd)}. Policy: ${enInglesLaPolitica(g.politica)}.`;
106
+ const motivos = [
107
+ ...prueba.filter((x) => !x.ok).map((x) => x.mensaje),
108
+ ...(buenos < TOTAL_DE_INSTALACION ? [`the installation isn't complete (${buenos} of ${TOTAL_DE_INSTALACION})`] : []),
109
+ ];
110
+ const listaParaProbar = listo ? "Ready for a test: yes." : `Ready for a test: no, because ${motivos.join("; ") || "the engine says it isn't ready"}`;
111
+ const cuerpo = [
112
+ lineaDeLoRevisado(buenos),
113
+ ...lineasDePuntos(instalacion, nombreDelPunto),
114
+ listaParaProbar,
115
+ ...lineasDePuntos(prueba),
116
+ resumen,
117
+ ];
118
+ if (listo)
119
+ return { codigo: SALIDA.ok, json: { ok: true, ...datos }, texto: cuerpo.join("\n") };
120
+ const message = `Something needs fixing before testing ${p.proyecto.repo}.`;
121
+ return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: "no_listo", message, ...datos }, texto: [message, ...cuerpo].join("\n") };
51
122
  }
52
123
  export async function probar(d, flags) {
53
124
  // El reloj del tramo arranca con el comando: los 90 s son del comando entero, no sólo de los sondeos.
54
125
  const inicio = d.ahora();
55
126
  // Todo lo que se puede rechazar sin mirar nada, antes de tocar el motor o lo guardado.
56
- if (flags.wait && (flags.autorizoGasto || flags.mensaje !== null)) {
57
- return fallo(SALIDA.entradaInvalida, "flags_incompatibles", "`probar --wait` sólo sigue una prueba: sin --autorizo-gasto ni --mensaje. Primero `supcoflow probar --autorizo-gasto` y después `supcoflow probar --wait`.");
127
+ if (flags.wait && (flags.authorizeSpend || flags.message !== null || flags.ceiling !== null || flags.expectPolicy !== null)) {
128
+ return fallo(SALIDA.entradaInvalida, "flags_incompatibles", "`test --wait` only follows a test: no --authorize-spend, --ceiling, --expect-policy or --message. First `supcoflow test --authorize-spend --ceiling <usd> --expect-policy <policy>`, then `supcoflow test --wait`.");
129
+ }
130
+ if (!flags.wait && flags.testId !== null) {
131
+ return fallo(SALIDA.entradaInvalida, "flags_incompatibles", "--test-id goes with --wait: `supcoflow test --wait --test-id prb_…`.");
58
132
  }
59
- if (!flags.wait && flags.prueba !== null) {
60
- return fallo(SALIDA.entradaInvalida, "flags_incompatibles", "--prueba va con --wait: `supcoflow probar --wait --prueba prb_…`.");
133
+ if (!flags.authorizeSpend && !flags.wait && (flags.ceiling !== null || flags.expectPolicy !== null)) {
134
+ return fallo(SALIDA.entradaInvalida, "flags_incompatibles", "--ceiling and --expect-policy go with --authorize-spend: they say what you authorize.");
61
135
  }
62
- if (flags.prueba !== null && !ID_DE_PRUEBA.test(flags.prueba)) {
63
- return fallo(SALIDA.entradaInvalida, "prueba_invalida", "--prueba es el id que dio `supcoflow probar` (prb_ y 16 caracteres).");
136
+ if (flags.testId !== null && !ID_DE_PRUEBA.test(flags.testId)) {
137
+ return fallo(SALIDA.entradaInvalida, "prueba_invalida", "--test-id is the id `supcoflow test` printed (prb_ and 16 characters).");
64
138
  }
65
139
  let mensaje = null;
66
- if (flags.mensaje !== null) {
67
- mensaje = flags.mensaje.trim();
140
+ if (flags.message !== null) {
141
+ mensaje = flags.message.trim();
68
142
  if (mensaje.length === 0 || mensaje.length > MAX_MENSAJE) {
69
- return fallo(SALIDA.entradaInvalida, "mensaje_invalido", `--mensaje va con texto, hasta ${MAX_MENSAJE} caracteres.`);
143
+ return fallo(SALIDA.entradaInvalida, "mensaje_invalido", `--message goes with text, up to ${MAX_MENSAJE} characters.`);
144
+ }
145
+ }
146
+ // Lo que la persona autorizó: el techo y la política que vio. Sin eso no hay pedido que valga (R5).
147
+ let autorizado = null;
148
+ if (flags.authorizeSpend) {
149
+ if (flags.ceiling === null) {
150
+ const comoSeEscribe = "`supcoflow test --authorize-spend --ceiling <usd> --expect-policy investigate|save`";
151
+ return fallo(SALIDA.entradaInvalida, "falta_techo", flags.viejo === true
152
+ ? `\`probar --autorizo-gasto\` now needs --ceiling <usd>, the number you were shown: an alias can't widen what you authorized. Use ${comoSeEscribe}. (The old behaviour is in supcoflow 0.7.0.)`
153
+ : `--authorize-spend needs --ceiling <usd>, the number you were shown: ${comoSeEscribe}.`);
70
154
  }
155
+ if (!TECHO.test(flags.ceiling) || !(Number(flags.ceiling) > 0)) {
156
+ return fallo(SALIDA.entradaInvalida, "techo_invalido", "--ceiling is a positive number of US dollars, for example 2 or 2.50.");
157
+ }
158
+ let politicaEsperada = null;
159
+ if (flags.expectPolicy !== null) {
160
+ politicaEsperada = aLaPoliticaDeLaApi(flags.expectPolicy);
161
+ if (politicaEsperada === null)
162
+ return fallo(SALIDA.entradaInvalida, "politica_invalida", "--expect-policy is `investigate` or `save`.");
163
+ }
164
+ else if (flags.viejo !== true) {
165
+ return fallo(SALIDA.entradaInvalida, "falta_politica", "--authorize-spend needs --expect-policy investigate|save: the policy you were offered, so a yes to a test that won't investigate can't turn into a paid one.");
166
+ }
167
+ autorizado = { techoUsd: Number(flags.ceiling), politicaEsperada };
71
168
  }
72
169
  const p = await proyectoDelRepo(d, flags);
73
170
  if (!p.ok)
@@ -75,9 +172,9 @@ export async function probar(d, flags) {
75
172
  const proyecto = p.proyecto;
76
173
  const clave = p.clave;
77
174
  if (flags.wait)
78
- return seguir(d, clave, proyecto.id, flags.prueba, inicio);
175
+ return seguir(d, clave, proyecto.id, flags.testId, inicio);
79
176
  // ── Sin el flag, o con --dry-run: sólo el diagnóstico (gratis). Nada que escriba, ni acá ni en el motor.
80
- if (!flags.autorizoGasto || flags.dryRun) {
177
+ if (autorizado === null || flags.dryRun) {
81
178
  let g;
82
179
  try {
83
180
  g = await d.leerDiagnostico(d.api, clave, proyecto.id);
@@ -85,10 +182,12 @@ export async function probar(d, flags) {
85
182
  catch (e) {
86
183
  return resultadoDeFallo(e);
87
184
  }
88
- const datos = { proyecto: { id: proyecto.id, repo: proyecto.repo }, ...datosDelDiagnostico(g) };
185
+ // Lo que esta máquina puede hacer con el pedido, visto ANTES del sí de gasto (fricción 3).
186
+ const revision = await revisarLaMaquina(d, proyecto.id);
187
+ const datos = { proyecto: { id: proyecto.id, repo: proyecto.repo }, ...datosDelDiagnostico(g), ...datosDeLaMaquina(revision) };
89
188
  const gasto = g.politica === "guardar"
90
- ? `Con la política \`guardar\` la prueba llega hasta el caso guardado y no gasta; el techo que se autoriza igual es ${usd(g.techoUsd)}.`
91
- : `Una prueba corre un relevamiento que gasta hasta ${usd(g.techoUsd)} (saldo del mes ${usdONull(g.saldoUsd)} de ${usdONull(g.topeUsd)}).`;
189
+ ? `With the \`save\` policy the test goes as far as the saved case and spends nothing; the ceiling it authorizes is still ${usd(g.techoUsd)}.`
190
+ : `A test runs an investigation that spends up to ${usd(g.techoUsd)} (monthly balance ${usdONull(g.saldoUsd)} of ${usdONull(g.topeUsd)}).`;
92
191
  if (flags.dryRun) {
93
192
  // Lo guardado se mira sólo para decir qué haría; si no se puede leer, el dry-run igual contesta.
94
193
  let pendiente = null;
@@ -99,37 +198,44 @@ export async function probar(d, flags) {
99
198
  pendiente = null;
100
199
  }
101
200
  const ruta = rutaDe(proyecto, "/pruebas");
201
+ const cuerpo = autorizado === null ? `techo_usd: ${g.techoUsd}` : `techo_usd: ${autorizado.techoUsd}${autorizado.politicaEsperada !== null ? `, politica_esperada: ${autorizado.politicaEsperada}` : ""}`;
102
202
  const haria = pendiente
103
- ? `reenviaría el pedido guardado sin confirmar (POST ${ruta}, la misma Idempotency-Key)`
104
- : `POST ${ruta} {techo_usd: ${g.techoUsd}${mensaje !== null ? ", mensaje" : ""}} con una Idempotency-Key nueva`;
203
+ ? `would resend the saved request that was never confirmed (POST ${ruta}, the same Idempotency-Key)`
204
+ : `would send POST ${ruta} {${cuerpo}${mensaje !== null ? ", mensaje" : ""}} with a new Idempotency-Key`;
105
205
  return {
106
206
  codigo: SALIDA.ok,
107
- json: { ok: true, dry_run: true, autorizado: flags.autorizoGasto, intento_pendiente: pendiente, haria, ...datos },
108
- texto: `Con --dry-run no hago nada. ${flags.autorizoGasto ? "Sin --dry-run" : "Con --autorizo-gasto y sin --dry-run"}: ${haria} (proyecto ${proyecto.repo}, política ${g.politica}). ${gasto}`,
207
+ json: { ok: true, dry_run: true, autorizado: flags.authorizeSpend, intento_pendiente: pendiente, haria, ...datos },
208
+ texto: `With --dry-run I do nothing. ${flags.authorizeSpend ? "Without --dry-run, I" : "With --authorize-spend and without --dry-run, I"}${haria.replace(/^would/, "")} (project ${proyecto.repo}, policy ${enInglesLaPolitica(g.politica)}). ${gasto}`,
109
209
  };
110
210
  }
111
- const message = `${gasto} Para lanzarla: \`supcoflow probar --autorizo-gasto\`. Antes, \`supcoflow diagnosticar\` dice si está todo listo.`;
112
- return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: "falta_autorizacion", message, ...datos }, texto: message };
211
+ const lanzar = `supcoflow test --authorize-spend --ceiling ${g.techoUsd} --expect-policy ${enInglesLaPolitica(g.politica)}`;
212
+ // Primero lo que impide, si algo: es lo que la persona tiene que saber antes de decir que sí.
213
+ const impide = revision.maquina.ok ? "" : `This machine can't save the test request yet: ${detalles(revision)} Fix that before authorizing. `;
214
+ const previo = revision.intentoPendiente;
215
+ const pendiente = previo === null ? ""
216
+ : ` There's a test request waiting for confirmation (ceiling ${usd(previo.techoUsd)}, policy ${enInglesLaPolitica(previo.politica)}): to recover that same request, repeat \`${comandoDeReintento({ clave: "", mensaje: null, techoUsd: previo.techoUsd, politicaEsperada: previo.politica })}\`.`;
217
+ const message = `${impide}${gasto} To launch it: \`${lanzar}\`. \`supcoflow diagnose\` first says whether everything is ready.${pendiente}`;
218
+ return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: "falta_autorizacion", message, ...datos, ...datosDelIntentoPendiente(revision) }, texto: message };
113
219
  }
114
- // ── Con --autorizo-gasto: el ciclo de la clave.
220
+ // ── Con --authorize-spend: el ciclo de la clave.
115
221
  let intento;
116
222
  try {
117
223
  const guardado = await d.pruebas.leer(proyecto.id);
118
224
  if (guardado.intento !== null) {
119
- if (mensaje !== null && mensaje !== guardado.intento.mensaje) {
120
- return fallo(SALIDA.haceFaltaUnaPersona, "intento_pendiente", "Hay una prueba pedida sin confirmar; repetí `supcoflow probar --autorizo-gasto` sin `--mensaje` para recuperarla.");
225
+ const previo = guardado.intento;
226
+ const igual = Math.abs(previo.techoUsd - autorizado.techoUsd) < 1e-9 && previo.politicaEsperada === autorizado.politicaEsperada;
227
+ if (mensaje !== null && mensaje !== previo.mensaje) {
228
+ return fallo(SALIDA.haceFaltaUnaPersona, "intento_pendiente", `There's a test request waiting for confirmation; repeat \`${comandoDeReintento(previo)}\` without --message to recover it.`);
121
229
  }
122
- intento = guardado.intento;
230
+ if (!igual) {
231
+ return fallo(SALIDA.haceFaltaUnaPersona, "intento_pendiente", `There's a test request waiting for confirmation, authorized with a ceiling of ${usd(previo.techoUsd)}; repeat it with exactly that to recover it: \`${comandoDeReintento(previo)}\`.`);
232
+ }
233
+ intento = previo;
123
234
  }
124
235
  else {
125
- // El techo que se autoriza es el que dice el motor ahora (D6), no uno escrito en la terminal.
126
- const g = await d.leerDiagnostico(d.api, clave, proyecto.id);
127
- if (g.pruebaAbierta !== null)
128
- return enCurso(g.pruebaAbierta);
129
- // No frena (el motor decide lo que acepta), pero quien autorizó tiene que ver lo que falta.
130
- for (const punto of g.puntos.filter((x) => !x.ok))
131
- d.avisar(`Aviso (${punto.punto}): ${punto.mensaje}`);
132
- intento = { clave: d.generar(), mensaje, techoUsd: g.techoUsd };
236
+ // Lo que se manda es lo que la persona vio (R5): no se relee el diagnóstico para elegirlo. El motor compara
237
+ // el techo y la política con los de ahora y rechaza si cambiaron.
238
+ intento = { clave: d.generar(), mensaje, techoUsd: autorizado.techoUsd, politicaEsperada: autorizado.politicaEsperada };
133
239
  await d.pruebas.abrir(proyecto.id, intento);
134
240
  }
135
241
  }
@@ -142,17 +248,17 @@ export async function probar(d, flags) {
142
248
  await d.pruebas.confirmar(proyecto.id, r.pruebaId);
143
249
  }
144
250
  catch {
145
- const message = `El motor aceptó la prueba ${r.pruebaId}, pero no pude guardarla en esta máquina. Repetí \`supcoflow probar --autorizo-gasto\` (recupera la misma) o seguila con \`supcoflow probar --wait --prueba ${r.pruebaId}\`.`;
251
+ const message = `The engine accepted test ${r.pruebaId}, but I couldn't save it on this machine. Repeat \`${comandoDeReintento(intento)}\` (it recovers the same one) or follow it with \`supcoflow test --wait --test-id ${r.pruebaId}\`.`;
146
252
  return { codigo: SALIDA.inesperado, json: { ok: false, error: "prueba_no_guardada", message, prueba_id: r.pruebaId, intento_pendiente: true }, texto: message };
147
253
  }
148
254
  return {
149
255
  codigo: SALIDA.esperandoAUnaPersona,
150
256
  json: { ok: false, esperando: "relevamiento", prueba_id: r.pruebaId, reporte_id: r.reporteId, estado: r.estado },
151
- texto: `Prueba ${r.pruebaId} pedida (${r.estado}). Seguila con \`supcoflow probar --wait\`.`,
257
+ texto: `Test ${r.pruebaId} requested (${r.estado}). Follow it with \`supcoflow test --wait\`.`,
152
258
  };
153
259
  }
154
260
  if (r.tipo === "ambigua") {
155
- const message = `${r.message} El pedido quedó guardado: repetí \`supcoflow probar --autorizo-gasto\` y retomo la misma prueba, sin pedir otra.`;
261
+ const message = `${r.message} The request was saved: repeat \`${comandoDeReintento(intento)}\` and I'll pick up the same test, without asking for another.`;
156
262
  return { codigo: SALIDA.inesperado, json: { ok: false, error: r.error, message, intento_pendiente: true }, texto: message };
157
263
  }
158
264
  // Un rechazo definitivo: el motor no creó nada con esta clave, así que el intento se cierra.
@@ -160,22 +266,29 @@ export async function probar(d, flags) {
160
266
  await d.pruebas.descartar(proyecto.id);
161
267
  }
162
268
  catch {
163
- d.avisar("No pude cerrar el pedido guardado; el próximo `probar --autorizo-gasto` lo va a reenviar.");
269
+ d.avisar("I couldn't close the saved request; the next `test --authorize-spend` will resend it.");
164
270
  }
165
- return rechazo(r, d.avisar);
271
+ return rechazo(r, intento, d.avisar);
166
272
  }
167
273
  function enCurso(pruebaId) {
168
- const message = `Ya hay una prueba de este proyecto sin desenlace (${pruebaId}). Seguila con \`supcoflow probar --wait --prueba ${pruebaId}\`.`;
274
+ const message = `This project already has a test with no outcome (${pruebaId}). Follow it with \`supcoflow test --wait --test-id ${pruebaId}\`.`;
169
275
  return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: "prueba_en_curso", message, prueba_id: pruebaId }, texto: message };
170
276
  }
171
- function rechazo(r, avisar) {
277
+ function rechazo(r, intento, avisar) {
172
278
  const c = r.cuerpo;
173
279
  switch (r.error) {
174
280
  case "prueba_en_curso":
175
281
  return typeof c.prueba_id === "string" ? enCurso(c.prueba_id) : fallo(SALIDA.haceFaltaUnaPersona, r.error, r.message);
176
282
  case "techo_distinto": {
177
- const message = `${r.message} Volvé a correr \`supcoflow probar\` para ver el techo vigente y autorizalo.`;
178
- return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: r.error, message, techo_usd: c.techo_usd ?? null }, texto: message };
283
+ // Sin reintentar: lo que se autorizó ya no es lo que hay, y decidir de nuevo es de la persona (con el número nuevo).
284
+ const nuevo = typeof c.techo_usd === "number" ? c.techo_usd : null;
285
+ const message = `The ceiling for a test changed${nuevo !== null ? `: it's now ${usd(nuevo)}` : ""}, and you authorized ${usd(intento.techoUsd)}. Nothing was started. Ask again with the new number.`;
286
+ return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: r.error, message, techo_usd: nuevo, autorizado_usd: intento.techoUsd }, texto: message };
287
+ }
288
+ case "politica_distinta": {
289
+ const ahora = typeof c.politica === "string" ? c.politica : typeof c.politica_actual === "string" ? c.politica_actual : null;
290
+ const message = `The project's policy changed${ahora !== null ? `: it's now \`${enInglesLaPolitica(ahora)}\`` : ""}, and you authorized a test under \`${enInglesLaPolitica(intento.politicaEsperada)}\`. Nothing was started. Ask again with the current policy.`;
291
+ return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: r.error, message, politica: ahora, politica_esperada: intento.politicaEsperada }, texto: message };
179
292
  }
180
293
  case "tope_mensual":
181
294
  return {
@@ -184,13 +297,13 @@ function rechazo(r, avisar) {
184
297
  texto: r.message,
185
298
  };
186
299
  case "requiere_api_key": {
187
- const message = `La prueba se pide con tu API key personal (la que deja \`supcoflow login\`), no con una clave de consulta ni con la sesión de la consola. ${r.message}`;
300
+ const message = `The test is requested with your personal API key (the one \`supcoflow login\` leaves), not with a read-only key or the console session. ${r.message}`;
188
301
  return fallo(SALIDA.haceFaltaUnaPersona, r.error, message);
189
302
  }
190
303
  case "idempotency_key_reusada":
191
304
  // No debería pasar: siempre se reenvía lo guardado con su clave. Si pasa, la clave ya no sirve.
192
- avisar("El motor dice que la clave del pedido ya se usó con otro contenido; la descarté.");
193
- return fallo(SALIDA.inesperado, r.error, `${r.message} Volvé a correr \`supcoflow probar --autorizo-gasto\` para pedir una prueba nueva.`);
305
+ avisar("The engine says this request's key was already used with other content; I discarded it.");
306
+ return fallo(SALIDA.inesperado, r.error, `${r.message} Run \`${comandoDeReintento(intento)}\` again to ask for a new test.`);
194
307
  }
195
308
  if (r.status === 401)
196
309
  return fallo(SALIDA.faltaLogin, "clave_invalida", r.message);
@@ -201,14 +314,14 @@ function rechazo(r, avisar) {
201
314
  function textoDelCobro(c) {
202
315
  // Pendiente y perdido nunca se muestran como cero: el monto todavía no se sabe, la cota es el techo.
203
316
  if (c.estado === "leido" && c.usd !== null)
204
- return `Costo: ${usd(c.usd)}.`;
317
+ return `Cost: ${usd(c.usd)}.`;
205
318
  if (c.estado === "pendiente")
206
- return `Cobro pendiente: se lee unos minutos después del cierre (hasta ${usd(c.techo_usd)}).`;
319
+ return `Charge pending: it's read a few minutes after the run closes (up to ${usd(c.techo_usd)}).`;
207
320
  if (c.estado === "perdido")
208
- return `Cobro perdido: no se pudo leer el gasto (hasta ${usd(c.techo_usd)}).`;
321
+ return `Charge lost: the spend couldn't be read (up to ${usd(c.techo_usd)}).`;
209
322
  if (c.estado === null)
210
323
  return null;
211
- return `Cobro: ${c.estado} (hasta ${usd(c.techo_usd)}).`;
324
+ return `Charge: ${c.estado} (up to ${usd(c.techo_usd)}).`;
212
325
  }
213
326
  function resultadoDelSeguimiento(s) {
214
327
  const datos = {
@@ -216,24 +329,24 @@ function resultadoDelSeguimiento(s) {
216
329
  reporte: s.reporte, run: s.run, issue: s.issue, cobro: s.cobro,
217
330
  };
218
331
  const lineas = [
219
- `Prueba ${s.pruebaId}: ${s.estado}.${s.mensaje ? ` ${s.mensaje}` : ""}`,
332
+ `Test ${s.pruebaId}: ${s.estado}.${s.mensaje ? ` ${s.mensaje}` : ""}`,
220
333
  s.issue ? `Issue #${s.issue.numero}: ${s.issue.url}` : null,
221
- s.run?.motivo ? `Motivo del run: ${s.run.motivo}` : null,
334
+ s.run?.motivo ? `Run reason: ${s.run.motivo}` : null,
222
335
  textoDelCobro(s.cobro),
223
336
  ];
224
337
  if (ABIERTOS.has(s.estado)) {
225
- lineas.push("Volvé a correr `supcoflow probar --wait`.");
338
+ lineas.push("Run `supcoflow test --wait` again.");
226
339
  return { codigo: SALIDA.esperandoAUnaPersona, json: { ok: false, esperando: "relevamiento", ...datos }, texto: lineas.filter(Boolean).join("\n") };
227
340
  }
228
341
  if (CON_DESENLACE_OK.has(s.estado))
229
342
  return { codigo: SALIDA.ok, json: { ok: true, ...datos }, texto: lineas.filter(Boolean).join("\n") };
230
343
  if (PIDEN_UNA_PERSONA.has(s.estado)) {
231
344
  if (s.reporte)
232
- lineas.push(`El caso en la consola: ${s.reporte.url}`);
345
+ lineas.push(`The case in the console: ${s.reporte.url}`);
233
346
  const texto = lineas.filter(Boolean).join("\n");
234
347
  return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: s.estado, message: s.mensaje ?? texto, ...datos }, texto };
235
348
  }
236
- const message = `El motor dice que la prueba está en «${s.estado}», un estado que esta versión de la terminal no conoce. Actualizá supcoflow o mirala en la consola.`;
349
+ const message = `The engine says the test is in «${s.estado}», a state this version of the terminal doesn't know. Update supcoflow or look at it in the console.`;
237
350
  return { codigo: SALIDA.inesperado, json: { ok: false, error: "estado_desconocido", message, ...datos }, texto: message };
238
351
  }
239
352
  async function seguir(d, clave, proyectoId, elegida, inicio) {
@@ -242,17 +355,17 @@ async function seguir(d, clave, proyectoId, elegida, inicio) {
242
355
  try {
243
356
  const g = await d.pruebas.leer(proyectoId);
244
357
  // Un pedido sin confirmar gana sobre la última: seguir la anterior y salir 0 con SU issue se leería
245
- // como el desenlace de la prueba recién pedida. Con `--prueba` explícito no se llega acá.
358
+ // como el desenlace de la prueba recién pedida. Con `--test-id` explícito no se llega acá.
246
359
  pruebaId = g.ultima;
247
360
  if (g.intento !== null) {
248
- return fallo(SALIDA.haceFaltaUnaPersona, "intento_pendiente", "Hay una prueba pedida sin confirmar; repetí `supcoflow probar --autorizo-gasto` sin `--mensaje` para recuperarla, y después `--wait`.");
361
+ return fallo(SALIDA.haceFaltaUnaPersona, "intento_pendiente", `There's a test request waiting for confirmation; repeat \`${comandoDeReintento(g.intento)}\` without --message to recover it, and then \`--wait\`.`);
249
362
  }
250
363
  }
251
364
  catch (e) {
252
365
  return resultadoDeFallo(e);
253
366
  }
254
367
  if (pruebaId === null) {
255
- return fallo(SALIDA.entradaInvalida, "sin_prueba", "No hay una prueba guardada en esta máquina para este proyecto: pedila con `supcoflow probar --autorizo-gasto`, o pasá `--prueba prb_…`.");
368
+ return fallo(SALIDA.entradaInvalida, "sin_prueba", "There's no saved test on this machine for this project: request one with `supcoflow test --authorize-spend --ceiling <usd> --expect-policy <policy>`, or pass `--test-id prb_…`.");
256
369
  }
257
370
  }
258
371
  // Se aparta lo que puede tardar un sondeo forzado (el primero siempre va, aunque quede poco): así el
@@ -1,10 +1,12 @@
1
1
  import { resultadoDeFallo, SALIDA } from "../contrato.js";
2
2
  import { repoPedido } from "../repo-local.js";
3
3
  import { claveVigente } from "./clave-vigente.js";
4
+ import { coherenciaDelClon, leerElArchivoDelClon } from "./contexto.js";
4
5
  export async function proyectoDelRepo(d, flags) {
5
6
  let repo;
7
+ let raiz;
6
8
  try {
7
- repo = (await repoPedido({ flagRepo: flags.repo, git: d.git, cwd: d.cwd })).repo;
9
+ ({ repo, raiz } = await repoPedido({ flagRepo: flags.repo, git: d.git, cwd: d.cwd }));
8
10
  }
9
11
  catch (e) {
10
12
  return { ok: false, resultado: resultadoDeFallo(e) };
@@ -12,10 +14,18 @@ export async function proyectoDelRepo(d, flags) {
12
14
  const leida = await claveVigente(d.guardado, d.api.origin);
13
15
  if (!leida.ok)
14
16
  return { ok: false, resultado: leida.resultado };
17
+ // El archivo del clon se lee ANTES de actuar: un `proyecto.json` que no se entiende frena sin tocar nada, y uno que anota
18
+ // otro proyecto que el que la clave ve también (ningún pedido parcial actúa sobre un proyecto distinto del que anota el clon).
19
+ const archivo = leerElArchivoDelClon(d.archivos, raiz);
20
+ if (archivo.estado === "ilegible")
21
+ return { ok: false, resultado: coherenciaDelClon(archivo, null, repo) };
15
22
  try {
16
23
  const proyecto = await d.buscarProyecto(d.api, leida.vigente.clave, repo);
24
+ const incoherente = coherenciaDelClon(archivo, proyecto, repo);
25
+ if (incoherente !== null)
26
+ return { ok: false, resultado: incoherente };
17
27
  if (proyecto === null) {
18
- const message = `El repo ${repo} todavía no tiene proyecto. Creá el proyecto: \`supcoflow proyecto crear\`.`;
28
+ const message = `The repo ${repo} doesn't have a project yet. Create the project: \`supcoflow project create\`.`;
19
29
  return { ok: false, resultado: { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: "sin_proyecto", message, repo }, texto: message } };
20
30
  }
21
31
  return { ok: true, clave: leida.vigente.clave, proyecto };