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.
- package/.claude-plugin/marketplace.json +1 -1
- package/bin/navegador.mjs +108 -0
- package/bin/serve.py +112 -4
- package/bin/test +2 -1
- package/bin/test-arnes.py +109 -0
- package/bin/test-channel.py +34 -2
- package/bin/test-diario.py +185 -0
- package/bin/test-navegador.py +9 -0
- package/bin/test-seat.py +52 -0
- package/bin/test-serve.py +98 -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/arnes.py +10 -1
- package/plugin/scripts/diario.py +119 -0
- package/plugin/scripts/doctor.py +53 -0
|
@@ -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-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
|
-
|
|
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.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",
|