@trycore/spec-build-harness 0.14.1 → 0.15.0
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/plugin.json +1 -1
- package/README.md +5 -2
- package/VERSION +1 -1
- package/commands/build/epic.md +15 -1
- package/commands/build/graph-sync.md +129 -0
- package/dist/commands/init.js +3 -0
- package/docs/commands.md +2 -1
- package/docs/runtime/guia-modo-dual-y-migracion.md +37 -2
- package/docs/runtime/protocolo-cliente-runtime.md +74 -12
- package/hooks/build/lib/runtime-client.sh +55 -12
- package/hooks/build/lib/runtime-ops.sh +140 -29
- package/hooks/build/release-ops.sh +13 -8
- package/hooks/build/slice-ops.sh +497 -55
- package/package.json +1 -1
- package/scripts/lib/graph-bundle.py +118 -3
- package/scripts/tests/test-install.sh +2 -1
- package/scripts/tests/test-runtime-client.sh +49 -3
- package/scripts/tests/test-skill-ops.sh +601 -46
|
@@ -31,11 +31,20 @@ source "$ROOT/hooks/build/lib/runtime-ops.sh" 2>/dev/null || { echo "FAIL no exi
|
|
|
31
31
|
[ "$(ops_path_front_integration f-1 ak-1)" = "/fronts/f-1/members/ak-1/integration" ] && echo "OK ops_path_front_integration" || { echo "FAIL ops_path_front_integration"; fail=1; }
|
|
32
32
|
|
|
33
33
|
# [#62] Tabla de rutas de la propuesta de épica: un solo sitio donde se escriben rutas.
|
|
34
|
-
[
|
|
34
|
+
# [#70] El carril del AGENTE lleva `/agent/`: `/projects/{id}/epic-proposals` es la vista de
|
|
35
|
+
# consola (GET, JWT de usuario) y devuelve 405 a un POST — el drift que rompió el piloto.
|
|
36
|
+
[ "$(ops_path_epic_proposals p-1)" = "/projects/p-1/agent/epic-proposals" ] \
|
|
35
37
|
&& echo "OK ops_path_epic_proposals" || { echo "FAIL ops_path_epic_proposals"; fail=1; }
|
|
36
|
-
[ "$(ops_path_epic_proposal p-1 prop-7)" = "/projects/p-1/epic-proposals/prop-7" ] \
|
|
38
|
+
[ "$(ops_path_epic_proposal p-1 prop-7)" = "/projects/p-1/agent/epic-proposals/prop-7" ] \
|
|
37
39
|
&& echo "OK ops_path_epic_proposal" || { echo "FAIL ops_path_epic_proposal"; fail=1; }
|
|
38
40
|
|
|
41
|
+
# [EP-OR-17] Re-sync del grafo: mismo carril `/agent/`. Sin él se cae en la superficie HUMANA
|
|
42
|
+
# (sesión + PDP), que a un bearer de agente le responde 403 auditado.
|
|
43
|
+
[ "$(ops_path_graph_sync_proposals p-1)" = "/projects/p-1/agent/graph-sync-proposals" ] \
|
|
44
|
+
&& echo "OK ops_path_graph_sync_proposals" || { echo "FAIL ops_path_graph_sync_proposals"; fail=1; }
|
|
45
|
+
[ "$(ops_path_graph_sync_proposal p-1 gs-7)" = "/projects/p-1/agent/graph-sync-proposals/gs-7" ] \
|
|
46
|
+
&& echo "OK ops_path_graph_sync_proposal" || { echo "FAIL ops_path_graph_sync_proposal"; fail=1; }
|
|
47
|
+
|
|
39
48
|
# [#62] Ledger: upsert por client_event_id, sin duplicar, conservando lo no mencionado.
|
|
40
49
|
rm -f "$(ops_epic_ledger_path)"
|
|
41
50
|
ops_epic_ledger_upsert '{"client_event_id":"cid-1","status":"QUEUED","draft":{"title":"Notificaciones"}}'
|
|
@@ -142,7 +151,7 @@ fi
|
|
|
142
151
|
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
143
152
|
|
|
144
153
|
# --- ops_mirror: sólo tiene sentido con project_id; nunca lanza sin él ---
|
|
145
|
-
ops_mirror
|
|
154
|
+
ops_mirror gate_verdict "$TMP/payload.json" EP-003; rc=$?
|
|
146
155
|
[ "$rc" -ne 0 ] && echo "OK ops_mirror sin credenciales -> rc != 0" || { echo "FAIL ops_mirror sin credenciales"; fail=1; }
|
|
147
156
|
|
|
148
157
|
# ══════════════ slice-ops.sh: mode / claim / next-step [Task 2] ══════════════
|
|
@@ -181,6 +190,56 @@ STUBS="$STUBS $!"
|
|
|
181
190
|
sleep 0.3
|
|
182
191
|
runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_CL\",\"project_token\":\"tok-c\",\"project_id\":\"p-1\",\"agent_key\":\"ak-1\",\"manifest_hash\":\"sha256:v1\"}"
|
|
183
192
|
|
|
193
|
+
# ── [EP-OR-17] Ledger y cid del re-sync del grafo ────────────────────────────────────
|
|
194
|
+
# Va aquí, y no arriba con el ledger de épicas, porque `ops_graph_sync_cid` necesita la
|
|
195
|
+
# `agent_key` de las credenciales que la línea anterior acaba de fijar.
|
|
196
|
+
rm -f "$(ops_graph_sync_ledger_path)"
|
|
197
|
+
ops_graph_sync_ledger_upsert '{"client_event_id":"ak-1:graph-sync:0","status":"QUEUED","epics":41}'
|
|
198
|
+
ops_graph_sync_ledger_upsert '{"client_event_id":"ak-1:graph-sync:0","status":"PROPOSED","proposal_id":"gs-7"}'
|
|
199
|
+
ops_graph_sync_ledger_read | python3 -c "
|
|
200
|
+
import json,sys
|
|
201
|
+
ps=json.load(sys.stdin)['proposals']
|
|
202
|
+
e=ps[0]
|
|
203
|
+
print('SI' if len(ps)==1 and e['status']=='PROPOSED' and e['proposal_id']=='gs-7' and e['epics']==41 else 'NO')" \
|
|
204
|
+
| grep -q SI && echo "OK el ledger de graph-sync hace upsert sin perder campos" \
|
|
205
|
+
|| { echo "FAIL upsert del ledger de graph-sync"; fail=1; }
|
|
206
|
+
case "$(ops_graph_sync_ledger_path)" in
|
|
207
|
+
*/.claude/state/graph-sync-proposals.json) echo "OK el ledger de graph-sync vive en su propio fichero" ;;
|
|
208
|
+
*) echo "FAIL ruta del ledger de graph-sync"; fail=1 ;;
|
|
209
|
+
esac
|
|
210
|
+
|
|
211
|
+
# El cid es DETERMINISTA y avanza con el ledger: el reintento reusa el mismo, una propuesta
|
|
212
|
+
# nueva toma el siguiente.
|
|
213
|
+
rm -f "$(ops_graph_sync_ledger_path)"
|
|
214
|
+
cid_a="$(ops_graph_sync_cid)"; cid_b="$(ops_graph_sync_cid)"
|
|
215
|
+
[ "$cid_a" = "$cid_b" ] && echo "OK ops_graph_sync_cid es estable mientras no se registre nada" \
|
|
216
|
+
|| { echo "FAIL cid inestable ($cid_a / $cid_b)"; fail=1; }
|
|
217
|
+
[ "$cid_a" = "ak-1:graph-sync:1" ] && echo "OK el cid sigue el patrón <agent_key>:graph-sync:<n>" \
|
|
218
|
+
|| { echo "FAIL patrón del cid ($cid_a)"; fail=1; }
|
|
219
|
+
ops_graph_sync_ledger_upsert "{\"client_event_id\":\"$cid_a\",\"status\":\"PROPOSED\"}"
|
|
220
|
+
[ "$(ops_graph_sync_cid)" = "ak-1:graph-sync:2" ] \
|
|
221
|
+
&& echo "OK el cid avanza cuando el ledger registra una propuesta" \
|
|
222
|
+
|| { echo "FAIL el cid no avanza ($(ops_graph_sync_cid))"; fail=1; }
|
|
223
|
+
[ "${#cid_a}" -le 64 ] && echo "OK el cid cabe en los 64 caracteres del contrato" \
|
|
224
|
+
|| { echo "FAIL cid demasiado largo (${#cid_a})"; fail=1; }
|
|
225
|
+
# Una agent_key larga no puede desbordar el contrato: se sustituye por su sha256 truncado.
|
|
226
|
+
ak_prev="$(runtime_field agent_key)"
|
|
227
|
+
runtime_credentials_merge "{\"agent_key\":\"$(python3 -c 'print("a"*120)')\"}"
|
|
228
|
+
cid_l="$(ops_graph_sync_cid)"
|
|
229
|
+
[ "${#cid_l}" -le 64 ] && [ -n "$cid_l" ] \
|
|
230
|
+
&& echo "OK una agent_key larga no desborda el cid (${#cid_l})" \
|
|
231
|
+
|| { echo "FAIL cid desbordado con agent_key larga (${#cid_l})"; fail=1; }
|
|
232
|
+
runtime_credentials_merge "{\"agent_key\":\"$ak_prev\"}"
|
|
233
|
+
rm -f "$(ops_graph_sync_ledger_path)"
|
|
234
|
+
|
|
235
|
+
# El ledger de épicas no se toca: misma conducta tras extraer la base común.
|
|
236
|
+
rm -f "$(ops_epic_ledger_path)"
|
|
237
|
+
ops_epic_ledger_upsert '{"client_event_id":"cid-9","status":"QUEUED"}'
|
|
238
|
+
ops_epic_ledger_read | grep -q '"cid-9"' \
|
|
239
|
+
&& echo "OK el ledger de épicas sobrevive a la extracción de la base común" \
|
|
240
|
+
|| { echo "FAIL regresión del ledger de épicas"; fail=1; }
|
|
241
|
+
rm -f "$(ops_epic_ledger_path)"
|
|
242
|
+
|
|
184
243
|
# (#39) `claim --epic` NO existe en el protocolo del hub: `TasksNextIn` no declara
|
|
185
244
|
# epic_code (pydantic lo ignoraba y la cola repartía OTRA épica con claim "exitoso").
|
|
186
245
|
# Debe fallar EXPLÍCITO antes de tocar la red.
|
|
@@ -925,16 +984,18 @@ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -
|
|
|
925
984
|
[ "$rc" = 0 ] && [ "$n" = 0 ] && echo "OK phase en dual -> rc 0 sin encolar" || { echo "FAIL phase dual (rc=$rc n=$n)"; fail=1; }
|
|
926
985
|
[ "$(python3 - "$TMP/calls-ga.jsonl" <<'PYX'
|
|
927
986
|
import json,sys
|
|
987
|
+
# [EP-OR-17] El shape es MirrorTransitionsIn: la épica va en el SOBRE (`epic_code`), no como
|
|
988
|
+
# `slice_ref` dentro del payload — los schemas del catálogo son extra="forbid".
|
|
928
989
|
tos=[]
|
|
929
990
|
for line in open(sys.argv[1]):
|
|
930
991
|
c=json.loads(line)
|
|
931
992
|
if c["path"]=="/projects/p-1/mirror/transitions":
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
993
|
+
for t in json.loads(c["body"])["transitions"]:
|
|
994
|
+
if t["event_type"]=="phase_advanced":
|
|
995
|
+
tos.append((t["payload"].get("to"), t["epic_code"]))
|
|
935
996
|
print("SI" if tos==[("green","EP-009")] else "NO:"+repr(tos))
|
|
936
997
|
PYX
|
|
937
|
-
)" = SI ] && echo "OK phase en dual espeja phase_advanced {to} con
|
|
998
|
+
)" = SI ] && echo "OK phase en dual espeja phase_advanced {to} con el epic_code en el sobre" \
|
|
938
999
|
|| { echo "FAIL espejo de phase en dual"; fail=1; }
|
|
939
1000
|
|
|
940
1001
|
# dual: archive también hace catch-up por el espejo (cadena red→…→pr antes del archivado).
|
|
@@ -948,12 +1009,14 @@ kinds=[]
|
|
|
948
1009
|
for line in open(sys.argv[1]):
|
|
949
1010
|
c=json.loads(line)
|
|
950
1011
|
if c["path"]=="/projects/p-1/mirror/transitions":
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1012
|
+
for t in json.loads(c["body"])["transitions"]:
|
|
1013
|
+
kinds.append((t["event_type"], t["payload"].get("to")))
|
|
1014
|
+
# [EP-OR-17] `slice_archived` NO está en TIPOS_DE_ESPEJO del hub: la cadena de fases sí se
|
|
1015
|
+
# espeja, y el archivado se corta en local (rc 8) en vez de viajar para volver rechazado.
|
|
1016
|
+
esperado=[("phase_advanced",t) for t in ["green","refactor","smoke","api","data","dod","pr"]]
|
|
954
1017
|
print("SI" if kinds==esperado else "NO:"+repr(kinds))
|
|
955
1018
|
PYX
|
|
956
|
-
)" = SI ] && echo "OK archive en dual espeja la cadena
|
|
1019
|
+
)" = SI ] && echo "OK archive en dual espeja la cadena de fases y no el slice_archived no espejable" \
|
|
957
1020
|
|| { echo "FAIL cadena del archive dual"; fail=1; }
|
|
958
1021
|
|
|
959
1022
|
# Restaura la ruta y la caché al estado que esperan los bloques siguientes (sl-7, fase tdd).
|
|
@@ -980,12 +1043,16 @@ for line in open(sys.argv[1]):
|
|
|
980
1043
|
c=json.loads(line)
|
|
981
1044
|
if c["path"]=="/projects/p-1/mirror/transitions":
|
|
982
1045
|
b=json.loads(c["body"])
|
|
983
|
-
|
|
984
|
-
#
|
|
985
|
-
|
|
1046
|
+
t=b["transitions"][0]
|
|
1047
|
+
# [#44] el espejo usa el nombre del catálogo (gate_verdict, no verdict_reported).
|
|
1048
|
+
# [EP-OR-17] La identidad local viaja en el SOBRE (`epic_code`); el `origin` ya no se
|
|
1049
|
+
# manda — la marca de origen-espejo la estampa el SERVIDOR, y lo que dijera el cliente
|
|
1050
|
+
# no elegiría el origen de todos modos.
|
|
1051
|
+
ok = (list(b.keys())==["transitions"] and t["event_type"]=="gate_verdict"
|
|
1052
|
+
and t["epic_code"]=="EP-009" and "slice_ref" not in t["payload"])
|
|
986
1053
|
print("SI" if ok else "NO")
|
|
987
1054
|
PYX
|
|
988
|
-
)" = SI ] && echo "OK dual espeja con la identidad fichero-primaria" || { echo "FAIL espejo dual"; fail=1; }
|
|
1055
|
+
)" = SI ] && echo "OK dual espeja con la identidad fichero-primaria en el sobre" || { echo "FAIL espejo dual"; fail=1; }
|
|
989
1056
|
[ ! -s "$PROJ/.claude/state/outbox" ] || true
|
|
990
1057
|
grep -q '"path": "/slices/' "$TMP/calls-ga.jsonl" && { echo "FAIL dual llamó a la superficie primaria"; fail=1; } || echo "OK dual no llama a /slices (el fichero decide)"
|
|
991
1058
|
|
|
@@ -997,12 +1064,15 @@ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
|
|
|
997
1064
|
|
|
998
1065
|
# ══════════════ hooks/build/dual-compare.sh [Task E] ══════════════
|
|
999
1066
|
# build-state.json de arriba sigue en pie: EP-009, phase tdd, gates {dor:true}.
|
|
1000
|
-
# En dual, escalate NO encola:
|
|
1001
|
-
#
|
|
1067
|
+
# En dual, escalate NO encola: intenta el espejo (el fichero es primario).
|
|
1068
|
+
# [EP-OR-17] `slice_escalated` existe en el catálogo del hub pero NO es espejable
|
|
1069
|
+
# (`TIPOS_DE_ESPEJO` solo cubre apertura, fases, gates, wiring, checkpoints, handoff y notas de
|
|
1070
|
+
# progreso). Antes la escalada viajaba para volver rechazada; ahora se corta en local. La
|
|
1071
|
+
# discrepancia sigue siendo VISIBLE donde el humano la lee —el stderr del hook Stop— y el hueco
|
|
1072
|
+
# de registro en el hub queda documentado como pendiente hub-side, no disimulado aquí.
|
|
1002
1073
|
DC="$ROOT/hooks/build/dual-compare.sh"
|
|
1003
1074
|
mirror_escalated() {
|
|
1004
|
-
# SI/NO: ¿
|
|
1005
|
-
# [#44] la escalada usa el nombre del catálogo v2 (slice_escalated) también en el espejo.
|
|
1075
|
+
# SI/NO: ¿viajó al espejo un `slice_escalated`? Debe ser NO: el hub no lo acepta.
|
|
1006
1076
|
python3 - "$TMP/calls-ga.jsonl" <<'PYX'
|
|
1007
1077
|
import json,sys
|
|
1008
1078
|
found=False
|
|
@@ -1012,9 +1082,9 @@ for line in open(sys.argv[1]):
|
|
|
1012
1082
|
except Exception:
|
|
1013
1083
|
continue
|
|
1014
1084
|
if c.get("path")=="/projects/p-1/mirror/transitions":
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1085
|
+
for t in json.loads(c["body"]).get("transitions") or []:
|
|
1086
|
+
if t.get("event_type")=="slice_escalated":
|
|
1087
|
+
found=True
|
|
1018
1088
|
print("SI" if found else "NO")
|
|
1019
1089
|
PYX
|
|
1020
1090
|
}
|
|
@@ -1051,9 +1121,10 @@ json.dump(d,open(p,"w"))
|
|
|
1051
1121
|
PYX
|
|
1052
1122
|
: > "$TMP/calls-ga.jsonl"
|
|
1053
1123
|
rc=0; err="$(TRYCORE_RUNTIME_MODE=dual bash "$DC" 2>&1 1>/dev/null)" || rc=$?
|
|
1054
|
-
[ "$rc" = 0 ] && [ "$(mirror_escalated)" =
|
|
1124
|
+
[ "$rc" = 0 ] && [ "$(mirror_escalated)" = NO ] && echo "$err" | grep -qi "discrepancia" \
|
|
1055
1125
|
&& echo "$err" | grep -q "EP-009" && echo "$err" | grep -q "EP-999" \
|
|
1056
|
-
&& echo "OK dual-compare con épica distinta
|
|
1126
|
+
&& echo "OK dual-compare con épica distinta avisa con el detalle de ambos lados, sin mandar al espejo un tipo que el hub no acepta" \
|
|
1127
|
+
|| { echo "FAIL dual-compare épica distinta (rc=$rc err=$err)"; fail=1; }
|
|
1057
1128
|
|
|
1058
1129
|
# dual, gate DISTINTO (mismo epic/fase): también cuenta como discrepancia.
|
|
1059
1130
|
python3 - "$TMP/routes-ga.json" <<'PYX'
|
|
@@ -1063,8 +1134,10 @@ d["GET /agent/context"]={"status":200,"body":{"active_slice":{"slice_id":"sl-9",
|
|
|
1063
1134
|
json.dump(d,open(p,"w"))
|
|
1064
1135
|
PYX
|
|
1065
1136
|
: > "$TMP/calls-ga.jsonl"
|
|
1066
|
-
rc=0; TRYCORE_RUNTIME_MODE=dual bash "$DC" >/dev/null
|
|
1067
|
-
|
|
1137
|
+
rc=0; err="$(TRYCORE_RUNTIME_MODE=dual bash "$DC" 2>&1 1>/dev/null)" || rc=$?
|
|
1138
|
+
echo "$err" | grep -qi "discrepancia" && [ "$(mirror_escalated)" = NO ] \
|
|
1139
|
+
&& echo "OK dual-compare detecta gate distinto (dor: true vs false)" \
|
|
1140
|
+
|| { echo "FAIL dual-compare gate distinto (rc=$rc err=$err)"; fail=1; }
|
|
1068
1141
|
|
|
1069
1142
|
# dual, runtime SIN slice activo (el fichero sí tiene): también es discrepancia.
|
|
1070
1143
|
python3 - "$TMP/routes-ga.json" <<'PYX'
|
|
@@ -1074,8 +1147,10 @@ d["GET /agent/context"]={"status":200,"body":{"project":{"scaffold_confirmed":Tr
|
|
|
1074
1147
|
json.dump(d,open(p,"w"))
|
|
1075
1148
|
PYX
|
|
1076
1149
|
: > "$TMP/calls-ga.jsonl"
|
|
1077
|
-
rc=0; TRYCORE_RUNTIME_MODE=dual bash "$DC" >/dev/null
|
|
1078
|
-
|
|
1150
|
+
rc=0; err="$(TRYCORE_RUNTIME_MODE=dual bash "$DC" 2>&1 1>/dev/null)" || rc=$?
|
|
1151
|
+
echo "$err" | grep -qi "discrepancia" && [ "$(mirror_escalated)" = NO ] \
|
|
1152
|
+
&& echo "OK dual-compare detecta slice ausente en el runtime" \
|
|
1153
|
+
|| { echo "FAIL dual-compare slice ausente en runtime (rc=$rc err=$err)"; fail=1; }
|
|
1079
1154
|
|
|
1080
1155
|
# fail-open: espejo caído (runtime inalcanzable) -> nunca lanza, nunca aborta (rc 0 siempre).
|
|
1081
1156
|
python3 - "$TMP/routes-ga.json" <<'PYX'
|
|
@@ -1618,9 +1693,13 @@ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
|
|
|
1618
1693
|
# ══════════════ slice-ops.sh: propose-epic / epic-status / epic-writeback [#62] ══════════════
|
|
1619
1694
|
# Stub PROPIO (puerto, routes-ep.json y calls-ep.jsonl): routes-cl.json/STUB_CL ya no existen
|
|
1620
1695
|
# en este punto del fichero y mutarlos rompería aserciones aguas arriba.
|
|
1696
|
+
# [#70] Las rutas y los cuerpos de este bloque son los del CONTRATO DEL AGENTE del hub
|
|
1697
|
+
# (`contrato-agente-openapi.json`, EP-OR-14/EP-OR-15), no los del borrador de hub#113:
|
|
1698
|
+
# carril `/agent/…`, `layer` en MAYÚSCULAS (FOUNDATIONAL|BUSINESS), historias con
|
|
1699
|
+
# `description`+`acceptance[]`, y respuesta `{id, status, assigned_code, reject_reason}`.
|
|
1621
1700
|
PORT_EP="$(freeport)"
|
|
1622
1701
|
cat > "$TMP/routes-ep.json" <<JSON
|
|
1623
|
-
{"POST /projects/p-1/epic-proposals": {"status": 201, "body": {"
|
|
1702
|
+
{"POST /projects/p-1/agent/epic-proposals": {"status": 201, "body": {"id": "prop-77", "project_id": "p-1", "title": "Notificaciones en tiempo real", "objective": "que el usuario vea los avisos sin recargar", "layer": "BUSINESS", "stories": [], "files_scope": [], "depends_on": [], "status": "PROPOSED", "assigned_code": null, "reject_reason": null}}}
|
|
1624
1703
|
JSON
|
|
1625
1704
|
: > "$TMP/calls-ep.jsonl"
|
|
1626
1705
|
python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_EP" "$TMP/routes-ep.json" "$TMP/calls-ep.jsonl" &
|
|
@@ -1634,7 +1713,8 @@ cat > "$TMP/draft.json" <<'JSON'
|
|
|
1634
1713
|
{"title":"Notificaciones en tiempo real","objective":"que el usuario vea los avisos sin recargar",
|
|
1635
1714
|
"layer":"business","files_scope":["src/notifications/**"],"depends_on":["EP-012"],
|
|
1636
1715
|
"epic_code":"EP-999",
|
|
1637
|
-
"stories":[{"title":"ver el aviso","
|
|
1716
|
+
"stories":[{"title":"ver el aviso","description":"Como usuario quiero ver el aviso sin recargar",
|
|
1717
|
+
"acceptance_criteria":"Dado un aviso nuevo Cuando llega Entonces se ve. Dado un aviso leído Cuando entro Entonces no se repite"}]}
|
|
1638
1718
|
JSON
|
|
1639
1719
|
|
|
1640
1720
|
# legacy y dual: rc 3 sin efectos (ni cola, ni red).
|
|
@@ -1659,22 +1739,59 @@ out="$(bash "$SO" propose-epic --file "$TMP/draft.json" 2>&1)"; rc=$?
|
|
|
1659
1739
|
&& echo "OK propose-epic envia y devuelve el identificador" \
|
|
1660
1740
|
|| { echo "FAIL propose-epic online (rc $rc): $out"; fail=1; }
|
|
1661
1741
|
|
|
1662
|
-
# El
|
|
1742
|
+
# [#70] El cuerpo enviado valida contra `EpicProposalIn` del contrato del agente: `layer` en
|
|
1743
|
+
# MAYÚSCULAS, historias con `description`+`acceptance[]` y NUNCA `acceptance_criteria` (el
|
|
1744
|
+
# hub lo descarta en el borde con extra="ignore": la consola mostraba las HU sin criterios).
|
|
1663
1745
|
body="$(python3 - "$TMP/calls-ep.jsonl" <<'PY'
|
|
1664
1746
|
import json,sys
|
|
1665
1747
|
for l in open(sys.argv[1]):
|
|
1666
1748
|
d=json.loads(l)
|
|
1667
|
-
if d["path"]=="/projects/p-1/epic-proposals":
|
|
1668
|
-
b=json.loads(d["body"])
|
|
1669
|
-
|
|
1749
|
+
if d["path"]=="/projects/p-1/agent/epic-proposals":
|
|
1750
|
+
b=json.loads(d["body"]); errores=[]
|
|
1751
|
+
if b.get("layer")!="BUSINESS": errores.append("layer=%r" % b.get("layer"))
|
|
1752
|
+
if "epic_code" in b: errores.append("viajo el epic_code del borrador")
|
|
1753
|
+
if not b.get("title","").startswith("Notificaciones"): errores.append("title=%r" % b.get("title"))
|
|
1754
|
+
s=(b.get("stories") or [{}])[0]
|
|
1755
|
+
if "acceptance_criteria" in s: errores.append("la historia lleva acceptance_criteria")
|
|
1756
|
+
if set(s)-{"title","description","acceptance","code","acceptance_ref"}:
|
|
1757
|
+
errores.append("campos fuera del contrato: %s" % sorted(set(s)-{"title","description","acceptance","code","acceptance_ref"}))
|
|
1758
|
+
if s.get("description")!="Como usuario quiero ver el aviso sin recargar":
|
|
1759
|
+
errores.append("description=%r" % s.get("description"))
|
|
1760
|
+
acc=s.get("acceptance")
|
|
1761
|
+
if not isinstance(acc,list) or len(acc)!=2: errores.append("acceptance=%r" % acc)
|
|
1762
|
+
elif not acc[0].startswith("Dado un aviso nuevo") or not acc[1].startswith("Dado un aviso leído"):
|
|
1763
|
+
errores.append("escenarios mal partidos: %r" % acc)
|
|
1764
|
+
print("SI" if not errores else "NO: "+" | ".join(errores))
|
|
1670
1765
|
break
|
|
1671
1766
|
PY
|
|
1672
1767
|
)"
|
|
1673
|
-
[ "$body" = SI ] && echo "OK propose-epic
|
|
1674
|
-
|| { echo "FAIL
|
|
1768
|
+
[ "$body" = SI ] && echo "OK el cuerpo de propose-epic valida contra el contrato del agente" \
|
|
1769
|
+
|| { echo "FAIL cuerpo de propose-epic ($body)"; fail=1; }
|
|
1675
1770
|
echo "$out" | grep -qi 'ignorado' && echo "OK propose-epic avisa de que ignoro el codigo" \
|
|
1676
1771
|
|| { echo "FAIL sin aviso del codigo ignorado"; fail=1; }
|
|
1677
1772
|
|
|
1773
|
+
# [#70] `technical` no existe en el enum del hub (FOUNDATIONAL|BUSINESS): el 422 que devolvía
|
|
1774
|
+
# se evita mapeándolo a BUSINESS en el borde, no rechazando el borrador del agente.
|
|
1775
|
+
cat > "$TMP/draft-tech.json" <<'JSON'
|
|
1776
|
+
{"title":"Observabilidad","objective":"ver qué pasa en produccion","layer":"technical",
|
|
1777
|
+
"stories":[{"title":"ver las trazas"}]}
|
|
1778
|
+
JSON
|
|
1779
|
+
: > "$TMP/calls-ep.jsonl"
|
|
1780
|
+
bash "$SO" propose-epic --file "$TMP/draft-tech.json" >/dev/null 2>&1
|
|
1781
|
+
tech="$(python3 - "$TMP/calls-ep.jsonl" <<'PY'
|
|
1782
|
+
import json,sys
|
|
1783
|
+
for l in open(sys.argv[1]):
|
|
1784
|
+
d=json.loads(l)
|
|
1785
|
+
if d["path"]=="/projects/p-1/agent/epic-proposals":
|
|
1786
|
+
b=json.loads(d["body"])
|
|
1787
|
+
s=(b.get("stories") or [{}])[0]
|
|
1788
|
+
print("SI" if b.get("layer")=="BUSINESS" and "acceptance" not in s else "NO: layer=%r story=%r" % (b.get("layer"), s))
|
|
1789
|
+
break
|
|
1790
|
+
PY
|
|
1791
|
+
)"
|
|
1792
|
+
[ "$tech" = SI ] && echo "OK layer technical viaja como BUSINESS y una historia sin AC no inventa acceptance" \
|
|
1793
|
+
|| { echo "FAIL mapeo de layer technical ($tech)"; fail=1; }
|
|
1794
|
+
|
|
1678
1795
|
# El ledger recuerda la propuesta con su borrador.
|
|
1679
1796
|
ops_epic_ledger_read | python3 -c "
|
|
1680
1797
|
import json,sys
|
|
@@ -1710,14 +1827,18 @@ out="$(bash "$SO" propose-epic --file "$TMP/malo.json" 2>&1)"
|
|
|
1710
1827
|
|| { echo "FAIL validacion local del borrador: $out"; fail=1; }
|
|
1711
1828
|
|
|
1712
1829
|
# --- [#62] epic-status ---
|
|
1830
|
+
# [#70] La respuesta es `EpicProposalOut` del contrato: el código aprobado viaja en
|
|
1831
|
+
# `assigned_code` y la épica en la RAÍZ (no anidada en `epic`).
|
|
1713
1832
|
python3 - "$TMP/routes-ep.json" <<'PY'
|
|
1714
1833
|
import json,sys
|
|
1715
1834
|
p=sys.argv[1]; d=json.load(open(p))
|
|
1716
|
-
d["GET /projects/p-1/epic-proposals/prop-77"]={"status":200,"body":{
|
|
1717
|
-
"
|
|
1718
|
-
"
|
|
1719
|
-
|
|
1720
|
-
|
|
1835
|
+
d["GET /projects/p-1/agent/epic-proposals/prop-77"]={"status":200,"body":{
|
|
1836
|
+
"id":"prop-77","project_id":"p-1","status":"APPROVED","assigned_code":"EP-045",
|
|
1837
|
+
"title":"Notificaciones en tiempo real","objective":"que el usuario vea los avisos sin recargar",
|
|
1838
|
+
"layer":"BUSINESS","depends_on":["EP-012"],"files_scope":["src/notifications/**"],
|
|
1839
|
+
"stories":[{"code":"HU-310","title":"ver el aviso","description":"Como usuario…",
|
|
1840
|
+
"acceptance":["Dado un aviso nuevo Cuando llega Entonces se ve"],"acceptance_ref":None},],
|
|
1841
|
+
"reject_reason":None}}
|
|
1721
1842
|
json.dump(d,open(p,"w"))
|
|
1722
1843
|
PY
|
|
1723
1844
|
TRYCORE_RUNTIME_MODE=legacy bash "$SO" epic-status >/dev/null 2>&1
|
|
@@ -1733,20 +1854,38 @@ out="$(bash "$SO" epic-status 2>&1)"; rc=$?
|
|
|
1733
1854
|
|| { echo "FAIL epic-status (rc $rc): $out"; fail=1; }
|
|
1734
1855
|
ops_epic_ledger_read | python3 -c "
|
|
1735
1856
|
import json,sys
|
|
1736
|
-
|
|
1737
|
-
|
|
1857
|
+
ps=json.load(sys.stdin)['proposals']
|
|
1858
|
+
p=[q for q in ps if q.get('proposal_id')=='prop-77'][0]
|
|
1859
|
+
print('SI' if p['status']=='APPROVED' and p['epic_code']=='EP-045' and p['epic']['stories'][0]['code']=='HU-310' and p['epic']['title'].startswith('Notificaciones') else 'NO')" \
|
|
1738
1860
|
| grep -q SI && echo "OK epic-status guarda el codigo y el contenido aprobado" \
|
|
1739
1861
|
|| { echo "FAIL el ledger no recogio la aprobacion"; fail=1; }
|
|
1740
1862
|
|
|
1863
|
+
# [#70] El rechazo humano viaja en `reject_reason` (EP-OR-15): sin leerlo, la terminal decía
|
|
1864
|
+
# "sin motivo registrado" y el usuario no sabía qué corregir.
|
|
1865
|
+
python3 - "$TMP/routes-ep.json" <<'PY'
|
|
1866
|
+
import json,sys
|
|
1867
|
+
p=sys.argv[1]; d=json.load(open(p))
|
|
1868
|
+
d["GET /projects/p-1/agent/epic-proposals/prop-88"]={"status":200,"body":{
|
|
1869
|
+
"id":"prop-88","project_id":"p-1","status":"REJECTED","assigned_code":None,
|
|
1870
|
+
"title":"Otra","objective":"otra cosa","layer":"BUSINESS","depends_on":[],"files_scope":[],
|
|
1871
|
+
"stories":[],"reject_reason":"se solapa con EP-012"}}
|
|
1872
|
+
json.dump(d,open(p,"w"))
|
|
1873
|
+
PY
|
|
1874
|
+
ops_epic_ledger_upsert '{"client_event_id":"cid-r","proposal_id":"prop-88","status":"PROPOSED"}'
|
|
1875
|
+
out="$(bash "$SO" epic-status --id prop-88 2>&1)"; rc=$?
|
|
1876
|
+
[ $rc -eq 0 ] && echo "$out" | grep -q 'se solapa con EP-012' \
|
|
1877
|
+
&& echo "OK epic-status muestra el motivo del rechazo humano" \
|
|
1878
|
+
|| { echo "FAIL motivo del rechazo (rc $rc): $out"; fail=1; }
|
|
1879
|
+
|
|
1741
1880
|
# Un hub sin soporte (404) se dice con esas palabras y NO rompe nada.
|
|
1742
1881
|
python3 - "$TMP/routes-ep.json" <<'PY'
|
|
1743
1882
|
import json,sys
|
|
1744
1883
|
p=sys.argv[1]; d=json.load(open(p))
|
|
1745
|
-
d.pop("GET /projects/p-1/epic-proposals/prop-77",None)
|
|
1884
|
+
d.pop("GET /projects/p-1/agent/epic-proposals/prop-77",None)
|
|
1746
1885
|
json.dump(d,open(p,"w"))
|
|
1747
1886
|
PY
|
|
1748
1887
|
ops_epic_ledger_upsert '{"client_event_id":"cid-x","proposal_id":"prop-77","status":"PROPOSED"}'
|
|
1749
|
-
out="$(bash "$SO" epic-status 2>&1)"; rc=$?
|
|
1888
|
+
out="$(bash "$SO" epic-status --id prop-77 2>&1)"; rc=$?
|
|
1750
1889
|
[ $rc -eq 0 ] && echo "$out" | grep -qi 'sin soporte' \
|
|
1751
1890
|
&& echo "OK epic-status nombra la falta de soporte del hub" \
|
|
1752
1891
|
|| { echo "FAIL epic-status con 404 (rc $rc): $out"; fail=1; }
|
|
@@ -1933,6 +2072,317 @@ out="$(cd "$PROJ" && bash "$SO" epic-writeback 2>&1)"; rc=$?
|
|
|
1933
2072
|
[ $rc -eq 4 ] && echo "OK epic-writeback rc 4 sin fichero de epicas" \
|
|
1934
2073
|
|| { echo "FAIL epic-writeback sin fichero (rc $rc): $out"; fail=1; }
|
|
1935
2074
|
|
|
2075
|
+
# ══════════════ slice-ops.sh: graph-sync / graph-sync-status [EP-OR-17] ══════════════
|
|
2076
|
+
# Stub PROPIO (puerto, routes-gs.json y calls-gs.jsonl): mutar los de arriba rompería
|
|
2077
|
+
# aserciones aguas arriba, y este bloque reescribe la ruta del POST varias veces.
|
|
2078
|
+
PORT_GS="$(freeport)"
|
|
2079
|
+
cat > "$TMP/routes-gs.json" <<'JSON'
|
|
2080
|
+
{"POST /projects/p-1/agent/graph-sync-proposals": {"status": 201, "body": {"id": "gs-7", "project_id": "p-1", "status": "PROPOSED", "graph_version": 4, "bundle": {"epics": []}, "delta": {"epics_added": [{"code": "EP-002", "title": "Cobros", "layer": "BUSINESS", "stories": ["HU-011"]}], "epics_changed": [], "stories_added": [], "stories_changed": []}, "proposed_by": "ak-1", "proposed_at": "2026-09-07T10:00:00Z"}},
|
|
2081
|
+
"GET /agent/context": {"status": 200, "body": {"project": {}, "active_slice": null, "nudges": [], "context": {"manifest_hash": "sha256:v1", "version": 4, "graph_version": 4}}},
|
|
2082
|
+
"POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
|
|
2083
|
+
JSON
|
|
2084
|
+
: > "$TMP/calls-gs.jsonl"
|
|
2085
|
+
python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_GS" "$TMP/routes-gs.json" "$TMP/calls-gs.jsonl" &
|
|
2086
|
+
STUB_GS=$!
|
|
2087
|
+
STUBS="$STUBS $STUB_GS"
|
|
2088
|
+
sleep 0.3
|
|
2089
|
+
runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GS\",\"project_token\":\"tok-gs\",\"project_id\":\"p-1\",\"agent_key\":\"ak-1\"}"
|
|
2090
|
+
runtime_projection_write '{"fetched_at":"2099-01-01T00:00:00Z","active_slice":null,"nudges":[],"context":{"manifest_hash":"sha256:v1","version":4,"graph_version":4}}'
|
|
2091
|
+
|
|
2092
|
+
cat > "$TMP/gs-epics.json" <<'JSON'
|
|
2093
|
+
{"epics":[{"code":"EP-001","title":"Cimiento","layer":"foundational","stories":[]},
|
|
2094
|
+
{"code":"EP-002","title":"Cobros","layer":"business","depends_on":["EP-001"],
|
|
2095
|
+
"stories":[{"id":"HU-011","title":"Registrar cobro"}]}]}
|
|
2096
|
+
JSON
|
|
2097
|
+
|
|
2098
|
+
# legacy y dual: el grafo vive en los documentos y entra al hub por el import de admin.
|
|
2099
|
+
rc=0; out="$(TRYCORE_RUNTIME_MODE=legacy bash "$SO" graph-sync --file "$TMP/gs-epics.json" 2>&1)" || rc=$?
|
|
2100
|
+
[ "$rc" = 3 ] && echo "OK graph-sync en legacy -> rc 3" || { echo "FAIL graph-sync legacy rc ($rc): $out"; fail=1; }
|
|
2101
|
+
|
|
2102
|
+
# --dry-run: imprime el bundle proyectado y NO abre socket. Sirve en cualquier modo.
|
|
2103
|
+
: > "$TMP/calls-gs.jsonl"
|
|
2104
|
+
rc=0; out="$(bash "$SO" graph-sync --file "$TMP/gs-epics.json" --dry-run 2>/dev/null)" || rc=$?
|
|
2105
|
+
[ "$rc" = 0 ] && echo "OK graph-sync --dry-run -> rc 0" || { echo "FAIL dry-run rc ($rc)"; fail=1; }
|
|
2106
|
+
printf '%s' "$out" | python3 -c "
|
|
2107
|
+
import json,sys
|
|
2108
|
+
d=json.load(sys.stdin)
|
|
2109
|
+
print('SI' if list(d.keys())==['epics'] and len(d['epics'])==2 else 'NO')" | grep -q SI \
|
|
2110
|
+
&& echo "OK --dry-run enseña el bundle proyectado" || { echo "FAIL dry-run sin bundle ($out)"; fail=1; }
|
|
2111
|
+
grep -q 'graph-sync-proposals' "$TMP/calls-gs.jsonl" && { echo "FAIL --dry-run tocó la red"; fail=1; } \
|
|
2112
|
+
|| echo "OK --dry-run no abre socket"
|
|
2113
|
+
|
|
2114
|
+
# Bundle que no cumple el contrato: se corta en LOCAL, sin abrir socket.
|
|
2115
|
+
echo '{"epics":[{"code":"EP-009","title":"Sin capa"}]}' > "$TMP/gs-malo.json"
|
|
2116
|
+
: > "$TMP/calls-gs.jsonl"
|
|
2117
|
+
rc=0; out="$(bash "$SO" graph-sync --file "$TMP/gs-malo.json" 2>&1)" || rc=$?
|
|
2118
|
+
[ "$rc" = 2 ] && echo "OK bundle inválido -> rc 2 (uso)" || { echo "FAIL bundle inválido rc ($rc): $out"; fail=1; }
|
|
2119
|
+
grep -q 'graph-sync-proposals' "$TMP/calls-gs.jsonl" && { echo "FAIL bundle inválido tocó la red"; fail=1; } \
|
|
2120
|
+
|| echo "OK un bundle inválido no abre socket"
|
|
2121
|
+
|
|
2122
|
+
# 201: PROPOSED en el ledger con el id del hub; el cuerpo cumple GraphSyncProposeIn.
|
|
2123
|
+
rm -f "$(ops_graph_sync_ledger_path)"
|
|
2124
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/rejected/"*.json 2>/dev/null
|
|
2125
|
+
: > "$TMP/calls-gs.jsonl"
|
|
2126
|
+
rc=0; out="$(bash "$SO" graph-sync --file "$TMP/gs-epics.json" 2>&1)" || rc=$?
|
|
2127
|
+
[ "$rc" = 0 ] && echo "OK graph-sync 201 -> rc 0" || { echo "FAIL graph-sync 201 rc ($rc): $out"; fail=1; }
|
|
2128
|
+
echo "$out" | grep -q "gs-7" && echo "OK graph-sync enseña el id de la propuesta" \
|
|
2129
|
+
|| { echo "FAIL id ausente ($out)"; fail=1; }
|
|
2130
|
+
echo "$out" | grep -qi "aprueb" && echo "OK graph-sync dice que falta la aprobación humana" \
|
|
2131
|
+
|| { echo "FAIL sin mención de la aprobación ($out)"; fail=1; }
|
|
2132
|
+
echo "$out" | grep -q "EP-002" && echo "OK graph-sync resume el delta que devuelve el hub" \
|
|
2133
|
+
|| { echo "FAIL delta sin resumir ($out)"; fail=1; }
|
|
2134
|
+
python3 - "$TMP/calls-gs.jsonl" <<'PYX' | grep -q SI \
|
|
2135
|
+
&& echo "OK el cuerpo enviado cumple GraphSyncProposeIn" || { echo "FAIL cuerpo de graph-sync"; fail=1; }
|
|
2136
|
+
import json,sys
|
|
2137
|
+
env=None
|
|
2138
|
+
for line in open(sys.argv[1]):
|
|
2139
|
+
c=json.loads(line)
|
|
2140
|
+
if c["path"].endswith("/agent/graph-sync-proposals") and c["method"]=="POST":
|
|
2141
|
+
env=json.loads(c["body"])
|
|
2142
|
+
ok = (env is not None
|
|
2143
|
+
and set(env.keys()) <= {"bundle","client_event_id","graph_version"}
|
|
2144
|
+
and list(env["bundle"].keys())==["epics"]
|
|
2145
|
+
and len(env["bundle"]["epics"])==2
|
|
2146
|
+
and env["bundle"]["epics"][1]["layer"]=="BUSINESS"
|
|
2147
|
+
and env["client_event_id"].endswith(":graph-sync:1")
|
|
2148
|
+
and env["graph_version"]==4)
|
|
2149
|
+
print("SI" if ok else "NO %s" % json.dumps(env))
|
|
2150
|
+
PYX
|
|
2151
|
+
ops_graph_sync_ledger_read | python3 -c "
|
|
2152
|
+
import json,sys
|
|
2153
|
+
ps=json.load(sys.stdin)['proposals']
|
|
2154
|
+
print('SI' if len(ps)==1 and ps[0]['status']=='PROPOSED' and ps[0]['proposal_id']=='gs-7' else 'NO')" \
|
|
2155
|
+
| grep -q SI && echo "OK el ledger registra la propuesta PROPOSED" \
|
|
2156
|
+
|| { echo "FAIL ledger tras 201"; fail=1; }
|
|
2157
|
+
|
|
2158
|
+
# 200 empty_delta: el grafo ya está al día. No es un error y NO deja entrada viva en el ledger.
|
|
2159
|
+
python3 - "$TMP/routes-gs.json" <<'PYX'
|
|
2160
|
+
import json,sys
|
|
2161
|
+
d=json.load(open(sys.argv[1]))
|
|
2162
|
+
d["POST /projects/p-1/agent/graph-sync-proposals"]={"status":200,"body":{"empty_delta":True,"message":"El bundle coincide con el grafo vigente: no hay cambios que proponer"}}
|
|
2163
|
+
json.dump(d,open(sys.argv[1],"w"))
|
|
2164
|
+
PYX
|
|
2165
|
+
rm -f "$(ops_graph_sync_ledger_path)"
|
|
2166
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/rejected/"*.json 2>/dev/null
|
|
2167
|
+
rc=0; out="$(bash "$SO" graph-sync --file "$TMP/gs-epics.json" 2>&1)" || rc=$?
|
|
2168
|
+
[ "$rc" = 0 ] && echo "OK graph-sync empty_delta -> rc 0" || { echo "FAIL empty_delta rc ($rc): $out"; fail=1; }
|
|
2169
|
+
echo "$out" | grep -qi "al día" && echo "OK empty_delta se anuncia como grafo al día" \
|
|
2170
|
+
|| { echo "FAIL mensaje de empty_delta ($out)"; fail=1; }
|
|
2171
|
+
ops_graph_sync_ledger_read | python3 -c "
|
|
2172
|
+
import json,sys
|
|
2173
|
+
ps=json.load(sys.stdin)['proposals']
|
|
2174
|
+
print('SI' if all(p.get('status')!='PROPOSED' for p in ps) else 'NO')" | grep -q SI \
|
|
2175
|
+
&& echo "OK empty_delta no deja una propuesta viva en el ledger" \
|
|
2176
|
+
|| { echo "FAIL empty_delta dejó una propuesta viva"; fail=1; }
|
|
2177
|
+
|
|
2178
|
+
# 422: bundle inválido para el hub -> rechazo, con el motivo del hub a la vista.
|
|
2179
|
+
python3 - "$TMP/routes-gs.json" <<'PYX'
|
|
2180
|
+
import json,sys
|
|
2181
|
+
d=json.load(open(sys.argv[1]))
|
|
2182
|
+
d["POST /projects/p-1/agent/graph-sync-proposals"]={"status":422,"body":{"detail":"la épica EP-002 depende de EP-999, que no existe"}}
|
|
2183
|
+
json.dump(d,open(sys.argv[1],"w"))
|
|
2184
|
+
PYX
|
|
2185
|
+
rm -f "$(ops_graph_sync_ledger_path)"
|
|
2186
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/rejected/"*.json 2>/dev/null
|
|
2187
|
+
rc=0; out="$(bash "$SO" graph-sync --file "$TMP/gs-epics.json" 2>&1)" || rc=$?
|
|
2188
|
+
[ "$rc" = 6 ] && echo "OK graph-sync 422 -> rc 6" || { echo "FAIL 422 rc ($rc): $out"; fail=1; }
|
|
2189
|
+
echo "$out" | grep -q "EP-999" && echo "OK el 422 muestra el motivo del hub" \
|
|
2190
|
+
|| { echo "FAIL motivo del 422 ($out)"; fail=1; }
|
|
2191
|
+
|
|
2192
|
+
# 409 graph_version_stale: se refresca el contexto y se reintenta UNA vez con el MISMO cid.
|
|
2193
|
+
python3 - "$TMP/routes-gs.json" <<'PYX'
|
|
2194
|
+
import json,sys
|
|
2195
|
+
d=json.load(open(sys.argv[1]))
|
|
2196
|
+
d["POST /projects/p-1/agent/graph-sync-proposals"]={"status":409,"body":{"detail":{"error":"graph_version_stale","message":"el grafo avanzo","graph_version":9}}}
|
|
2197
|
+
d["GET /agent/context"]["body"]["context"]["graph_version"]=9
|
|
2198
|
+
json.dump(d,open(sys.argv[1],"w"))
|
|
2199
|
+
PYX
|
|
2200
|
+
rm -f "$(ops_graph_sync_ledger_path)"
|
|
2201
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/rejected/"*.json 2>/dev/null
|
|
2202
|
+
runtime_graph_clear_stale
|
|
2203
|
+
runtime_projection_write '{"fetched_at":"2099-01-01T00:00:00Z","active_slice":null,"nudges":[],"context":{"manifest_hash":"sha256:v1","version":4,"graph_version":4}}'
|
|
2204
|
+
: > "$TMP/calls-gs.jsonl"
|
|
2205
|
+
rc=0; out="$(bash "$SO" graph-sync --file "$TMP/gs-epics.json" 2>&1)" || rc=$?
|
|
2206
|
+
n="$(grep -c '"path": "/projects/p-1/agent/graph-sync-proposals"' "$TMP/calls-gs.jsonl" | tr -d ' ')"
|
|
2207
|
+
[ "$n" = 2 ] && echo "OK el 409 stale provoca UN reintento (2 envíos)" \
|
|
2208
|
+
|| { echo "FAIL reintentos del 409 ($n): $out"; fail=1; }
|
|
2209
|
+
python3 - "$TMP/calls-gs.jsonl" <<'PYX' | grep -q SI \
|
|
2210
|
+
&& echo "OK el reintento reusa el MISMO client_event_id y re-estampa la versión" \
|
|
2211
|
+
|| { echo "FAIL identidad del reintento"; fail=1; }
|
|
2212
|
+
import json,sys
|
|
2213
|
+
envs=[json.loads(json.loads(l)["body"]) for l in open(sys.argv[1])
|
|
2214
|
+
if json.loads(l)["path"].endswith("/agent/graph-sync-proposals")]
|
|
2215
|
+
ok = (len(envs)==2 and envs[0]["client_event_id"]==envs[1]["client_event_id"]
|
|
2216
|
+
and envs[1]["graph_version"]==9)
|
|
2217
|
+
print("SI" if ok else "NO %s" % json.dumps(envs))
|
|
2218
|
+
PYX
|
|
2219
|
+
echo "$out" | grep -qi "rancio\|grafo" && echo "OK el 409 stale se explica al usuario" \
|
|
2220
|
+
|| { echo "FAIL mensaje del 409 ($out)"; fail=1; }
|
|
2221
|
+
|
|
2222
|
+
# 404 anti-oráculo: el mensaje no puede afirmar cuál de las dos causas fue.
|
|
2223
|
+
python3 - "$TMP/routes-gs.json" <<'PYX'
|
|
2224
|
+
import json,sys
|
|
2225
|
+
d=json.load(open(sys.argv[1]))
|
|
2226
|
+
d["POST /projects/p-1/agent/graph-sync-proposals"]={"status":404,"body":{"detail":"Proyecto no encontrado"}}
|
|
2227
|
+
json.dump(d,open(sys.argv[1],"w"))
|
|
2228
|
+
PYX
|
|
2229
|
+
rm -f "$(ops_graph_sync_ledger_path)"
|
|
2230
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/rejected/"*.json 2>/dev/null
|
|
2231
|
+
runtime_graph_clear_stale
|
|
2232
|
+
rc=0; out="$(bash "$SO" graph-sync --file "$TMP/gs-epics.json" 2>&1)" || rc=$?
|
|
2233
|
+
[ "$rc" = 6 ] && echo "OK graph-sync 404 -> rc 6" || { echo "FAIL 404 rc ($rc): $out"; fail=1; }
|
|
2234
|
+
echo "$out" | grep -qi "indistinguible\|no es el tuyo" \
|
|
2235
|
+
&& echo "OK el 404 se explica como anti-oráculo" || { echo "FAIL mensaje del 404 ($out)"; fail=1; }
|
|
2236
|
+
|
|
2237
|
+
# --- graph-sync-status: el veredicto humano, legible por la terminal que propuso ---
|
|
2238
|
+
# El motivo del rechazo lo escribe el ADMIN precisamente para que lo lea quien propuso: sin
|
|
2239
|
+
# este subcomando el veredicto se queda en la consola y el agente vuelve a proponer lo mismo.
|
|
2240
|
+
rm -f "$(ops_graph_sync_ledger_path)"
|
|
2241
|
+
ops_graph_sync_ledger_upsert '{"client_event_id":"ak-1:graph-sync:1","status":"PROPOSED","proposal_id":"gs-7"}'
|
|
2242
|
+
ops_graph_sync_ledger_upsert '{"client_event_id":"ak-1:graph-sync:2","status":"PROPOSED","proposal_id":"gs-8"}'
|
|
2243
|
+
cat > "$TMP/routes-gs.json" <<'JSON'
|
|
2244
|
+
{"GET /projects/p-1/agent/graph-sync-proposals/gs-7": {"status": 200, "body": {"id": "gs-7", "status": "APPROVED", "graph_version": 4, "graph_version_applied": 5, "decided_by": "u-1", "reject_reason": null}},
|
|
2245
|
+
"GET /projects/p-1/agent/graph-sync-proposals/gs-8": {"status": 200, "body": {"id": "gs-8", "status": "REJECTED", "graph_version": 4, "reject_reason": "EP-014 no está en el PRD aprobado", "decided_by": "u-1"}}}
|
|
2246
|
+
JSON
|
|
2247
|
+
rc=0; out="$(bash "$SO" graph-sync-status 2>&1)" || rc=$?
|
|
2248
|
+
[ "$rc" = 0 ] && echo "OK graph-sync-status -> rc 0" || { echo "FAIL graph-sync-status rc ($rc): $out"; fail=1; }
|
|
2249
|
+
echo "$out" | grep -q "APPROVED" && echo "$out" | grep -q "v5" \
|
|
2250
|
+
&& echo "OK graph-sync-status muestra APPROVED con la versión aplicada" \
|
|
2251
|
+
|| { echo "FAIL render de APPROVED ($out)"; fail=1; }
|
|
2252
|
+
echo "$out" | grep -q "EP-014 no está en el PRD aprobado" \
|
|
2253
|
+
&& echo "OK graph-sync-status muestra el motivo del rechazo que escribió el ADMIN" \
|
|
2254
|
+
|| { echo "FAIL motivo del rechazo ($out)"; fail=1; }
|
|
2255
|
+
ops_graph_sync_ledger_read | python3 -c "
|
|
2256
|
+
import json,sys
|
|
2257
|
+
ps={p['proposal_id']:p for p in json.load(sys.stdin)['proposals']}
|
|
2258
|
+
print('SI' if ps['gs-7']['status']=='APPROVED' and ps['gs-8']['status']=='REJECTED' else 'NO')" \
|
|
2259
|
+
| grep -q SI && echo "OK el ledger se actualiza con el veredicto" \
|
|
2260
|
+
|| { echo "FAIL ledger tras el veredicto"; fail=1; }
|
|
2261
|
+
|
|
2262
|
+
out="$(bash "$SO" graph-sync-status --id gs-7 2>&1)"
|
|
2263
|
+
echo "$out" | grep -q "gs-7" && ! echo "$out" | grep -q "gs-8" \
|
|
2264
|
+
&& echo "OK --id filtra a una propuesta" || { echo "FAIL --id no filtra ($out)"; fail=1; }
|
|
2265
|
+
|
|
2266
|
+
rm -f "$(ops_graph_sync_ledger_path)"
|
|
2267
|
+
rc=0; out="$(bash "$SO" graph-sync-status 2>&1)" || rc=$?
|
|
2268
|
+
[ "$rc" = 0 ] && echo "$out" | grep -qi "ninguna\|no hay" \
|
|
2269
|
+
&& echo "OK graph-sync-status sin propuestas -> rc 0 y lo dice" \
|
|
2270
|
+
|| { echo "FAIL sin propuestas (rc $rc): $out"; fail=1; }
|
|
2271
|
+
|
|
2272
|
+
rc=0; out="$(TRYCORE_RUNTIME_MODE=legacy bash "$SO" graph-sync-status 2>&1)" || rc=$?
|
|
2273
|
+
[ "$rc" = 3 ] && echo "OK graph-sync-status en legacy -> rc 3" || { echo "FAIL status legacy rc ($rc)"; fail=1; }
|
|
2274
|
+
|
|
2275
|
+
# Un 404 al consultar es el MISMO anti-oráculo: propuesta ajena o inexistente, sin distinguir.
|
|
2276
|
+
ops_graph_sync_ledger_upsert '{"client_event_id":"ak-1:graph-sync:9","status":"PROPOSED","proposal_id":"gs-99"}'
|
|
2277
|
+
out="$(bash "$SO" graph-sync-status --id gs-99 2>&1)"
|
|
2278
|
+
echo "$out" | grep -qi "indistinguible\|no es de este proyecto\|desconocida" \
|
|
2279
|
+
&& echo "OK una propuesta que el hub no reconoce se explica sin oráculo" \
|
|
2280
|
+
|| { echo "FAIL 404 del status ($out)"; fail=1; }
|
|
2281
|
+
rm -f "$(ops_graph_sync_ledger_path)"
|
|
2282
|
+
|
|
2283
|
+
# ══════════════ espejo dual: el cuerpo que el hub acepta de verdad [EP-OR-17] ══════════════
|
|
2284
|
+
[ "$OPS_RC_NOT_MIRRORABLE" = 8 ] && echo "OK OPS_RC_NOT_MIRRORABLE=8" || { echo "FAIL rc del espejo"; fail=1; }
|
|
2285
|
+
|
|
2286
|
+
# Réplica local de TIPOS_DE_ESPEJO del hub (event_catalog.py: los schemas que heredan de
|
|
2287
|
+
# _TransicionDeEspejo). Test ANTI-DRIFT: si el hub amplía la enumeración, esta lista tiene que
|
|
2288
|
+
# ampliarse a la vez — dos listas del mismo hecho divergen, y la que calla es la que miente.
|
|
2289
|
+
esperados="checkpoint_recorded gate_verdict handoff_recorded phase_advanced phase_reverted progress_noted slice_opened wiring_checklist_seeded wiring_item_updated"
|
|
2290
|
+
got="$(echo $OPS_MIRROR_TYPES | tr ' ' '\n' | sort | tr '\n' ' ' | sed 's/ $//')"
|
|
2291
|
+
[ "$got" = "$esperados" ] && echo "OK OPS_MIRROR_TYPES replica la enumeración normativa del hub" \
|
|
2292
|
+
|| { echo "FAIL OPS_MIRROR_TYPES ($got)"; fail=1; }
|
|
2293
|
+
|
|
2294
|
+
echo '{"gate":"security","status":"pass"}' > "$TMP/mir-payload.json"
|
|
2295
|
+
cat > "$TMP/routes-gs.json" <<'JSON'
|
|
2296
|
+
{"POST /projects/p-1/mirror/transitions": {"status": 200, "body": {"results": [{"client_event_id": "c1", "epic_code": "EP-003", "event_type": "gate_verdict", "status": "applied", "reason": null}], "applied": 1, "duplicates": 0, "rejected": 0}}}
|
|
2297
|
+
JSON
|
|
2298
|
+
|
|
2299
|
+
# Un tipo fuera de la enumeración NO gasta red: hoy viajaba y volvía rechazado por elemento, y
|
|
2300
|
+
# el aviso genérico mandaba al usuario a revisar una conexión que funcionaba.
|
|
2301
|
+
: > "$TMP/calls-gs.jsonl"
|
|
2302
|
+
rc=0; err="$(ops_mirror release_verdict_reported "$TMP/mir-payload.json" EP-003 2>&1)" || rc=$?
|
|
2303
|
+
[ "$rc" = 8 ] && echo "OK un tipo no espejable -> rc 8" || { echo "FAIL tipo no espejable rc ($rc)"; fail=1; }
|
|
2304
|
+
grep -q 'mirror/transitions' "$TMP/calls-gs.jsonl" && { echo "FAIL un tipo no espejable tocó la red"; fail=1; } \
|
|
2305
|
+
|| echo "OK un tipo no espejable no abre socket"
|
|
2306
|
+
echo "$err" | grep -qi "catálogo\|enumeración" \
|
|
2307
|
+
&& echo "OK el aviso dice POR QUÉ no se espeja" || { echo "FAIL aviso del tipo no espejable ($err)"; fail=1; }
|
|
2308
|
+
|
|
2309
|
+
# Sin epic_code no se envía: el hub lo exige y el lote sería un 422 garantizado.
|
|
2310
|
+
: > "$TMP/calls-gs.jsonl"
|
|
2311
|
+
rm -f "$PROJ/.claude/state/build-state.json"
|
|
2312
|
+
rc=0; ops_mirror gate_verdict "$TMP/mir-payload.json" "" >/dev/null 2>&1 || rc=$?
|
|
2313
|
+
[ "$rc" = 5 ] && echo "OK sin epic_code -> rc 5, sin red" || { echo "FAIL sin epic_code rc ($rc)"; fail=1; }
|
|
2314
|
+
grep -q 'mirror/transitions' "$TMP/calls-gs.jsonl" && { echo "FAIL sin epic_code tocó la red"; fail=1; } \
|
|
2315
|
+
|| echo "OK sin epic_code no abre socket"
|
|
2316
|
+
|
|
2317
|
+
# El epic_code cae al estado local cuando no se pasa: en dual el fichero es primario.
|
|
2318
|
+
echo '{"active_slice":{"epica":"EP-077","phase":"green"}}' > "$PROJ/.claude/state/build-state.json"
|
|
2319
|
+
: > "$TMP/calls-gs.jsonl"
|
|
2320
|
+
ops_mirror gate_verdict "$TMP/mir-payload.json" >/dev/null 2>&1
|
|
2321
|
+
python3 - "$TMP/calls-gs.jsonl" <<'PYX' | grep -q SI \
|
|
2322
|
+
&& echo "OK el epic_code cae a active_slice.epica del estado local" \
|
|
2323
|
+
|| { echo "FAIL fallback del epic_code"; fail=1; }
|
|
2324
|
+
import json,sys
|
|
2325
|
+
env=None
|
|
2326
|
+
for line in open(sys.argv[1]):
|
|
2327
|
+
c=json.loads(line)
|
|
2328
|
+
if c["path"].endswith("/mirror/transitions"):
|
|
2329
|
+
env=json.loads(c["body"])
|
|
2330
|
+
print("SI" if env and env["transitions"][0]["epic_code"]=="EP-077" else "NO %s" % json.dumps(env))
|
|
2331
|
+
PYX
|
|
2332
|
+
|
|
2333
|
+
# El cuerpo es {"transitions":[{client_event_id, epic_code, event_type, payload}]} — y el
|
|
2334
|
+
# payload NO lleva claves de más (los schemas del catálogo son extra="forbid").
|
|
2335
|
+
: > "$TMP/calls-gs.jsonl"
|
|
2336
|
+
rc=0; ops_mirror gate_verdict "$TMP/mir-payload.json" EP-003 >/dev/null 2>&1 || rc=$?
|
|
2337
|
+
[ "$rc" = 0 ] && echo "OK espejo aplicado -> rc 0" || { echo "FAIL espejo aplicado rc ($rc)"; fail=1; }
|
|
2338
|
+
python3 - "$TMP/calls-gs.jsonl" <<'PYX' | grep -q SI \
|
|
2339
|
+
&& echo "OK el espejo manda el shape MirrorTransitionsIn" || { echo "FAIL shape del espejo"; fail=1; }
|
|
2340
|
+
import json,sys
|
|
2341
|
+
env=None
|
|
2342
|
+
for line in open(sys.argv[1]):
|
|
2343
|
+
c=json.loads(line)
|
|
2344
|
+
if c["path"].endswith("/mirror/transitions"):
|
|
2345
|
+
env=json.loads(c["body"])
|
|
2346
|
+
t=(env or {}).get("transitions") or [{}]
|
|
2347
|
+
ok = (list((env or {}).keys())==["transitions"] and len(t)==1
|
|
2348
|
+
and set(t[0].keys())=={"client_event_id","epic_code","event_type","payload"}
|
|
2349
|
+
and t[0]["epic_code"]=="EP-003" and t[0]["event_type"]=="gate_verdict"
|
|
2350
|
+
and 1 <= len(t[0]["client_event_id"]) <= 64
|
|
2351
|
+
and set(t[0]["payload"].keys())=={"gate","status"})
|
|
2352
|
+
print("SI" if ok else "NO %s" % json.dumps(env))
|
|
2353
|
+
PYX
|
|
2354
|
+
|
|
2355
|
+
# Un duplicado no es un fallo: el hub deduplica por client_event_id y el trabajo local siguió.
|
|
2356
|
+
python3 - "$TMP/routes-gs.json" <<'PYX'
|
|
2357
|
+
import json,sys
|
|
2358
|
+
d=json.load(open(sys.argv[1]))
|
|
2359
|
+
r=d["POST /projects/p-1/mirror/transitions"]["body"]
|
|
2360
|
+
r["results"][0]["status"]="duplicate"; r["applied"]=0; r["duplicates"]=1
|
|
2361
|
+
json.dump(d,open(sys.argv[1],"w"))
|
|
2362
|
+
PYX
|
|
2363
|
+
rc=0; ops_mirror gate_verdict "$TMP/mir-payload.json" EP-003 >/dev/null 2>&1 || rc=$?
|
|
2364
|
+
[ "$rc" = 0 ] && echo "OK espejo duplicado -> rc 0" || { echo "FAIL espejo duplicado rc ($rc)"; fail=1; }
|
|
2365
|
+
|
|
2366
|
+
# Un rechazo POR ELEMENTO se lee y se muestra: es el motivo, no el cuerpo crudo.
|
|
2367
|
+
python3 - "$TMP/routes-gs.json" <<'PYX'
|
|
2368
|
+
import json,sys
|
|
2369
|
+
d=json.load(open(sys.argv[1]))
|
|
2370
|
+
r=d["POST /projects/p-1/mirror/transitions"]["body"]
|
|
2371
|
+
r["results"][0]["status"]="rejected"
|
|
2372
|
+
r["results"][0]["reason"]="la épica 'EP-003' no existe en el grafo del proyecto"
|
|
2373
|
+
r["applied"]=0; r["duplicates"]=0; r["rejected"]=1
|
|
2374
|
+
json.dump(d,open(sys.argv[1],"w"))
|
|
2375
|
+
PYX
|
|
2376
|
+
rc=0; err="$(ops_mirror gate_verdict "$TMP/mir-payload.json" EP-003 2>&1)" || rc=$?
|
|
2377
|
+
[ "$rc" = 6 ] && echo "OK espejo rechazado -> rc 6" || { echo "FAIL espejo rechazado rc ($rc)"; fail=1; }
|
|
2378
|
+
echo "$err" | grep -q "no existe en el grafo" \
|
|
2379
|
+
&& echo "OK el rechazo del espejo muestra el motivo POR ELEMENTO" \
|
|
2380
|
+
|| { echo "FAIL motivo del rechazo del espejo ($err)"; fail=1; }
|
|
2381
|
+
echo "$err" | grep -q "graph-sync" \
|
|
2382
|
+
&& echo "OK el rechazo por épica ausente apunta a graph-sync" \
|
|
2383
|
+
|| { echo "FAIL el rechazo no sugiere graph-sync ($err)"; fail=1; }
|
|
2384
|
+
rm -f "$PROJ/.claude/state/build-state.json"
|
|
2385
|
+
|
|
1936
2386
|
# ══════════════ scripts/lib/graph-bundle.py [Task 13 · issue #41] ══════════════
|
|
1937
2387
|
GB="$ROOT/scripts/lib/graph-bundle.py"
|
|
1938
2388
|
cat > "$TMP/epics.json" <<'JSON'
|
|
@@ -2087,6 +2537,66 @@ grep -q "EP-020 tiene sección" "$TMP/gb10.err" && grep -q "EP-021 no aparece co
|
|
|
2087
2537
|
|
|
2088
2538
|
# 4-ter) Las dos banderas juntas son un error explícito (ignorar una en silencio es el
|
|
2089
2539
|
# patrón que causó #56).
|
|
2540
|
+
# ══════════ graph-bundle.py --for-sync [EP-OR-17] ══════════
|
|
2541
|
+
# La proyección de contrato del carril `graph-sync`. `GraphSyncBundle` es extra="forbid" y
|
|
2542
|
+
# admite SOLO `epics`: cualquier clave de preparación (bundle_version, kind, project_ref,
|
|
2543
|
+
# warnings…) es un 422. Y lo que en el bundle de preparación es AVISO aquí es RECHAZO: sin un
|
|
2544
|
+
# ADMIN revisando antes de subirlo, un aviso ignorado es el 422 del hub con pasos extra.
|
|
2545
|
+
cat > "$TMP/sync-in.json" <<'JSON'
|
|
2546
|
+
{"project_ref": "demo",
|
|
2547
|
+
"epics": [{"code": "EP-002", "title": "Cobros", "layer": "business",
|
|
2548
|
+
"files_scope": ["src/cobros/**"], "depends_on": ["EP-001"],
|
|
2549
|
+
"stories": [{"id": "HU-011", "title": "Registrar cobro"}]},
|
|
2550
|
+
{"code": "EP-001", "title": "Cimiento", "layer": "foundational",
|
|
2551
|
+
"stories": []}],
|
|
2552
|
+
"release_lines": [{"id": "R1", "epics": ["EP-001", "EP-002"]}]}
|
|
2553
|
+
JSON
|
|
2554
|
+
rc=0; out="$(python3 "$GB" --for-sync < "$TMP/sync-in.json" 2>"$TMP/sync-err.txt")" || rc=$?
|
|
2555
|
+
[ "$rc" = 0 ] && echo "OK --for-sync con bundle válido -> rc 0" \
|
|
2556
|
+
|| { echo "FAIL --for-sync rc ($rc): $(cat "$TMP/sync-err.txt")"; fail=1; }
|
|
2557
|
+
printf '%s' "$out" | python3 -c "
|
|
2558
|
+
import json,sys
|
|
2559
|
+
d=json.load(sys.stdin)
|
|
2560
|
+
e=d['epics']
|
|
2561
|
+
ok = (list(d.keys())==['epics'] and len(e)==2 and e[0]['code']=='EP-001'
|
|
2562
|
+
and e[1]['layer']=='BUSINESS' and e[1]['stories'][0]['code']=='HU-011'
|
|
2563
|
+
and e[1]['release_line']=='R1'
|
|
2564
|
+
and not any(k in e[0] for k in ('warnings','bundle_version','kind','project_ref')))
|
|
2565
|
+
print('SI' if ok else 'NO')" | grep -q SI \
|
|
2566
|
+
&& echo "OK --for-sync emite solo {epics:[…]} con el shape del contrato" \
|
|
2567
|
+
|| { echo "FAIL shape de --for-sync ($out)"; fail=1; }
|
|
2568
|
+
|
|
2569
|
+
echo '{"epics":[{"code":"EP-009","title":"Sin capa"}]}' > "$TMP/sync-nolayer.json"
|
|
2570
|
+
rc=0; python3 "$GB" --for-sync < "$TMP/sync-nolayer.json" >/dev/null 2>"$TMP/sync-err.txt" || rc=$?
|
|
2571
|
+
[ "$rc" = 2 ] && echo "OK --for-sync sin layer -> rc 2" || { echo "FAIL layer ausente rc ($rc)"; fail=1; }
|
|
2572
|
+
grep -q "EP-009" "$TMP/sync-err.txt" && grep -qi "layer" "$TMP/sync-err.txt" \
|
|
2573
|
+
&& echo "OK el rechazo nombra la épica y el campo" \
|
|
2574
|
+
|| { echo "FAIL mensaje de layer ausente ($(cat "$TMP/sync-err.txt"))"; fail=1; }
|
|
2575
|
+
|
|
2576
|
+
python3 -c "
|
|
2577
|
+
import json
|
|
2578
|
+
hs=[{'id':'HU-%03d'%i,'title':'h%d'%i} for i in range(101)]
|
|
2579
|
+
print(json.dumps({'epics':[{'code':'EP-010','title':'Muchas','layer':'BUSINESS','stories':hs}]}))" > "$TMP/sync-techo.json"
|
|
2580
|
+
rc=0; python3 "$GB" --for-sync < "$TMP/sync-techo.json" >/dev/null 2>"$TMP/sync-err.txt" || rc=$?
|
|
2581
|
+
[ "$rc" = 2 ] && echo "OK --for-sync con 101 historias -> rc 2" || { echo "FAIL techo de historias rc ($rc)"; fail=1; }
|
|
2582
|
+
grep -q "100" "$TMP/sync-err.txt" && echo "OK el rechazo por techo dice cuál es el techo" \
|
|
2583
|
+
|| { echo "FAIL mensaje de techo ($(cat "$TMP/sync-err.txt"))"; fail=1; }
|
|
2584
|
+
|
|
2585
|
+
echo '{"epics":[]}' > "$TMP/sync-vacio.json"
|
|
2586
|
+
rc=0; python3 "$GB" --for-sync < "$TMP/sync-vacio.json" >/dev/null 2>"$TMP/sync-err.txt" || rc=$?
|
|
2587
|
+
[ "$rc" = 2 ] && echo "OK --for-sync sin épicas -> rc 2" || { echo "FAIL bundle vacío rc ($rc)"; fail=1; }
|
|
2588
|
+
|
|
2589
|
+
# El modo de preparación NO cambia: sigue tolerando lo que el ADMIN va a revisar.
|
|
2590
|
+
rc=0; out="$(python3 "$GB" < "$TMP/sync-nolayer.json" 2>/dev/null)" || rc=$?
|
|
2591
|
+
[ "$rc" = 0 ] && printf '%s' "$out" | grep -q '"bundle_version"' \
|
|
2592
|
+
&& echo "OK el modo de preparación sigue tolerando layer ausente (rc 0 + avisos)" \
|
|
2593
|
+
|| { echo "FAIL regresión en el modo de preparación (rc $rc)"; fail=1; }
|
|
2594
|
+
|
|
2595
|
+
# --for-sync se combina con --from-docs: las dependencias en prosa también viajan al hub.
|
|
2596
|
+
rc=0; out="$(python3 "$GB" --for-sync --from-docs "$TMP/docs/epicas.md" < "$TMP/sync-in.json" 2>/dev/null)" || rc=$?
|
|
2597
|
+
[ "$rc" = 0 ] && echo "OK --for-sync se combina con --from-docs" \
|
|
2598
|
+
|| { echo "FAIL --for-sync con --from-docs (rc $rc)"; fail=1; }
|
|
2599
|
+
|
|
2090
2600
|
echo '{"epics":[]}' | python3 "$GB" --from-docs "$TMP/docs/epicas.md" --print-deps "$TMP/docs/epicas.md" > /dev/null 2> "$TMP/gb11.err"
|
|
2091
2601
|
[ "$?" = 1 ] && grep -qi "excluyentes" "$TMP/gb11.err" \
|
|
2092
2602
|
&& echo "OK --from-docs y --print-deps juntas -> rc 1 explícito" || { echo "FAIL banderas excluyentes"; fail=1; }
|
|
@@ -2105,7 +2615,7 @@ PORT_GV="$(freeport)"
|
|
|
2105
2615
|
cat > "$TMP/routes-gv.json" <<JSON
|
|
2106
2616
|
{"GET /agent/context": {"status": 200, "body": {"project": {}, "active_slice": {}, "nudges": [],
|
|
2107
2617
|
"context": {"manifest_hash": "sha256:v2", "version": 9, "graph_version": 45}}},
|
|
2108
|
-
"POST /tasks/next": {"status": 409, "body": {"
|
|
2618
|
+
"POST /tasks/next": {"status": 409, "body": {"detail": {"error": "graph_version_stale", "message": "el grafo avanzo", "graph_version": 45}}}}
|
|
2109
2619
|
JSON
|
|
2110
2620
|
: > "$TMP/calls-gv.jsonl"
|
|
2111
2621
|
python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_GV" "$TMP/routes-gv.json" "$TMP/calls-gv.jsonl" &
|
|
@@ -2165,6 +2675,51 @@ PY
|
|
|
2165
2675
|
[ "$has" = NO ] && [ $rc -eq 7 ] && echo "OK sin hub que versione, el claim no manda nada nuevo" \
|
|
2166
2676
|
|| { echo "FAIL compatibilidad hacia atras del claim (has=$has rc=$rc)"; fail=1; }
|
|
2167
2677
|
|
|
2678
|
+
# --- [EP-OR-17] Los OTROS motivos del 409, como los emite el hub de verdad ---
|
|
2679
|
+
# El `case` esperaba `reason: drift|contention|…` en la raíz y el hub anida todo bajo `detail`:
|
|
2680
|
+
# los cuatro casos caían en el `*)` y salían como rechazo duro. Un drift es REINTENTABLE tras
|
|
2681
|
+
# sincronizar el contexto, y reportarlo como rechazo dejaba al agente sin claim y sin salida.
|
|
2682
|
+
python3 - "$TMP/routes-gv.json" <<'PY'
|
|
2683
|
+
import json,sys
|
|
2684
|
+
p=sys.argv[1]; d=json.load(open(p))
|
|
2685
|
+
d["POST /tasks/next"]={"status":409,"body":{"detail":{"message":"tu contexto local no coincide","manifest_hash":"sha256:v9"}}}
|
|
2686
|
+
json.dump(d,open(p,"w"))
|
|
2687
|
+
PY
|
|
2688
|
+
: > "$TMP/calls-gv.jsonl"
|
|
2689
|
+
out="$(bash "$SO" claim 2>&1)"; rc=$?
|
|
2690
|
+
n="$(grep -c '"path": "/tasks/next"' "$TMP/calls-gv.jsonl" | tr -d ' ')"
|
|
2691
|
+
[ "$n" = 2 ] && echo "OK un 409 de drift (detail.manifest_hash) se reintenta una vez" \
|
|
2692
|
+
|| { echo "FAIL reintentos del 409 de drift ($n)"; fail=1; }
|
|
2693
|
+
[ $rc -eq 7 ] && echo "OK un drift persistente acaba en NO_WORK, no en rechazo duro" \
|
|
2694
|
+
|| { echo "FAIL rc del 409 de drift ($rc): $out"; fail=1; }
|
|
2695
|
+
|
|
2696
|
+
python3 - "$TMP/routes-gv.json" <<'PY'
|
|
2697
|
+
import json,sys
|
|
2698
|
+
p=sys.argv[1]; d=json.load(open(p))
|
|
2699
|
+
d["POST /tasks/next"]={"status":409,"body":{"detail":"contención con otra escritura del contexto: reintenta el claim"}}
|
|
2700
|
+
json.dump(d,open(p,"w"))
|
|
2701
|
+
PY
|
|
2702
|
+
: > "$TMP/calls-gv.jsonl"
|
|
2703
|
+
out="$(bash "$SO" claim 2>&1)"; rc=$?
|
|
2704
|
+
n="$(grep -c '"path": "/tasks/next"' "$TMP/calls-gv.jsonl" | tr -d ' ')"
|
|
2705
|
+
[ "$n" = 2 ] && [ $rc -eq 7 ] && echo "OK un 409 de contención se reintenta una vez" \
|
|
2706
|
+
|| { echo "FAIL 409 de contención (n=$n rc=$rc): $out"; fail=1; }
|
|
2707
|
+
|
|
2708
|
+
python3 - "$TMP/routes-gv.json" <<'PY'
|
|
2709
|
+
import json,sys
|
|
2710
|
+
p=sys.argv[1]; d=json.load(open(p))
|
|
2711
|
+
d["POST /tasks/next"]={"status":409,"body":{"detail":"el contexto vigente del proyecto no resuelve (manifiesto ausente): no hay trabajo sin contexto declarado"}}
|
|
2712
|
+
json.dump(d,open(p,"w"))
|
|
2713
|
+
PY
|
|
2714
|
+
: > "$TMP/calls-gv.jsonl"
|
|
2715
|
+
out="$(bash "$SO" claim 2>&1)"; rc=$?
|
|
2716
|
+
n="$(grep -c '"path": "/tasks/next"' "$TMP/calls-gv.jsonl" | tr -d ' ')"
|
|
2717
|
+
[ "$n" = 1 ] && [ $rc -eq 6 ] && echo "OK un contexto irresoluble NO se reintenta (rc 6)" \
|
|
2718
|
+
|| { echo "FAIL 409 de contexto irresoluble (n=$n rc=$rc): $out"; fail=1; }
|
|
2719
|
+
echo "$out" | grep -q "no hay trabajo sin contexto declarado" \
|
|
2720
|
+
&& echo "OK el 409 enseña el motivo del hub en prosa, no el JSON crudo" \
|
|
2721
|
+
|| { echo "FAIL prosa del 409 ($out)"; fail=1; }
|
|
2722
|
+
|
|
2168
2723
|
kill "$STUB_GV" 2>/dev/null; wait "$STUB_GV" 2>/dev/null
|
|
2169
2724
|
|
|
2170
2725
|
exit $fail
|