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.
- package/.claude-plugin/marketplace.json +1 -1
- package/bin/navegador.mjs +108 -0
- package/bin/serve.py +95 -4
- package/bin/test +2 -1
- package/bin/test-diario.py +185 -0
- package/bin/test-navegador.py +9 -0
- package/bin/test-serve.py +74 -0
- package/city/web/dist-hall/hall.js +57 -6
- package/city/web/src/casa.ts +46 -1
- package/city/web/src/hall.ts +52 -5
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/scripts/diario.py +119 -0
- package/plugin/scripts/doctor.py +53 -0
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
|
-
|
|
764
|
-
|
|
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
|
-
|
|
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())
|
package/bin/test-navegador.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
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();
|
package/city/web/src/casa.ts
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/city/web/src/hall.ts
CHANGED
|
@@ -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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "city",
|
|
3
3
|
"displayName": "Agents City",
|
|
4
|
-
"version": "0.5.
|
|
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
|
package/plugin/scripts/doctor.py
CHANGED
|
@@ -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)')
|