agents-city 0.5.1 → 0.5.2

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.
@@ -9,7 +9,7 @@
9
9
  {
10
10
  "name": "city",
11
11
  "source": "./plugin",
12
- "version": "0.5.1",
12
+ "version": "0.5.2",
13
13
  "description": "Operate one autonomous city seat, its domain, role, repo support agents, goal, recognised skills and explicit roads to other cities."
14
14
  }
15
15
  ]
package/bin/navegador.mjs CHANGED
@@ -553,6 +553,114 @@ async function main() {
553
553
  JSON.stringify(demo?.dice),
554
554
  );
555
555
 
556
+ // Building a house for real: type a name, press the button, get a house.
557
+ //
558
+ // The suite used to open this form, check its fields existed and press
559
+ // Escape — so the one thing it is FOR was never exercised. What that hid:
560
+ // the roles list arrives a few hundred milliseconds after the form opens,
561
+ // the form repainted when it did, and the repaint redrew the name field
562
+ // from a state that had never been told what was typed in it. Typing while
563
+ // that answer was in flight lost the name, and then the button said to
564
+ // give it one. Intermittent, which is the worst kind.
565
+ //
566
+ // So this types IMMEDIATELY, before the roles can land, which is exactly
567
+ // what a person does.
568
+ const construida = await cdp.evalua(`(async () => {
569
+ const espera = async (sel, n = 40) => {
570
+ for (let i = 0; i < n; i++) {
571
+ const el = document.querySelector(sel);
572
+ if (el) return el;
573
+ await new Promise(r => setTimeout(r, 250));
574
+ }
575
+ return null;
576
+ };
577
+ const ir = [...document.querySelectorAll('nav li')].find(l => /houses|casas/i.test(l.textContent));
578
+ ir?.click();
579
+ await new Promise(r => setTimeout(r, 600));
580
+ document.getElementById('altaAgente')?.click();
581
+ const campo = await espera('.dlgFondo #bvNombre');
582
+ if (!campo) return { falta: true };
583
+ // Straight away: the roles request is still in flight right now.
584
+ campo.value = 'urgencias';
585
+ campo.dispatchEvent(new Event('input', { bubbles: true }));
586
+ // Long enough for that answer to land and do whatever it does.
587
+ await new Promise(r => setTimeout(r, 1500));
588
+ const sobrevive = (document.querySelector('.dlgFondo #bvNombre') || {}).value;
589
+ const boton = document.querySelector('.dlgFondo [data-dlg="si"]');
590
+ boton?.click();
591
+ const leeNombres = () =>
592
+ [...document.querySelectorAll('.fichaRPG h3')].map(h => h.textContent.trim());
593
+ // Wait for the house, not for a guess at how long building takes. A
594
+ // fixed sleep here made this check itself flaky, which is the one thing
595
+ // a check for a flaky bug must not be.
596
+ let nombres = [];
597
+ for (let i = 0; i < 60; i++) {
598
+ nombres = leeNombres();
599
+ if (!document.querySelector('.dlgFondo') && nombres.includes('urgencias')) break;
600
+ await new Promise(r => setTimeout(r, 250));
601
+ }
602
+ return { sobrevive, cerrado: !document.querySelector('.dlgFondo'), nombres };
603
+ })()`);
604
+ comprueba(
605
+ 'a name typed while the roles are still loading is still there afterwards',
606
+ construida?.sobrevive === 'urgencias',
607
+ JSON.stringify(construida),
608
+ );
609
+ comprueba(
610
+ 'and pressing build actually builds the house',
611
+ !!construida?.cerrado && (construida?.nombres ?? []).includes('urgencias'),
612
+ JSON.stringify(construida),
613
+ );
614
+
615
+ // And the other half: pressing build with nothing in the name.
616
+ //
617
+ // The failure this guards is not "it refuses" — it is refusing SILENTLY, or
618
+ // refusing while closing the dialog, which loses everything the person had
619
+ // already chosen. That is the same complaint from the other side: a form
620
+ // that says no and takes the work with it.
621
+ const vacia = await cdp.evalua(`(async () => {
622
+ const espera = async (sel, n = 40) => {
623
+ for (let i = 0; i < n; i++) {
624
+ const el = document.querySelector(sel);
625
+ if (el) return el;
626
+ await new Promise(r => setTimeout(r, 250));
627
+ }
628
+ return null;
629
+ };
630
+ const antes = document.querySelectorAll('.fichaRPG h3').length;
631
+ document.getElementById('altaAgente')?.click();
632
+ const campo = await espera('.dlgFondo #bvNombre');
633
+ if (!campo) return { falta: true };
634
+ // Choose something first: whatever it says, this must not be thrown away.
635
+ document.querySelector('.dlgFondo [data-bv=\"clase\"][data-id=\"knowledge\"]')?.click();
636
+ await new Promise(r => setTimeout(r, 300));
637
+ document.querySelector('.dlgFondo [data-dlg=\"si\"]')?.click();
638
+ await new Promise(r => setTimeout(r, 1200));
639
+ const caja = document.querySelector('.dlgFondo');
640
+ const elegida = !!document.querySelector('.dlgFondo .bvOpcion.elegida[data-id=\"knowledge\"]');
641
+ const aviso = document.querySelector('#aviso,.aviso,.toast');
642
+ const fuera = {
643
+ sigueAbierto: !!caja,
644
+ elegida,
645
+ dice: (aviso?.textContent ?? '').trim().slice(0, 80),
646
+ despues: document.querySelectorAll('.fichaRPG h3').length,
647
+ antes,
648
+ };
649
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
650
+ await new Promise(r => setTimeout(r, 200));
651
+ return fuera;
652
+ })()`);
653
+ comprueba(
654
+ 'building with no name refuses without closing the form',
655
+ !!vacia && !vacia.falta && vacia.sigueAbierto === true,
656
+ JSON.stringify(vacia),
657
+ );
658
+ comprueba(
659
+ 'and keeps what was already chosen, and builds nothing',
660
+ !!vacia?.elegida && vacia?.despues === vacia?.antes,
661
+ JSON.stringify(vacia),
662
+ );
663
+
556
664
  // Spanish and English, on a switch. The READMEs shipped bilingual and the
557
665
  // product did not; a language button that does not actually change the page
558
666
  // is worse than none at all.
package/bin/serve.py CHANGED
@@ -29,6 +29,7 @@ import stat
29
29
  import subprocess
30
30
  import sys
31
31
  import threading
32
+ import time
32
33
  import urllib.parse
33
34
  import webbrowser
34
35
 
@@ -44,6 +45,7 @@ import parcels # noqa: E402
44
45
  import units # noqa: E402
45
46
  import busca # noqa: E402 the disk scanner, and the one list of where work lives
46
47
  import cities # noqa: E402
48
+ import diario # noqa: E402 what happened, written down
47
49
  import demos # noqa: E402 the recorded demos the Hall plays back
48
50
  import roads # noqa: E402
49
51
  import reception # noqa: E402
@@ -523,6 +525,15 @@ def actividad_viva(datos):
523
525
  return {"online": False, "url": "", "city": "", "started_at": ""}
524
526
 
525
527
 
528
+ def _resumen_cuerpo(cuerpo):
529
+ """On success, the shape rather than the content: which fields arrived and
530
+ how long each was. A log that repeats every goal somebody types is a log
531
+ they will not send anywhere."""
532
+ if not isinstance(cuerpo, dict):
533
+ return {}
534
+ return {k: (len(v) if isinstance(v, (str, list)) else v) for k, v in cuerpo.items()}
535
+
536
+
526
537
  def _que_es_git(ruta):
527
538
  """`repo`, `worktree`, or nothing. A label on a row, never a filter: the
528
539
  picker shows every folder and lets the person decide which one matters."""
@@ -603,7 +614,27 @@ class Manejador(http.server.BaseHTTPRequestHandler):
603
614
  if os.environ.get("CITY_SETUP_DEBUG"):
604
615
  sys.stderr.write(" %s\n" % (format % args))
605
616
 
617
+ #: What this handler last answered, so the request log can say so without
618
+ #: every endpoint having to report for itself.
619
+ _ultimo = None
620
+ _error = None
621
+
622
+ def apunta(self, q, tipo, **campos):
623
+ """One journal line, in the journal of the city the request acted on.
624
+
625
+ Not the selected one: a request carries `?city=`, and writing its line
626
+ into whichever city happened to be current put the record of what
627
+ happened to one city in another city's file. Which is worse than no
628
+ record, because it is a record that lies about where.
629
+ """
630
+ try:
631
+ diario.apunta(self.ciudad(q), tipo, **campos)
632
+ except Exception: # noqa: BLE001 logging must not break the request
633
+ pass
634
+
606
635
  def responde(self, cuerpo, tipo="application/json", codigo=200):
636
+ self._ultimo = codigo
637
+ self._error = cuerpo.get("error") if isinstance(cuerpo, dict) else None
607
638
  if not isinstance(cuerpo, bytes):
608
639
  cuerpo = json.dumps(cuerpo).encode()
609
640
  self.send_response(codigo)
@@ -690,6 +721,7 @@ class Manejador(http.server.BaseHTTPRequestHandler):
690
721
  "/api/instrucciones": "g_instrucciones",
691
722
  "/api/live": "g_live",
692
723
  "/api/carpeta": "g_carpeta",
724
+ "/api/diario": "g_diario",
693
725
  "/api/demos": "g_demos",
694
726
  "/api/domains": "g_domains",
695
727
  "/api/roles": "g_roles",
@@ -707,6 +739,7 @@ class Manejador(http.server.BaseHTTPRequestHandler):
707
739
  "/api/roads": "p_roads",
708
740
  "/api/reception": "p_reception",
709
741
  "/api/agente": "p_agente",
742
+ "/api/diario": "p_diario",
710
743
  "/api/agentes": "p_agentes",
711
744
  "/api/montaje": "p_montaje",
712
745
  "/api/motor": "p_motor",
@@ -759,9 +792,24 @@ class Manejador(http.server.BaseHTTPRequestHandler):
759
792
  cuerpo = json.loads(self.rfile.read(largo) or b"{}")
760
793
  except (ValueError, json.JSONDecodeError) as e:
761
794
  return self.responde({"error": f"unreadable body: {e}"}, codigo=400)
762
- if ruta in self.POSTS:
763
- return getattr(self, self.POSTS[ruta])(q, cuerpo)
764
- return self.responde({"error": "no such thing"}, codigo=404)
795
+ if ruta not in self.POSTS:
796
+ self.apunta(q, "post", ruta=ruta, estado=404, error="no such thing")
797
+ return self.responde({"error": "no such thing"}, codigo=404)
798
+ # Every write is recorded before it happens and judged after, so a
799
+ # request that never came back says so too — a handler that hangs or
800
+ # dies leaves a line with no verdict, which is itself the finding.
801
+ empezado = time.monotonic()
802
+ self._ultimo = None
803
+ try:
804
+ salida = getattr(self, self.POSTS[ruta])(q, cuerpo)
805
+ except Exception as e: # noqa: BLE001 the log is the point
806
+ self.apunta(q, "post", ruta=ruta, error=f"{type(e).__name__}: {e}",
807
+ cuerpo=cuerpo, ms=int((time.monotonic() - empezado) * 1000))
808
+ raise
809
+ self.apunta(q, "post", ruta=ruta, estado=self._ultimo,
810
+ cuerpo=cuerpo if self._ultimo != 200 else _resumen_cuerpo(cuerpo),
811
+ error=self._error, ms=int((time.monotonic() - empezado) * 1000))
812
+ return salida
765
813
 
766
814
  def g_estado(self, q):
767
815
  datos = self.ciudad(q)
@@ -871,6 +919,16 @@ class Manejador(http.server.BaseHTTPRequestHandler):
871
919
  return self.responde({"error": "no such demo"}, codigo=404)
872
920
  return self.responde({**(demos.ficha(cual, eventos) or {}), "eventos": eventos})
873
921
 
922
+ def g_diario(self, q):
923
+ """The journal, for `doctor --log` and for anybody about to send it."""
924
+ try:
925
+ cuantas = max(1, min(2000, int(q.get("n", ["200"])[0])))
926
+ except ValueError:
927
+ cuantas = 200
928
+ return self.responde(
929
+ {"ruta": diario.ruta(self.ciudad(q)), "lineas": diario.lee(self.ciudad(q), cuantas)}
930
+ )
931
+
874
932
  def g_carpeta(self, q):
875
933
  """One folder, listed: what is in it, and what each thing is.
876
934
 
@@ -1323,8 +1381,23 @@ class Manejador(http.server.BaseHTTPRequestHandler):
1323
1381
  return self.responde({"error": "this city has no owner card yet"}, codigo=409)
1324
1382
  nombre = " ".join(str(cuerpo.get("name") or "").split())
1325
1383
  slug = card.ventana(nombre)
1326
- if not nombre or not card.ventana_valida(slug):
1384
+ # `card.ventana` falls back to the word `repo` when nothing in the name
1385
+ # survives slugging — which is right for a repository whose folder is
1386
+ # punctuation, and wrong here: a house called `///` would be created
1387
+ # with the window `repo`, and the name a person reads would stop being
1388
+ # the thing the city addresses. A name has to carry a letter or a digit.
1389
+ legible = bool(re.search(r"[a-z0-9]", nombre.lower()))
1390
+ if not nombre or not card.ventana_valida(slug) or not legible:
1327
1391
  return self.responde({"error": "an agent needs a plain name"}, codigo=400)
1392
+ # A window slug is cut at 80 characters, so a longer name would be
1393
+ # stored in full on the card and addressed by a truncated one — the
1394
+ # name a person reads and the window it opens quietly stop being the
1395
+ # same thing. Refuse rather than silently rename.
1396
+ if len(nombre) > 80:
1397
+ return self.responde(
1398
+ {"error": "an agent's name has to fit in a window title: 80 characters"},
1399
+ codigo=400,
1400
+ )
1328
1401
  clase = str(cuerpo.get("kind") or workspace.CLASE_DEFECTO).strip().lower()
1329
1402
  if clase not in workspace.CLASES:
1330
1403
  return self.responde(
@@ -1419,6 +1492,24 @@ class Manejador(http.server.BaseHTTPRequestHandler):
1419
1492
  }
1420
1493
  )
1421
1494
 
1495
+ def p_diario(self, q, cuerpo):
1496
+ """The browser's half of the log.
1497
+
1498
+ Half of what goes wrong here goes wrong in the page — a handler that
1499
+ threw, a fetch that never came back, a button that did nothing. A log
1500
+ that stops at the network boundary tells half the story, so the page
1501
+ writes into the same file the server does, and one file answers "what
1502
+ happened" instead of two that have to be lined up by hand.
1503
+ """
1504
+ diario.apunta(
1505
+ self.ciudad(q),
1506
+ "browser",
1507
+ que=str(cuerpo.get("que") or "")[:120],
1508
+ detalle=cuerpo.get("detalle"),
1509
+ donde=str(cuerpo.get("donde") or "")[:200],
1510
+ )
1511
+ return self.responde({"ok": True})
1512
+
1422
1513
  def p_agente(self, q, cuerpo):
1423
1514
  """Tune one agent from its character sheet: model, effort, runtime and
1424
1515
  avatar seed, written to the card keys the launcher already resolves. An
package/bin/test CHANGED
@@ -22,7 +22,7 @@ if [ -d .git ] && [ -x .githooks/pre-commit ] \
22
22
  && printf ' pre-commit gate armed (core.hooksPath .githooks)\n'
23
23
  fi
24
24
 
25
- SUITES=(widgets card parcels domains busca arnes i18n serve seat cities channel connect committee live-feed runtime claude-runtime runtime-ui runtime-failures stress adapter benchmark demo contracts exit rutas cage broker evidencia admision pairing hall-protocol doctor security workspace avatar crecimiento atajos navegador actualiza desinstala launch)
25
+ SUITES=(widgets card parcels domains busca arnes i18n diario serve seat cities channel connect committee live-feed runtime claude-runtime runtime-ui runtime-failures stress adapter benchmark demo contracts exit rutas cage broker evidencia admision pairing hall-protocol doctor security workspace avatar crecimiento atajos navegador actualiza desinstala launch)
26
26
  [ $# -gt 0 ] && SUITES=("$@")
27
27
 
28
28
  fallos=0
@@ -91,6 +91,7 @@ necesarios = ["bin/agents-city.js", "bin/hall", "bin/seat", "bin/city", "bin/dem
91
91
  "plugin/scripts/crecimiento.py",
92
92
  "plugin/scripts/reset.py", "plugin/scripts/desinstala.py",
93
93
  "plugin/scripts/demos.py", "plugin/scripts/arnes.py",
94
+ "plugin/scripts/diario.py",
94
95
  "plugin/channel/runtime/arnes.json", "plugin/scripts/capabilities.py",
95
96
  "plugin/scripts/deliberations.py",
96
97
  "plugin/scripts/runtime_processes.py",
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env python3
2
+ """The journal: what it records, and everything it must survive.
3
+
4
+ This exists so a failure on somebody else's machine is a file they can send
5
+ rather than a story they have to remember. That promise has two halves, and the
6
+ second is the one with teeth:
7
+
8
+ · it records what happened — and
9
+ · it is safe to attach without reading it first, it never grows without
10
+ bound, and it can never be the reason a request failed.
11
+
12
+ A log that breaks the thing it is logging is worse than no log at all, so most
13
+ of what follows is the unhappy path: an unwritable directory, a value that
14
+ cannot be serialised, a line somebody corrupted, a structure with no bottom.
15
+ """
16
+
17
+ import os
18
+ import shutil
19
+ import sys
20
+ import tempfile
21
+
22
+ AQUI = os.path.dirname(os.path.abspath(__file__))
23
+ RAIZ = os.path.dirname(AQUI)
24
+ sys.path.insert(0, AQUI)
25
+ sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
26
+
27
+ import cities # noqa: E402
28
+ import diario # noqa: E402
29
+ from testlib import afirma, comprueba, resumen # noqa: E402
30
+
31
+
32
+ def ciudad():
33
+ """A city with an identity, which is what the journal's path is keyed on."""
34
+ base = tempfile.mkdtemp()
35
+ datos = os.path.join(base, "home")
36
+ os.makedirs(datos)
37
+ with open(os.path.join(datos, "city.yml"), "w", encoding="utf-8") as f:
38
+ f.write("owner: quien\nname: home\nslug: home\nid: city-diario-prueba\n")
39
+ return base, datos
40
+
41
+
42
+ def lo_que_registra(datos):
43
+ print(" what it records")
44
+ diario.apunta(datos, "post", ruta="/api/agentes", estado=400, error="an agent needs a name")
45
+ diario.apunta(datos, "browser", que="api refused", donde="/api/agentes")
46
+ lineas = diario.lee(datos)
47
+ comprueba("· one line per thing that happened", len(lineas), 2)
48
+ comprueba("· in the order they happened", lineas[0]["tipo"], "post")
49
+ afirma("· with the moment", bool(lineas[0].get("t")), str(lineas[0]))
50
+ comprueba("· and the reason a request was refused",
51
+ lineas[0]["error"], "an agent needs a name")
52
+ afirma("· the browser's half lands in the same file as the server's",
53
+ lineas[1]["tipo"] == "browser" and lineas[1]["que"] == "api refused", str(lineas[1]))
54
+ afirma("· the file is where the runtime keeps this city's things",
55
+ diario.ruta(datos).endswith("hall.jsonl")
56
+ and cities.identidad(datos).lower().replace("_", "-")[:20] in diario.ruta(datos).lower(),
57
+ diario.ruta(datos))
58
+
59
+
60
+ def nunca_un_secreto(datos):
61
+ print(" what it must never record")
62
+ diario.apunta(
63
+ datos, "post",
64
+ PASE="s3cr3t-pase-value", token="tok_abc", Authorization="Bearer xyz",
65
+ api_key="k", password="p",
66
+ anidado={"cookie": "c", "inocente": "visible"},
67
+ suelto="sk-ant-" + "A" * 44,
68
+ )
69
+ linea = diario.lee(datos)[-1]
70
+ for clave in ("PASE", "token", "Authorization", "api_key", "password"):
71
+ comprueba(f"· {clave} by name", linea.get(clave), "[redacted]")
72
+ comprueba("· and inside a nested object", linea["anidado"]["cookie"], "[redacted]")
73
+ comprueba("· while its neighbour survives", linea["anidado"]["inocente"], "visible")
74
+ afirma("· a value merely SHAPED like a credential goes too",
75
+ "[redacted]" in linea["suelto"] and "AAAA" not in linea["suelto"], linea["suelto"])
76
+
77
+ # The regression that made this worse than useless: a temp directory is a
78
+ # long run of characters, and redacting it turned a real error message into
79
+ # `[redacted]` while protecting nothing.
80
+ for ruta in ("/var/folders/xy/T/tmpab12cd34ef56gh78ij90klmnopqrs/city",
81
+ "~/.agents-city/quien/home",
82
+ "/Users/alguien/codigo/un-repo-con-nombre-larguisimo-de-verdad"):
83
+ comprueba(f"· but a path is not a credential: {ruta[:24]}…", diario.limpia(ruta), ruta)
84
+
85
+
86
+ def nunca_rompe_la_peticion(datos):
87
+ print(" what it must survive")
88
+ # It is called from inside request handlers. Anything it raises becomes a
89
+ # 500 on a request that had already succeeded.
90
+ imposible = os.path.join(datos, "no-existe", "ni-va-a-existir")
91
+ open(os.path.join(datos, "fichero"), "w").close()
92
+ for roto, como in (
93
+ (os.path.join(datos, "fichero", "sub"), "a file where a directory should be"),
94
+ (imposible, "a directory nobody created"),
95
+ ):
96
+ try:
97
+ diario.apunta(roto, "post", ruta="/x")
98
+ afirma(f"· {como} is survived, not raised", True, "")
99
+ except Exception as e: # noqa: BLE001 that is the assertion
100
+ afirma(f"· {como} is survived, not raised", False, f"{type(e).__name__}: {e}")
101
+
102
+ class NoSerializa:
103
+ pass
104
+
105
+ try:
106
+ diario.apunta(datos, "post", objeto=NoSerializa())
107
+ afirma("· a value that cannot be written is survived too", True, "")
108
+ except Exception as e: # noqa: BLE001
109
+ afirma("· a value that cannot be written is survived too", False, str(e))
110
+
111
+ print(" and what it must not do to itself")
112
+ hondo = {"a": {}}
113
+ nodo = hondo["a"]
114
+ for _ in range(40):
115
+ nodo["a"] = {}
116
+ nodo = nodo["a"]
117
+ diario.apunta(datos, "post", hondo=hondo)
118
+ afirma("· a structure with no bottom does not recurse forever",
119
+ len(diario.lee(datos)) > 0, "")
120
+ diario.apunta(datos, "post", larga="x" * 5000, lista=list(range(500)))
121
+ linea = diario.lee(datos)[-1]
122
+ afirma("· a very long string is cut, and says so",
123
+ len(linea["larga"]) <= 501 and linea["larga"].endswith("…"), str(len(linea["larga"])))
124
+ afirma("· and a very long list is cut", len(linea["lista"]) <= 40, str(len(linea["lista"])))
125
+
126
+
127
+ def se_puede_leer_siempre(datos):
128
+ print(" and reading it")
129
+ with open(diario.ruta(datos), "a", encoding="utf-8") as f:
130
+ f.write("esto no es json\n\n")
131
+ lineas = diario.lee(datos)
132
+ afirma("· a corrupted line does not stop the rest being read",
133
+ any(l.get("tipo") == "unreadable" for l in lineas) and len(lineas) > 1, str(lineas[-2:]))
134
+ comprueba("· a city with no journal reads as nothing, not as an error",
135
+ diario.lee(tempfile.mkdtemp()), [])
136
+
137
+
138
+ def no_crece_sin_fin(datos):
139
+ print(" and it does not grow without bound")
140
+ limite = diario.LIMITE
141
+ diario.LIMITE = 2000
142
+ try:
143
+ for i in range(60):
144
+ diario.apunta(datos, "post", ruta="/api/x", relleno="y" * 200, i=i)
145
+ actual = os.path.getsize(diario.ruta(datos))
146
+ afirma("· it rotates instead of growing", actual < 2000 * 3, str(actual))
147
+ afirma("· and keeps the previous one, so the rotation is not a hole",
148
+ os.path.isfile(diario.ruta(datos) + ".1"), "")
149
+ lineas = diario.lee(datos, 500)
150
+ afirma("· reading spans the rotation, oldest first",
151
+ len(lineas) > 20
152
+ and [l.get("i") for l in lineas if "i" in l]
153
+ == sorted(l.get("i") for l in lineas if "i" in l),
154
+ str([l.get("i") for l in lineas][:8]))
155
+ finally:
156
+ diario.LIMITE = limite
157
+
158
+
159
+ def main():
160
+ # An app home of its own. Without this the journal's path is derived from
161
+ # `cities.raiz()`, which is the caller's real `~/.agents-city` — so this
162
+ # suite wrote into the machine it was running on, and read back somebody
163
+ # else's lines. A test that touches the real home is worse than a flaky
164
+ # one: it is a test that changes the thing it is measuring.
165
+ previo = os.environ.get("AGENTS_CITY_HOME")
166
+ os.environ["AGENTS_CITY_HOME"] = tempfile.mkdtemp()
167
+ base, datos = ciudad()
168
+ try:
169
+ lo_que_registra(datos)
170
+ nunca_un_secreto(datos)
171
+ nunca_rompe_la_peticion(datos)
172
+ se_puede_leer_siempre(datos)
173
+ no_crece_sin_fin(datos)
174
+ finally:
175
+ shutil.rmtree(os.environ["AGENTS_CITY_HOME"], ignore_errors=True)
176
+ if previo is None:
177
+ os.environ.pop("AGENTS_CITY_HOME", None)
178
+ else:
179
+ os.environ["AGENTS_CITY_HOME"] = previo
180
+ shutil.rmtree(base, ignore_errors=True)
181
+ return resumen("diario")
182
+
183
+
184
+ if __name__ == "__main__":
185
+ sys.exit(main())
@@ -17,6 +17,7 @@ lost its Chrome fails loudly instead of quietly testing nothing.
17
17
  """
18
18
 
19
19
  import hashlib
20
+ import json
20
21
  import os
21
22
  import shutil
22
23
  import subprocess
@@ -201,6 +202,14 @@ def main():
201
202
  afirma("· the browser checks ran at all", False, texto.strip()[-500:])
202
203
  elif salida.returncode != 0:
203
204
  afirma("· the browser driver finished cleanly", False, texto.strip()[-300:])
205
+ # A browser check that fails says what it saw in the page. What the
206
+ # SERVER saw is in the city's journal, and printing it here is the
207
+ # difference between "it did nothing" and the refusal that caused it.
208
+ if salida.returncode != 0:
209
+ import diario
210
+
211
+ for entrada in diario.lee(datos, 25):
212
+ print(" journal " + json.dumps(entrada, ensure_ascii=False)[:200])
204
213
  finally:
205
214
  servidor.shutdown()
206
215
  servidor.server_close()
package/bin/test-serve.py CHANGED
@@ -115,6 +115,78 @@ def prepara_recepcion(hall_datos):
115
115
  return city_id, connection_id, road_id, remote_message_id, injection
116
116
 
117
117
 
118
+ def la_casa_que_no_debe_construirse(puerto, hallDatos):
119
+ """Every way a house must not be built, and what it must leave behind.
120
+
121
+ "I put the name in and it says I did not" was the report, and the server
122
+ half of that has to be exact: a refusal refuses, says why, leaves the card
123
+ as it was — a half-written roster is worse than a rejected one — and lands
124
+ in the journal, because that is what somebody sends when this happens on
125
+ their machine and not here.
126
+ """
127
+ # ── the house that must not be built ────────────────────────────────
128
+ # "I put the name in and it says I did not" was the report. The server
129
+ # half of that has to be exact: a refusal must refuse, must say why, and
130
+ # must leave the card as it was — a half-written roster is worse than a
131
+ # rejected one. And each refusal must be IN THE JOURNAL, because that is
132
+ # what somebody sends when it happens on their machine and not here.
133
+ print(" the house that must not be built")
134
+ antes = open(os.path.join(hallDatos, "halltest.md"), encoding="utf-8").read()
135
+ # Every reason this test provoked, so the journal can be checked against
136
+ # what actually happened rather than against a number somebody guessed.
137
+ # The count was the wrong assertion: it passed here and failed on Linux,
138
+ # and said nothing about WHICH refusal had gone missing.
139
+ provocados = []
140
+ for nombre, porque in (
141
+ ("", "no name at all"),
142
+ (" ", "a name that is only spaces"),
143
+ ("///", "a name that survives slugging as nothing"),
144
+ ("x" * 200, "a name longer than a window can be called"),
145
+ ):
146
+ st, cuerpo = pide(puerto, "/api/agentes", metodo="POST",
147
+ cuerpo={"name": nombre, "kind": "code", "role": "blank"})
148
+ comprueba(f"· {porque} is refused", st, 400)
149
+ motivo = json.loads(cuerpo).get("error", "")
150
+ afirma(f"· {porque} is refused with a reason a person can read",
151
+ len(motivo) > 12, cuerpo.decode())
152
+ provocados.append(motivo)
153
+ afirma("· and not one of them changed the card",
154
+ open(os.path.join(hallDatos, "halltest.md"), encoding="utf-8").read() == antes,
155
+ "a refused agent must leave no trace")
156
+
157
+ st, _ = pide(puerto, "/api/agentes", metodo="POST",
158
+ cuerpo={"name": "dos veces", "kind": "code", "role": "blank"})
159
+ comprueba("· a good name is accepted once", st, 200)
160
+ st, cuerpo = pide(puerto, "/api/agentes", metodo="POST",
161
+ cuerpo={"name": "dos veces", "kind": "code", "role": "blank"})
162
+ comprueba("· and the same name again is a conflict, not a duplicate", st, 409)
163
+ provocados.append(json.loads(cuerpo).get("error", ""))
164
+ st, cuerpo = pide(puerto, "/api/agentes", metodo="POST",
165
+ cuerpo={"name": "tercera", "kind": "inventada", "role": "blank"})
166
+ comprueba("· a kind nobody offers is refused", st, 400)
167
+ provocados.append(json.loads(cuerpo).get("error", ""))
168
+
169
+ # Read the journal through the endpoint, not by guessing its path.
170
+ #
171
+ # `ciudad({})` falls back to the SELECTED city, and this suite changes which
172
+ # one that is — it archives one, resets another, creates a third. Computing
173
+ # the path from `hallDatos` and hoping it matched is how this passed here
174
+ # and failed on Linux: five refusals in the file it looked at and one in
175
+ # another. Asking the server resolves the city exactly as the writes did.
176
+ _st, _cuerpo = pide(puerto, "/api/diario?n=400")
177
+ anotado = json.loads(_cuerpo)["lineas"]
178
+ escritos = [l.get("error") for l in anotado
179
+ if l.get("ruta") == "/api/agentes" and l.get("estado") in (400, 409)]
180
+ faltan = [m for m in provocados if m not in escritos]
181
+ afirma("· every refusal this test caused is in the journal, with its reason",
182
+ not faltan,
183
+ f"missing from {json.loads(_cuerpo)['ruta']}: {faltan}; journalled: {escritos}")
184
+ afirma("· and the journal says what was asked for, not just that it failed",
185
+ any("name" in (l.get("cuerpo") or {}) for l in anotado
186
+ if l.get("ruta") == "/api/agentes"),
187
+ str(anotado[-1:]))
188
+
189
+
118
190
  def main():
119
191
  print()
120
192
  destino = tempfile.mkdtemp()
@@ -1056,6 +1128,8 @@ def main():
1056
1128
  pide(puerto, "/api/agente", metodo="POST",
1057
1129
  cuerpo={"agent": "notas", "runtime": "", "model": "", "effort": ""})
1058
1130
 
1131
+ la_casa_que_no_debe_construirse(puerto, hallDatos)
1132
+
1059
1133
  # ── the demo shelf ───────────────────────────────────────────────────
1060
1134
  print(" the demos the Hall plays back")
1061
1135
  st, cuerpo = pide(puerto, "/api/demos")
@@ -645,11 +645,25 @@ var FormularioDeCasa = class extends Montada {
645
645
  try {
646
646
  const r = await this.p.api("/api/roles?scope=agent");
647
647
  this.roles = r.roles ?? [];
648
- if (this.roles.length) this.repinta();
648
+ this.pintaRoles();
649
649
  } catch {
650
650
  this.roles = [];
651
651
  }
652
652
  }
653
+ /** The role list, once it arrives, without touching anything else. */
654
+ pintaRoles() {
655
+ const sel = this.host?.querySelector("#bvRol");
656
+ if (!sel || !this.roles.length) return;
657
+ sel.innerHTML = this.opcionesDeRol();
658
+ sel.value = this.datos.rol;
659
+ sel.onchange = () => this.recoge();
660
+ this.recoge();
661
+ }
662
+ opcionesDeRol() {
663
+ return this.roles.map(
664
+ (r) => `<option value="${this.p.esc(r.id)}"${r.id === this.datos.rol ? " selected" : ""}>${this.p.esc(r.name)}</option>`
665
+ ).join("");
666
+ }
653
667
  /** The folder picker, built once so walking survives a repaint. */
654
668
  explorador = null;
655
669
  explora() {
@@ -741,6 +755,12 @@ var FormularioDeCasa = class extends Montada {
741
755
  this.enlaza(hueco);
742
756
  }
743
757
  enlaza(raiz) {
758
+ raiz.querySelectorAll(
759
+ "#bvNombre,#bvRol,#bvModelo,#bvEsfuerzo"
760
+ ).forEach((el) => {
761
+ el.oninput = () => this.recoge();
762
+ el.onchange = () => this.recoge();
763
+ });
744
764
  raiz.querySelectorAll("[data-bv]").forEach((el) => {
745
765
  el.onclick = (evento) => {
746
766
  evento.preventDefault();
@@ -1477,14 +1497,37 @@ function isSpeechEvent(event) {
1477
1497
  // city/web/src/hall.ts
1478
1498
  var PASE = window.PASE;
1479
1499
  var CIUDAD = new URLSearchParams(location.search).get("city") ?? "";
1500
+ function anota2(que, detalle, donde) {
1501
+ try {
1502
+ let u = "/api/diario?PASE=" + encodeURIComponent(PASE);
1503
+ if (CIUDAD) u += "&city=" + encodeURIComponent(CIUDAD);
1504
+ void fetch(u, {
1505
+ method: "POST",
1506
+ headers: { "X-City-Pase": PASE, "Content-Type": "application/json" },
1507
+ body: JSON.stringify({ que, detalle, donde: donde ?? location.hash ?? "" }),
1508
+ keepalive: true
1509
+ }).catch(() => {
1510
+ });
1511
+ } catch {
1512
+ }
1513
+ }
1480
1514
  async function api(ruta, opts) {
1481
1515
  let u = ruta + (ruta.includes("?") ? "&" : "?") + "PASE=" + encodeURIComponent(PASE);
1482
1516
  if (CIUDAD) u += "&city=" + encodeURIComponent(CIUDAD);
1483
- const r = await fetch(u, {
1484
- headers: { "X-City-Pase": PASE, "Content-Type": "application/json" },
1485
- ...opts
1486
- });
1487
- return r.json();
1517
+ let r;
1518
+ try {
1519
+ r = await fetch(u, {
1520
+ headers: { "X-City-Pase": PASE, "Content-Type": "application/json" },
1521
+ ...opts
1522
+ });
1523
+ } catch (e) {
1524
+ if (ruta !== "/api/diario") anota2("fetch failed", String(e), ruta);
1525
+ throw e;
1526
+ }
1527
+ const cuerpo = await r.json();
1528
+ if (ruta !== "/api/diario" && (!r.ok || cuerpo?.error))
1529
+ anota2("api refused", { estado: r.status, error: cuerpo?.error }, ruta);
1530
+ return cuerpo;
1488
1531
  }
1489
1532
  function q(sel, raiz = document) {
1490
1533
  const el = raiz.querySelector(sel);
@@ -3357,6 +3400,14 @@ function interruptorDeIdioma() {
3357
3400
  void refresca();
3358
3401
  };
3359
3402
  }
3403
+ window.addEventListener(
3404
+ "error",
3405
+ (e) => anota2("uncaught error", { mensaje: e.message, fichero: e.filename, linea: e.lineno })
3406
+ );
3407
+ window.addEventListener(
3408
+ "unhandledrejection",
3409
+ (e) => anota2("unhandled rejection", String(e.reason))
3410
+ );
3360
3411
  arrastreDelRail();
3361
3412
  interruptorDeIdioma();
3362
3413
  tema();
@@ -101,12 +101,38 @@ export class FormularioDeCasa extends Montada {
101
101
  try {
102
102
  const r = await this.p.api<{ roles: Rol[] }>('/api/roles?scope=agent');
103
103
  this.roles = r.roles ?? [];
104
- if (this.roles.length) this.repinta();
104
+ // Only the one control that changed. Rebuilding the whole form because a
105
+ // dropdown filled in is how the name got lost in the first place, and it
106
+ // also took the folder picker down mid-walk.
107
+ this.pintaRoles();
105
108
  } catch {
106
109
  this.roles = []; // the role list is a convenience; blank is always valid
107
110
  }
108
111
  }
109
112
 
113
+ /** The role list, once it arrives, without touching anything else. */
114
+ private pintaRoles(): void {
115
+ const sel = this.host?.querySelector<HTMLSelectElement>('#bvRol');
116
+ if (!sel || !this.roles.length) return;
117
+ sel.innerHTML = this.opcionesDeRol();
118
+ sel.value = this.datos.rol;
119
+ // A select that answers to nothing is a select that silently keeps the
120
+ // first option no matter what the person picks.
121
+ sel.onchange = () => this.recoge();
122
+ this.recoge();
123
+ }
124
+
125
+ private opcionesDeRol(): string {
126
+ return this.roles
127
+ .map(
128
+ (r) =>
129
+ `<option value="${this.p.esc(r.id)}"${
130
+ r.id === this.datos.rol ? ' selected' : ''
131
+ }>${this.p.esc(r.name)}</option>`,
132
+ )
133
+ .join('');
134
+ }
135
+
110
136
  /** The folder picker, built once so walking survives a repaint. */
111
137
  private explorador: Explorador | null = null;
112
138
 
@@ -215,6 +241,25 @@ export class FormularioDeCasa extends Montada {
215
241
  }
216
242
 
217
243
  protected enlaza(raiz: HTMLElement): void {
244
+ // Every field writes through as it is typed.
245
+ //
246
+ // `recoge()` used to be the only sync, called at the moments somebody
247
+ // remembered to call it — and a repaint from anywhere else redrew the
248
+ // inputs from a state that had never heard of what was in them. The roles
249
+ // list arrives a few hundred milliseconds after this form opens, which is
250
+ // exactly while a person is typing the name: the answer landed, the form
251
+ // repainted, the name was gone, and then "Build it" said to give it one.
252
+ //
253
+ // Now nothing can lose it, because there is no window in which the field
254
+ // and the state disagree.
255
+ raiz
256
+ .querySelectorAll<HTMLInputElement | HTMLSelectElement>(
257
+ '#bvNombre,#bvRol,#bvModelo,#bvEsfuerzo',
258
+ )
259
+ .forEach((el) => {
260
+ el.oninput = () => this.recoge();
261
+ el.onchange = () => this.recoge();
262
+ });
218
263
  raiz.querySelectorAll<HTMLElement>('[data-bv]').forEach((el) => {
219
264
  el.onclick = (evento) => {
220
265
  evento.preventDefault();
@@ -279,14 +279,52 @@ declare global {
279
279
  const PASE = window.PASE;
280
280
  const CIUDAD = new URLSearchParams(location.search).get('city') ?? '';
281
281
 
282
+ /**
283
+ * Tell the city's journal something happened here.
284
+ *
285
+ * The page is half of this product and it used to keep its failures to itself:
286
+ * an error became a toast, the toast went away, and a person reporting it had
287
+ * only their memory. This writes into the same file the server writes, so one
288
+ * file answers "what happened" — and `agents-city doctor --report` can hand it
289
+ * to somebody else without them having to have been watching.
290
+ *
291
+ * It never throws and never awaits: a log that can break the thing it is
292
+ * logging, or slow it down, is worse than no log.
293
+ */
294
+ function anota(que: string, detalle?: unknown, donde?: string): void {
295
+ try {
296
+ let u = '/api/diario?PASE=' + encodeURIComponent(PASE);
297
+ if (CIUDAD) u += '&city=' + encodeURIComponent(CIUDAD);
298
+ void fetch(u, {
299
+ method: 'POST',
300
+ headers: { 'X-City-Pase': PASE, 'Content-Type': 'application/json' },
301
+ body: JSON.stringify({ que, detalle, donde: donde ?? location.hash ?? '' }),
302
+ keepalive: true,
303
+ }).catch(() => {});
304
+ } catch {
305
+ /* the journal is a courtesy, never a dependency */
306
+ }
307
+ }
308
+
282
309
  async function api<T>(ruta: string, opts?: RequestInit): Promise<T> {
283
310
  let u = ruta + (ruta.includes('?') ? '&' : '?') + 'PASE=' + encodeURIComponent(PASE);
284
311
  if (CIUDAD) u += '&city=' + encodeURIComponent(CIUDAD);
285
- const r = await fetch(u, {
286
- headers: { 'X-City-Pase': PASE, 'Content-Type': 'application/json' },
287
- ...opts,
288
- });
289
- return r.json() as Promise<T>;
312
+ let r: Response;
313
+ try {
314
+ r = await fetch(u, {
315
+ headers: { 'X-City-Pase': PASE, 'Content-Type': 'application/json' },
316
+ ...opts,
317
+ });
318
+ } catch (e) {
319
+ // A request that never came back. This is the one a person cannot report,
320
+ // because nothing on screen says it happened.
321
+ if (ruta !== '/api/diario') anota('fetch failed', String(e), ruta);
322
+ throw e;
323
+ }
324
+ const cuerpo = (await r.json()) as T & { error?: string };
325
+ if (ruta !== '/api/diario' && (!r.ok || cuerpo?.error))
326
+ anota('api refused', { estado: r.status, error: cuerpo?.error }, ruta);
327
+ return cuerpo as T;
290
328
  }
291
329
 
292
330
  /** querySelector that refuses to hand back null: a missing element here is a bug
@@ -2684,6 +2722,15 @@ function interruptorDeIdioma(): void {
2684
2722
  };
2685
2723
  }
2686
2724
 
2725
+ // Nothing in a browser reports itself. These two are why a person can say "it
2726
+ // just did nothing" and be exactly right.
2727
+ window.addEventListener('error', (e) =>
2728
+ anota('uncaught error', { mensaje: e.message, fichero: e.filename, linea: e.lineno }),
2729
+ );
2730
+ window.addEventListener('unhandledrejection', (e) =>
2731
+ anota('unhandled rejection', String((e as PromiseRejectionEvent).reason)),
2732
+ );
2733
+
2687
2734
  arrastreDelRail();
2688
2735
  interruptorDeIdioma();
2689
2736
  tema();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-city",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Run autonomous agent cities: one chair seat, role-aware repo agents, repo-owned skills, and explicit roads.",
5
5
  "bin": {
6
6
  "agents-city": "bin/agents-city.js"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "city",
3
3
  "displayName": "Agents City",
4
- "version": "0.5.1",
4
+ "version": "0.5.2",
5
5
  "description": "One autonomous city seat with a work domain, role, goal, repo support agents, recognised skills and explicit roads to other cities.",
6
6
  "author": {
7
7
  "name": "jlcases",
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env python3
2
+ """What happened, written down, so a failure on somebody else's machine is not a
3
+ story they have to remember.
4
+
5
+ The Hall had no log at all. It answered the browser with an error, the browser
6
+ put a toast on screen, the toast went away, and there was nothing left. When
7
+ somebody said "it says the name is empty and the name is right there", the only
8
+ honest answer was "reproduce it for me" — which is asking a person to debug
9
+ their own bug report.
10
+
11
+ So: one file, always written, per city.
12
+
13
+ ~/.agents-city/.runtime/bus/<city>/hall.jsonl
14
+
15
+ One JSON object per line, oldest first. The browser writes into the same file
16
+ as the server, because half of these failures happen in the browser and a log
17
+ that stops at the network boundary tells half a story.
18
+
19
+ Two things it must never do. It must not grow without bound — it rotates at two
20
+ megabytes and keeps one previous file, which is enough to see what happened and
21
+ not enough to matter. And it must not record a secret: `agents-city doctor
22
+ --report` exists so somebody can send this to a stranger, and a log that has to
23
+ be read before it is sent is a log nobody sends.
24
+ """
25
+
26
+ import json
27
+ import os
28
+ import re
29
+ import time
30
+
31
+ #: Rotate here. Small enough that the file is readable, large enough to hold a
32
+ #: session's worth of a person clicking around.
33
+ LIMITE = 2 * 1024 * 1024
34
+
35
+ #: Anything whose NAME says it is a credential. Matched on the key, because the
36
+ #: value of a token looks like the value of an id.
37
+ SECRETO = re.compile(
38
+ r"pase|token|secret|password|passwd|authorization|cookie|api[_-]?key|credential",
39
+ re.I,
40
+ )
41
+
42
+ #: And anything shaped like one wherever it turns up: a long unbroken run of
43
+ #: hex or base64. Slashes are NOT part of it — a temp directory is a long run
44
+ #: of characters with slashes in it, and redacting `/var/folders/...` turned a
45
+ #: useful error message into `[redacted]` while protecting nothing.
46
+ PARECE_CLAVE = re.compile(r"(?<![\w./-])[A-Za-z0-9+_-]{32,}={0,2}(?![\w./-])")
47
+
48
+
49
+ def ruta(datos):
50
+ import runtime_processes
51
+
52
+ return os.path.join(runtime_processes.ruta(datos), "hall.jsonl")
53
+
54
+
55
+ def limpia(valor, profundidad=0):
56
+ """The same value with anything that looks like a credential taken out.
57
+
58
+ Applied on the way IN, not on the way out: a secret that reaches the file
59
+ has already been written to somebody's disk, and the promise this makes is
60
+ that the file is safe to attach to an issue without reading it first.
61
+ """
62
+ if profundidad > 6:
63
+ return "…"
64
+ if isinstance(valor, dict):
65
+ return {
66
+ k: ("[redacted]" if SECRETO.search(str(k)) else limpia(v, profundidad + 1))
67
+ for k, v in list(valor.items())[:40]
68
+ }
69
+ if isinstance(valor, (list, tuple)):
70
+ return [limpia(v, profundidad + 1) for v in valor[:40]]
71
+ if isinstance(valor, str):
72
+ recortado = valor if len(valor) <= 500 else valor[:497] + "…"
73
+ return PARECE_CLAVE.sub("[redacted]", recortado)
74
+ return valor
75
+
76
+
77
+ def _rota(fichero):
78
+ try:
79
+ if os.path.getsize(fichero) < LIMITE:
80
+ return
81
+ except OSError:
82
+ return
83
+ try:
84
+ os.replace(fichero, fichero + ".1")
85
+ except OSError:
86
+ pass
87
+
88
+
89
+ def apunta(datos, tipo, **campos):
90
+ """One line. Never raises: a log that can break the thing it is logging is
91
+ worse than no log, and this is called from inside request handlers."""
92
+ try:
93
+ fichero = ruta(datos)
94
+ os.makedirs(os.path.dirname(fichero), exist_ok=True)
95
+ _rota(fichero)
96
+ linea = {"t": time.strftime("%Y-%m-%dT%H:%M:%S"), "tipo": tipo}
97
+ linea.update(limpia(campos))
98
+ with open(fichero, "a", encoding="utf-8") as f:
99
+ f.write(json.dumps(linea, ensure_ascii=False) + "\n")
100
+ except (OSError, TypeError, ValueError):
101
+ pass
102
+
103
+
104
+ def lee(datos, cuantas=200):
105
+ """The last `cuantas` entries, oldest first, across the rotation."""
106
+ fuera = []
107
+ for fichero in (ruta(datos) + ".1", ruta(datos)):
108
+ try:
109
+ with open(fichero, encoding="utf-8") as f:
110
+ fuera.extend(l.strip() for l in f if l.strip())
111
+ except OSError:
112
+ continue
113
+ salida = []
114
+ for linea in fuera[-cuantas:]:
115
+ try:
116
+ salida.append(json.loads(linea))
117
+ except json.JSONDecodeError:
118
+ salida.append({"tipo": "unreadable", "linea": linea[:200]})
119
+ return salida
@@ -266,6 +266,56 @@ def informe_entorno():
266
266
  return 1 if roto else 0
267
267
 
268
268
 
269
+ def _diario(empaqueta, resto):
270
+ """`doctor --log` reads the journal; `doctor --report` bundles it to send.
271
+
272
+ The report is the thing that was missing. When this breaks on somebody
273
+ else's machine, the useful answer is not "tell me what you did" — it is a
274
+ file they can attach without reading it first. So it carries the journal and
275
+ the environment report, and nothing else: `diario` redacts credentials on
276
+ the way in, so what lands here was never a secret to begin with.
277
+ """
278
+ import cities # noqa: PLC0415
279
+ import diario # noqa: PLC0415
280
+
281
+ datos = cities.actual()
282
+ try:
283
+ cuantas = int(resto[0]) if resto else (2000 if empaqueta else 60)
284
+ except ValueError:
285
+ cuantas = 60
286
+ lineas = diario.lee(datos, cuantas)
287
+ if not empaqueta:
288
+ print(f'\n {diario.ruta(datos)}\n')
289
+ if not lineas:
290
+ print(' Nothing recorded yet. The Hall writes here as you use it.\n')
291
+ return 0
292
+ for l in lineas:
293
+ resto_l = {k: v for k, v in l.items() if k not in ('t', 'tipo')}
294
+ detalle = json.dumps(resto_l, ensure_ascii=False)[:160]
295
+ print(f" {l.get('t', '')} {l.get('tipo', ''):8} {detalle}")
296
+ print()
297
+ return 0
298
+
299
+ import io # noqa: PLC0415
300
+ from contextlib import redirect_stdout # noqa: PLC0415
301
+
302
+ entorno = io.StringIO()
303
+ with redirect_stdout(entorno):
304
+ informe_entorno()
305
+ destino = os.path.join(os.path.expanduser('~'), 'agents-city-report.txt')
306
+ with open(destino, 'w', encoding='utf-8') as f:
307
+ f.write('# agents-city report\n\n')
308
+ f.write('## this machine\n')
309
+ f.write(entorno.getvalue())
310
+ f.write(f'\n## the journal ({len(lineas)} entries)\n\n')
311
+ for l in lineas:
312
+ f.write(json.dumps(l, ensure_ascii=False) + '\n')
313
+ print(f'\n Written to {destino}\n')
314
+ print(' It holds what this machine is and what the town hall did.')
315
+ print(' Credentials are stripped as they are recorded, so it is safe to attach.\n')
316
+ return 0
317
+
318
+
269
319
  def main(argv=None):
270
320
  # The arguments are a parameter so this door can be knocked on from a test
271
321
  # without a subprocess: `doctor --config` is the command that backs a claim
@@ -290,6 +340,9 @@ def main(argv=None):
290
340
  # can be checked instead of believed.
291
341
  if argv[1] in ('--config', 'config'):
292
342
  return arnes.main(argv[2:])
343
+ # What happened, and something to attach to an issue.
344
+ if argv[1] in ('--log', 'log', '--report', 'report'):
345
+ return _diario(argv[1] in ('--report', 'report'), argv[2:])
293
346
  p = argparse.ArgumentParser(description='Detect, explain and migrate an old config shape.')
294
347
  p.add_argument('fichero', help='the config JSON file to check')
295
348
  p.add_argument('--fix', action='store_true', help='rewrite (default is dry-run report)')