agents-city 0.5.1 → 0.5.3

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.
@@ -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-seat.py CHANGED
@@ -1101,6 +1101,56 @@ def motores_del_puesto():
1101
1101
  )
1102
1102
 
1103
1103
 
1104
+ def el_settings_sobrevive_a_la_shell(asiento, api):
1105
+ """The launch line, parsed the way a shell parses it.
1106
+
1107
+ Asserting the words appear somewhere in the string is not enough, and the
1108
+ gap cost every Claude window in a city: `--settings {"a":"b","c":true}`
1109
+ emitted without quotes is split by brace expansion on the comma and
1110
+ stripped of its double quotes, so what reached Claude was `{a:b}` and it
1111
+ exited with "Invalid JSON provided to --settings". The string contained
1112
+ every word a substring check looks for.
1113
+ """
1114
+ import shlex
1115
+
1116
+ def ajustes_de(linea):
1117
+ """The `--settings` value, dug out through however many shells wrap it.
1118
+
1119
+ A repo window is launched through `tmux send-keys`, so its whole command
1120
+ is one quoted word inside the line; the chair's is not. Splitting once
1121
+ and giving up would test the chair and quietly skip the houses — which
1122
+ is exactly the window that broke.
1123
+ """
1124
+ pendientes, visto = [linea], 0
1125
+ while pendientes and visto < 6:
1126
+ visto += 1
1127
+ actual = pendientes.pop(0)
1128
+ try:
1129
+ palabras = shlex.split(actual)
1130
+ except ValueError:
1131
+ continue
1132
+ if "--settings" in palabras:
1133
+ return palabras[palabras.index("--settings") + 1]
1134
+ pendientes.extend(x for x in palabras if "--settings" in x and x != actual)
1135
+ return None
1136
+
1137
+ for etiqueta, linea in (("the chair", asiento), ("an agent house", api)):
1138
+ crudo = ajustes_de(linea)
1139
+ if crudo is None:
1140
+ afirma(f"· {etiqueta} is launched with --settings", False, linea[:250])
1141
+ continue
1142
+ try:
1143
+ ajustes = json.loads(crudo)
1144
+ except json.JSONDecodeError as e:
1145
+ afirma(f"· {etiqueta}'s --settings is still JSON after the shell has it",
1146
+ False, f"{crudo!r}: {e}")
1147
+ continue
1148
+ afirma(f"· {etiqueta}'s --settings is still JSON after the shell has it",
1149
+ isinstance(ajustes, dict), crudo)
1150
+ comprueba(f"· and {etiqueta} still refuses cross-session inbound",
1151
+ ajustes.get("crossSessionInbound"), "refuse")
1152
+
1153
+
1104
1154
  def arranque_escalonado():
1105
1155
  """Claude windows must not all start in the same millisecond.
1106
1156
 
@@ -1310,6 +1360,8 @@ def arranque_escalonado():
1310
1360
  "CITY_BUS_URL= CITY_BUS_TOKEN=" in api and "CITY_BUS_URL= CITY_BUS_TOKEN=" in docs,
1311
1361
  )
1312
1362
  claude_contract = (asiento + "\n" + api).replace("\\", "")
1363
+ el_settings_sobrevive_a_la_shell(asiento, api)
1364
+
1313
1365
  afirma(
1314
1366
  "· Claude native peer messaging is refused and its tools denied",
1315
1367
  claude_contract.count("crossSessionInbound") == 2
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()
@@ -865,7 +937,31 @@ def main():
865
937
  not os.path.exists(os.path.join(hallDatos, "units.yml"))
866
938
  or "u1" not in open(os.path.join(hallDatos, "units.yml")).read(),
867
939
  )
940
+ # Observing must not create.
941
+ #
942
+ # The journal runs after the handler, and it used to resolve its city
943
+ # through `ciudad`, which does `makedirs` and writes `city.yml`. On a
944
+ # request that archived a city, the log line recreated the folder that
945
+ # had just been taken away — a ghost city, left behind by the act of
946
+ # recording that it had gone. It also raced this very cleanup.
868
947
  shutil.rmtree(otra)
948
+ # The resolver the journal uses, on its own: it must answer where
949
+ # to write without bringing anything into being.
950
+ #
951
+ # (A handler is a different matter. It resolves through `ciudad`, which
952
+ # creates metadata for a city it is about to act on, so a request naming
953
+ # a deleted city can recreate its folder. That predates the journal and
954
+ # is not what this is about.)
955
+ fantasma = os.path.join(tempfile.mkdtemp(), "no-existe")
956
+ manejador = serve.Manejador.__new__(serve.Manejador)
957
+ donde = manejador._ciudad_para_apuntar(
958
+ {"city": [fantasma]}
959
+ )
960
+ afirma(
961
+ "· the journal's own resolver creates nothing",
962
+ not os.path.exists(fantasma) and isinstance(donde, str),
963
+ f"{fantasma} exists after only asking where to log",
964
+ )
869
965
 
870
966
  # ── the demo's remote control ────────────────────────────────────────
871
967
  print(" the demo's remote control")
@@ -1056,6 +1152,8 @@ def main():
1056
1152
  pide(puerto, "/api/agente", metodo="POST",
1057
1153
  cuerpo={"agent": "notas", "runtime": "", "model": "", "effort": ""})
1058
1154
 
1155
+ la_casa_que_no_debe_construirse(puerto, hallDatos)
1156
+
1059
1157
  # ── the demo shelf ───────────────────────────────────────────────────
1060
1158
  print(" the demos the Hall plays back")
1061
1159
  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.3",
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.3",
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",