@trycore/spec-build-harness 0.8.5 → 0.11.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/GOVERNANCE.md +27 -4
- package/INSTALL.md +27 -5
- package/METODOLOGIA.md +55 -5
- package/README.md +39 -6
- package/VERSION +1 -1
- package/agents/build/build-orchestrator.md +33 -7
- package/agents/build/dor-dod-gatekeeper.md +13 -5
- package/agents/build/wiring-adversarial-verifier.md +52 -5
- package/commands/build/architect.md +1 -1
- package/commands/build/claim.md +46 -0
- package/commands/build/escalate.md +36 -0
- package/commands/build/front.md +9 -3
- package/commands/build/onboard.md +75 -14
- package/commands/build/prototype.md +3 -2
- package/commands/build/reflect.md +60 -40
- package/commands/build/release.md +10 -7
- package/commands/build/resume.md +33 -13
- package/commands/build/slice.md +32 -27
- package/commands/build/status.md +35 -0
- package/commands/build/work.md +11 -8
- package/config/build-config.template.json +4 -0
- package/dist/cli.js +32 -0
- package/dist/commands/doctor.js +42 -0
- package/dist/commands/init.js +84 -1
- package/dist/commands/migrate.js +153 -0
- package/dist/commands/status.js +34 -0
- package/dist/lib/normalize.js +1123 -0
- package/dist/lib/paths.js +6 -0
- package/dist/lib/runtime-client.js +196 -0
- package/dist/lib/settings-merge.js +3 -3
- package/dist/lib/state-bundle.js +150 -0
- package/docs/commands.md +25 -8
- package/docs/getting-started.md +1 -0
- package/docs/hooks.md +114 -27
- package/docs/runtime/guia-modo-dual-y-migracion.md +143 -0
- package/docs/runtime/plan-migracion-harness-v0.9.md +11 -0
- package/docs/runtime/protocolo-cliente-runtime.md +120 -35
- package/hooks/build/build-gate-check.sh +21 -0
- package/hooks/build/context-monitor.sh +82 -15
- package/hooks/build/context-sync.sh +192 -0
- package/hooks/build/design-source-guard.sh +30 -2
- package/hooks/build/dual-compare.sh +92 -0
- package/hooks/build/event-emitter.sh +32 -0
- package/hooks/build/gitflow-guard.sh +164 -14
- package/hooks/build/heartbeat.sh +259 -0
- package/hooks/build/lib/agent-context.sh +139 -0
- package/hooks/build/lib/config.sh +27 -0
- package/hooks/build/lib/projection.sh +71 -0
- package/hooks/build/lib/runtime-client.sh +625 -0
- package/hooks/build/lib/runtime-ops.sh +227 -0
- package/hooks/build/lib/state-io.sh +5 -18
- package/hooks/build/load-build-state.sh +64 -2
- package/hooks/build/reflect-nudge.sh +15 -0
- package/hooks/build/release-gate-nudge.sh +15 -0
- package/hooks/build/release-ops.sh +171 -0
- package/hooks/build/scaffold-guard.sh +29 -2
- package/hooks/build/session-start.sh +103 -0
- package/hooks/build/session-stop.sh +22 -0
- package/hooks/build/slice-ops.sh +948 -0
- package/hooks/build/stack-guard.sh +8 -0
- package/hooks/build/statusline-bridge.sh +24 -3
- package/hooks/build-harness.json +16 -0
- package/package.json +3 -3
- package/scripts/check-agnostic.sh +3 -1
- package/scripts/check-pack-clean.sh +31 -0
- package/scripts/check-runtime-purity.sh +43 -0
- package/scripts/denylist.txt +4 -0
- package/scripts/lib/front-plan.py +4 -0
- package/scripts/lib/graph-bundle.py +181 -0
- package/scripts/runtime-purity-allow.txt +5 -0
- package/scripts/smoke-test.sh +1 -1
- package/scripts/tests/lib/http-stub.py +46 -0
- package/scripts/tests/test-baseline-verdict.sh +92 -0
- package/scripts/tests/test-config.sh +25 -0
- package/scripts/tests/test-hooks-runtime.sh +828 -0
- package/scripts/tests/test-install.sh +103 -0
- package/scripts/tests/test-runtime-client.sh +298 -0
- package/scripts/tests/test-schema.sh +29 -1
- package/scripts/tests/test-skill-ops.sh +1367 -0
- package/skills/building-a-micro-change/SKILL.md +22 -4
- package/skills/building-a-slice/SKILL.md +55 -21
- package/skills/building-a-slice/assets/baseline-verdict.sh +172 -0
- package/skills/building-a-slice/references/dod.md +12 -3
- package/skills/building-a-slice/references/dor.md +3 -2
- package/skills/building-a-slice/references/evidence-budget.md +51 -0
- package/skills/building-a-slice/references/exploration-fanout.md +1 -1
- package/skills/building-a-slice/references/gitflow.md +1 -1
- package/skills/building-a-slice/references/regression-baseline.md +67 -0
- package/skills/building-a-slice/references/runtime-protocol.md +75 -0
- package/skills/building-a-slice/references/state-protocol.md +12 -1
- package/skills/building-a-slice/workflows/README.md +7 -3
- package/skills/building-a-slice/workflows/explore-fanout.workflow.js +3 -3
- package/skills/building-a-slice/workflows/wiring-verify.workflow.js +26 -4
- package/skills/managing-parallel-front/SKILL.md +32 -16
- package/skills/openspec-archive-change/SKILL.md +15 -0
- package/skills/prototyping-screens/SKILL.md +9 -5
- package/skills/releasing-a-version/SKILL.md +26 -16
- package/skills/releasing-a-version/references/release-dod.md +7 -5
- package/skills/releasing-a-version/workflows/README.md +2 -1
- package/skills/releasing-a-version/workflows/release-gate.workflow.js +6 -5
- package/skills/setup-architecture/SKILL.md +4 -2
- package/state/README.md +16 -1
- package/state/build-state.schema.json +2 -1
- package/templates/CLAUDE.md.template +16 -0
- package/templates/settings-hooks.template.json +8 -4
- package/internal/skills/auditar-arnes/SKILL.md +0 -29
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# runtime-client.sh — cliente bash del Agent Orchestrator Runtime [EP-OR-08-A].
|
|
3
|
+
# Diseño: fail-open, igual que state-io.sh/config.sh. Ninguna función bloquea ni lanza;
|
|
4
|
+
# ante red caída, curl/python3 ausentes, JSON corrupto o timeout, degrada a un valor
|
|
5
|
+
# vacío/código distinguible y jamás corrompe el estado local.
|
|
6
|
+
# Contrato de red: docs/runtime/protocolo-cliente-runtime.md + spec §3.1/§4.1-A.
|
|
7
|
+
|
|
8
|
+
source "$(dirname "${BASH_SOURCE[0]}")/config.sh"
|
|
9
|
+
|
|
10
|
+
# runtime_mode — TRYCORE_RUNTIME_MODE (env) > runtime.mode (config) > "legacy".
|
|
11
|
+
# En "legacy" ninguna función de red debe invocarse desde un guard/hook. Normaliza
|
|
12
|
+
# cualquier valor que no sea exactamente "dual"/"runtime" a "legacy" — un valor inválido
|
|
13
|
+
# o mal escrito (typo, mayúscula) debe fallar HACIA el modo seguro, nunca hacia la red.
|
|
14
|
+
runtime_mode() {
|
|
15
|
+
local m
|
|
16
|
+
if [ -n "${TRYCORE_RUNTIME_MODE:-}" ]; then
|
|
17
|
+
m="$TRYCORE_RUNTIME_MODE"
|
|
18
|
+
else
|
|
19
|
+
m="$(config_get runtime.mode legacy)"
|
|
20
|
+
fi
|
|
21
|
+
case "$m" in
|
|
22
|
+
dual|runtime) echo "$m" ;;
|
|
23
|
+
*) echo legacy ;;
|
|
24
|
+
esac
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
runtime_credentials_path() {
|
|
28
|
+
echo "$(config_root)/.claude/state/runtime.credentials"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# runtime_field <clave> — lee un campo del fichero de credenciales. Vacío si falta.
|
|
32
|
+
runtime_field() {
|
|
33
|
+
local key="$1" file
|
|
34
|
+
file="$(runtime_credentials_path)"
|
|
35
|
+
[ -f "$file" ] || { echo ""; return; }
|
|
36
|
+
command -v python3 >/dev/null 2>&1 || { echo ""; return; }
|
|
37
|
+
python3 - "$file" "$key" <<'PY' 2>/dev/null
|
|
38
|
+
import json,sys
|
|
39
|
+
file,key=sys.argv[1],sys.argv[2]
|
|
40
|
+
try:
|
|
41
|
+
d=json.load(open(file))
|
|
42
|
+
v=d.get(key)
|
|
43
|
+
print(v if v is not None else "")
|
|
44
|
+
except Exception:
|
|
45
|
+
print("")
|
|
46
|
+
PY
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# runtime_credentials_merge <json> — hace upsert de las claves del fragmento sobre el
|
|
50
|
+
# fichero existente (o uno nuevo). Escritura atómica + chmod 0600. Nunca imprime el token.
|
|
51
|
+
runtime_credentials_merge() {
|
|
52
|
+
local fragment="$1" file dir
|
|
53
|
+
file="$(runtime_credentials_path)"
|
|
54
|
+
dir="$(dirname "$file")"
|
|
55
|
+
mkdir -p "$dir"
|
|
56
|
+
command -v python3 >/dev/null 2>&1 || return 1
|
|
57
|
+
python3 - "$file" "$fragment" <<'PY' 2>/dev/null || return 1
|
|
58
|
+
import json,sys,os,tempfile
|
|
59
|
+
path,frag=sys.argv[1],sys.argv[2]
|
|
60
|
+
try:
|
|
61
|
+
d=json.load(open(path)) if os.path.exists(path) else {}
|
|
62
|
+
except Exception:
|
|
63
|
+
d={}
|
|
64
|
+
if not isinstance(d,dict):
|
|
65
|
+
d={}
|
|
66
|
+
d.update(json.loads(frag))
|
|
67
|
+
dirn=os.path.dirname(path) or "."
|
|
68
|
+
fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".runtime-credentials.",suffix=".tmp")
|
|
69
|
+
try:
|
|
70
|
+
with os.fdopen(fd,"w") as out:
|
|
71
|
+
json.dump(d,out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
|
|
72
|
+
os.chmod(tmp,0o600)
|
|
73
|
+
os.replace(tmp,path)
|
|
74
|
+
except Exception:
|
|
75
|
+
try: os.unlink(tmp)
|
|
76
|
+
except OSError: pass
|
|
77
|
+
raise
|
|
78
|
+
PY
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
runtime_url() {
|
|
82
|
+
runtime_field runtime_url
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# runtime_status_file — fichero de estado para el código HTTP de la última llamada.
|
|
86
|
+
# NO se usa una variable global: toda función de este cliente se invoca vía sustitución
|
|
87
|
+
# de comandos (`body="$(runtime_get ...)"`), que bash ejecuta en una subshell — cualquier
|
|
88
|
+
# asignación de variable dentro de esa subshell se pierde al volver al shell padre. Un
|
|
89
|
+
# fichero en disco sí sobrevive ese límite (mismo principio que context.lock/credentials).
|
|
90
|
+
runtime_status_file() {
|
|
91
|
+
echo "$(config_root)/.claude/state/.runtime-http-status"
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# runtime_http_status — código HTTP de la última llamada a runtime_get/runtime_post.
|
|
95
|
+
runtime_http_status() {
|
|
96
|
+
local sfile; sfile="$(runtime_status_file)"
|
|
97
|
+
[ -f "$sfile" ] && cat "$sfile" || echo "000"
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# _runtime_http <METHOD> <path> [body] — primitivo interno. Nunca lanza; ante fallo de
|
|
101
|
+
# red/curl/python3 deja runtime_http_status()=000 y stdout vacío. El body SIEMPRE se
|
|
102
|
+
# manda vía `--data-binary @fichero` (nunca `-d "$body"` directo): un body en argv está
|
|
103
|
+
# limitado por ARG_MAX del sistema (~1 MB típico) — con la cota del outbox en 5 MB
|
|
104
|
+
# (Task 8) un lote grande superaría ese límite y curl fallaría en silencio, fail-open,
|
|
105
|
+
# sin drenar nunca la cola. Un fichero no tiene ese límite.
|
|
106
|
+
_runtime_http() {
|
|
107
|
+
local method="$1" path="$2" body="${3:-}" url token tmp bodyfile status rc sfile
|
|
108
|
+
url="$(runtime_url)${path}"
|
|
109
|
+
token="$(runtime_field project_token)"
|
|
110
|
+
sfile="$(runtime_status_file)"
|
|
111
|
+
mkdir -p "$(dirname "$sfile")"
|
|
112
|
+
if [ -z "$(runtime_url)" ] || ! command -v curl >/dev/null 2>&1; then
|
|
113
|
+
printf '000' > "$sfile"
|
|
114
|
+
return 1
|
|
115
|
+
fi
|
|
116
|
+
tmp="$(mktemp)" || { printf '000' > "$sfile"; return 1; }
|
|
117
|
+
if [ -n "$body" ]; then
|
|
118
|
+
bodyfile="$(mktemp)" || { printf '000' > "$sfile"; rm -f "$tmp"; return 1; }
|
|
119
|
+
printf '%s' "$body" > "$bodyfile"
|
|
120
|
+
status="$(curl -sS -m 10 -o "$tmp" -w '%{http_code}' -X "$method" "$url" \
|
|
121
|
+
-H "Authorization: Bearer $token" -H 'Content-Type: application/json' \
|
|
122
|
+
--data-binary "@$bodyfile" 2>/dev/null)"
|
|
123
|
+
rc=$?
|
|
124
|
+
rm -f "$bodyfile"
|
|
125
|
+
else
|
|
126
|
+
status="$(curl -sS -m 10 -o "$tmp" -w '%{http_code}' -X "$method" "$url" \
|
|
127
|
+
-H "Authorization: Bearer $token" 2>/dev/null)"
|
|
128
|
+
rc=$?
|
|
129
|
+
fi
|
|
130
|
+
if [ $rc -ne 0 ] || [ -z "$status" ]; then
|
|
131
|
+
printf '000' > "$sfile"
|
|
132
|
+
rm -f "$tmp"
|
|
133
|
+
return 1
|
|
134
|
+
fi
|
|
135
|
+
printf '%s' "$status" > "$sfile"
|
|
136
|
+
cat "$tmp"
|
|
137
|
+
rm -f "$tmp"
|
|
138
|
+
return 0
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
runtime_get() { _runtime_http GET "$1" ""; }
|
|
142
|
+
runtime_post() { _runtime_http POST "$1" "$2"; }
|
|
143
|
+
runtime_put() { _runtime_http PUT "$1" "$2"; }
|
|
144
|
+
|
|
145
|
+
# runtime_register <harness_version> [asset_types_json="[]"]
|
|
146
|
+
# 0 = registrado (credenciales actualizadas) · 1 = fallo de red/parseo (fail-open, reintentable)
|
|
147
|
+
# 2 = 409 incompatible_version (no reintentar sin actualizar el arnés)
|
|
148
|
+
runtime_register() {
|
|
149
|
+
local hv="$1" asset_types="${2:-[]}" resp status frag
|
|
150
|
+
resp="$(runtime_post "/agents/register" "{\"harness_version\":\"$hv\",\"asset_types\":$asset_types}")"
|
|
151
|
+
status="$(runtime_http_status)"
|
|
152
|
+
case "$status" in
|
|
153
|
+
200)
|
|
154
|
+
command -v python3 >/dev/null 2>&1 || return 1
|
|
155
|
+
frag="$(python3 - "$resp" <<'PY'
|
|
156
|
+
import json,sys
|
|
157
|
+
try:
|
|
158
|
+
d=json.loads(sys.argv[1])
|
|
159
|
+
out={k:d.get(k) for k in ("agent_key","project_id","poll_interval_s","lease_ttl_s","manifest_hash") if k in d}
|
|
160
|
+
print(json.dumps(out))
|
|
161
|
+
except Exception:
|
|
162
|
+
print("{}")
|
|
163
|
+
PY
|
|
164
|
+
)"
|
|
165
|
+
runtime_credentials_merge "$frag" || return 1
|
|
166
|
+
return 0
|
|
167
|
+
;;
|
|
168
|
+
409)
|
|
169
|
+
echo "⛔ runtime_register: versión del arnés incompatible con el runtime — $resp" >&2
|
|
170
|
+
return 2
|
|
171
|
+
;;
|
|
172
|
+
*)
|
|
173
|
+
return 1
|
|
174
|
+
;;
|
|
175
|
+
esac
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
runtime_lock_path() {
|
|
179
|
+
echo "$(config_root)/.claude/state/context.lock"
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
runtime_lock_manifest_hash() {
|
|
183
|
+
local file; file="$(runtime_lock_path)"
|
|
184
|
+
[ -f "$file" ] || { echo ""; return; }
|
|
185
|
+
command -v python3 >/dev/null 2>&1 || { echo ""; return; }
|
|
186
|
+
python3 - "$file" <<'PY' 2>/dev/null
|
|
187
|
+
import json,sys
|
|
188
|
+
try:
|
|
189
|
+
print(json.load(open(sys.argv[1])).get("manifest_hash",""))
|
|
190
|
+
except Exception:
|
|
191
|
+
print("")
|
|
192
|
+
PY
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
# runtime_lock_write <manifest_hash> [files_json="[]"]
|
|
196
|
+
runtime_lock_write() {
|
|
197
|
+
local hash="$1" files="${2:-[]}" file dir
|
|
198
|
+
file="$(runtime_lock_path)"; dir="$(dirname "$file")"
|
|
199
|
+
mkdir -p "$dir"
|
|
200
|
+
command -v python3 >/dev/null 2>&1 || return 1
|
|
201
|
+
python3 - "$file" "$hash" "$files" <<'PY' 2>/dev/null || return 1
|
|
202
|
+
import json,sys,os,tempfile,datetime
|
|
203
|
+
path,h,files=sys.argv[1],sys.argv[2],sys.argv[3]
|
|
204
|
+
d={"manifest_hash":h,
|
|
205
|
+
"synced_at":datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
206
|
+
"files":json.loads(files)}
|
|
207
|
+
dirn=os.path.dirname(path) or "."
|
|
208
|
+
fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".context.lock.",suffix=".tmp")
|
|
209
|
+
try:
|
|
210
|
+
with os.fdopen(fd,"w") as out:
|
|
211
|
+
json.dump(d,out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
|
|
212
|
+
os.replace(tmp,path)
|
|
213
|
+
except Exception:
|
|
214
|
+
try: os.unlink(tmp)
|
|
215
|
+
except OSError: pass
|
|
216
|
+
raise
|
|
217
|
+
PY
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
runtime_projection_path() {
|
|
221
|
+
echo "$(config_root)/.claude/state/runtime-projection.json"
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
runtime_projection_read() {
|
|
225
|
+
local file; file="$(runtime_projection_path)"
|
|
226
|
+
[ -f "$file" ] || { echo "{}"; return; }
|
|
227
|
+
command -v python3 >/dev/null 2>&1 || { echo "{}"; return; }
|
|
228
|
+
python3 - "$file" <<'PY' 2>/dev/null
|
|
229
|
+
import json,sys
|
|
230
|
+
try:
|
|
231
|
+
d=json.load(open(sys.argv[1]))
|
|
232
|
+
print(json.dumps(d))
|
|
233
|
+
except Exception:
|
|
234
|
+
print("{}")
|
|
235
|
+
PY
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
runtime_projection_write() {
|
|
239
|
+
local body="$1" file dir
|
|
240
|
+
file="$(runtime_projection_path)"; dir="$(dirname "$file")"
|
|
241
|
+
mkdir -p "$dir"
|
|
242
|
+
command -v python3 >/dev/null 2>&1 || return 1
|
|
243
|
+
python3 - "$file" "$body" <<'PY' 2>/dev/null || return 1
|
|
244
|
+
import json,sys,os,tempfile
|
|
245
|
+
path,body=sys.argv[1],sys.argv[2]
|
|
246
|
+
d=json.loads(body)
|
|
247
|
+
dirn=os.path.dirname(path) or "."
|
|
248
|
+
fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".runtime-projection.",suffix=".tmp")
|
|
249
|
+
try:
|
|
250
|
+
with os.fdopen(fd,"w") as out:
|
|
251
|
+
json.dump(d,out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
|
|
252
|
+
os.replace(tmp,path)
|
|
253
|
+
except Exception:
|
|
254
|
+
try: os.unlink(tmp)
|
|
255
|
+
except OSError: pass
|
|
256
|
+
raise
|
|
257
|
+
PY
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
RUNTIME_OUTBOX_MAX_BYTES=5242880
|
|
261
|
+
RUNTIME_OUTBOX_MAX_AGE_S=259200
|
|
262
|
+
# [EP-OR-08-C] Los hechos de dominio que emiten las skills (slice-ops.sh) tampoco se evictan:
|
|
263
|
+
# perder un `slice_archived` o un `wiring_item_updated` dejaría la proyección del servidor
|
|
264
|
+
# mintiendo. El volumen evictable es `progress_noted` (y su alias 0.10.x).
|
|
265
|
+
# [#38] Los hechos máquina de proyecto viajan como UN solo tipo del catálogo v2
|
|
266
|
+
# (`project_fact_updated`); los cinco alias legados (`scaffold_confirmed`,
|
|
267
|
+
# `design_source_declared`, `project_kind_declared`, `foundation_declared`,
|
|
268
|
+
# `harness_phase_changed`) ya no se encolan y salieron de la lista.
|
|
269
|
+
# [#44] La lista lleva los NOMBRES DEL CATÁLOGO v2 del hub (event_catalog.SCHEMAS):
|
|
270
|
+
# proteger de la evicción un tipo que el hub va a rechazar era absurdo. Salen
|
|
271
|
+
# `branch_drift` y `front_integration_reported` (no existen en el catálogo; el despacho
|
|
272
|
+
# los aparta a rejected/). Los tres alias 0.10.x del final (`checkpoint_created`,
|
|
273
|
+
# `verdict_reported`, `escalation_raised`) siguen protegidos SOLO porque la capa de
|
|
274
|
+
# compat del despacho los traduce y entrega — evictarlos perdería hechos entregables.
|
|
275
|
+
RUNTIME_OUTBOX_PROTECTED="checkpoint_recorded gate_verdict slice_escalated slice_submitted slice_archived handoff_recorded telemetry_gap wiring_checklist_seeded wiring_item_updated project_fact_updated checkpoint_created verdict_reported escalation_raised"
|
|
276
|
+
|
|
277
|
+
runtime_outbox_dir() {
|
|
278
|
+
echo "$(config_root)/.claude/state/outbox"
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
# runtime_enqueue_event <type> <payload_json> [slice_id] — encola un evento con
|
|
282
|
+
# client_event_id propio (idempotencia server-side) y aplica la cota tras encolar.
|
|
283
|
+
# [EP-OR-08-B] `slice_id` es OPCIONAL y se escribe como clave de PRIMER NIVEL del evento
|
|
284
|
+
# (no dentro de payload): `POST /events` lo exige por elemento para los tipos de ámbito
|
|
285
|
+
# slice. Sin él, el evento se manda sin la clave — el ámbito proyecto la omite a propósito.
|
|
286
|
+
runtime_enqueue_event() {
|
|
287
|
+
local type="$1" payload="$2" slice_id="${3:-}" dir
|
|
288
|
+
dir="$(runtime_outbox_dir)"; mkdir -p "$dir"
|
|
289
|
+
command -v python3 >/dev/null 2>&1 || return 1
|
|
290
|
+
python3 - "$dir" "$type" "$payload" "$slice_id" <<'PY' 2>/dev/null || return 1
|
|
291
|
+
import json,sys,os,tempfile,uuid,datetime
|
|
292
|
+
dir_,typ,payload,slice_id=sys.argv[1],sys.argv[2],sys.argv[3],sys.argv[4]
|
|
293
|
+
cid=str(uuid.uuid4())
|
|
294
|
+
ts=datetime.datetime.now(datetime.timezone.utc)
|
|
295
|
+
d={"client_event_id":cid,"type":typ,"payload":json.loads(payload),
|
|
296
|
+
"enqueued_at":ts.strftime("%Y-%m-%dT%H:%M:%S.%fZ")}
|
|
297
|
+
if slice_id:
|
|
298
|
+
d["slice_id"]=slice_id
|
|
299
|
+
fname=os.path.join(dir_, ts.strftime("%Y%m%d%H%M%S%f")+"-"+cid+".json")
|
|
300
|
+
fd,tmp=tempfile.mkstemp(dir=dir_,prefix=".outbox.",suffix=".tmp")
|
|
301
|
+
try:
|
|
302
|
+
with os.fdopen(fd,"w") as out:
|
|
303
|
+
json.dump(d,out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
|
|
304
|
+
os.replace(tmp,fname)
|
|
305
|
+
except Exception:
|
|
306
|
+
try: os.unlink(tmp)
|
|
307
|
+
except OSError: pass
|
|
308
|
+
raise
|
|
309
|
+
PY
|
|
310
|
+
runtime_outbox_enforce_cap >/dev/null
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
# runtime_outbox_enforce_cap — cota 5MB/72h. Descarta primero los eventos NO protegidos
|
|
314
|
+
# (más viejos primero); jamás descarta un tipo de RUNTIME_OUTBOX_PROTECTED. Si hubo
|
|
315
|
+
# descartes, encola un telemetry_gap con el payload EXACTO del catálogo v2 [#44]
|
|
316
|
+
# (TelemetryGap: {dropped>=1, window_h>0, reason<=2000}; extra="forbid" — el
|
|
317
|
+
# {dropped_count, dropped_types} de 0.10.x era rechazo garantizado): `dropped` es el
|
|
318
|
+
# conteo, `window_h` la ventana de la cota en horas y `reason` el motivo con los tipos
|
|
319
|
+
# descartados. Imprime en stdout el conteo de descartados (última línea).
|
|
320
|
+
runtime_outbox_enforce_cap() {
|
|
321
|
+
local dir; dir="$(runtime_outbox_dir)"
|
|
322
|
+
[ -d "$dir" ] || { echo 0; return 0; }
|
|
323
|
+
command -v python3 >/dev/null 2>&1 || { echo 0; return 0; }
|
|
324
|
+
python3 - "$dir" "$RUNTIME_OUTBOX_MAX_BYTES" "$RUNTIME_OUTBOX_MAX_AGE_S" "$RUNTIME_OUTBOX_PROTECTED" <<'PY' 2>/dev/null
|
|
325
|
+
import json,os,sys,time,tempfile,uuid,datetime
|
|
326
|
+
dir_,max_bytes,max_age,protected=sys.argv[1],int(sys.argv[2]),int(sys.argv[3]),set(sys.argv[4].split())
|
|
327
|
+
files=[]
|
|
328
|
+
total=0
|
|
329
|
+
now=time.time()
|
|
330
|
+
for name in os.listdir(dir_):
|
|
331
|
+
if not name.endswith(".json") or name.startswith("."):
|
|
332
|
+
continue
|
|
333
|
+
p=os.path.join(dir_,name)
|
|
334
|
+
try:
|
|
335
|
+
st=os.stat(p)
|
|
336
|
+
d=json.load(open(p))
|
|
337
|
+
except Exception:
|
|
338
|
+
continue
|
|
339
|
+
files.append((st.st_mtime,p,d.get("type","")))
|
|
340
|
+
total+=st.st_size
|
|
341
|
+
files.sort()
|
|
342
|
+
dropped=[]
|
|
343
|
+
over_size=total>max_bytes
|
|
344
|
+
for mtime,p,typ in files:
|
|
345
|
+
over_age=(now-mtime)>max_age
|
|
346
|
+
if not (over_size or over_age):
|
|
347
|
+
break
|
|
348
|
+
if typ in protected:
|
|
349
|
+
continue
|
|
350
|
+
try:
|
|
351
|
+
sz=os.path.getsize(p)
|
|
352
|
+
os.unlink(p)
|
|
353
|
+
total-=sz
|
|
354
|
+
dropped.append(typ)
|
|
355
|
+
except OSError:
|
|
356
|
+
pass
|
|
357
|
+
over_size=total>max_bytes
|
|
358
|
+
if dropped:
|
|
359
|
+
# Coalescer en un telemetry_gap YA en cola (no despachado) en vez de acumular uno
|
|
360
|
+
# por descarte: telemetry_gap es protegido (nunca se evicta), así que sin coalescer
|
|
361
|
+
# crece sin límite mientras la cola siga sobre la cota.
|
|
362
|
+
existing_gap_path=None
|
|
363
|
+
existing_gap=None
|
|
364
|
+
for name in os.listdir(dir_):
|
|
365
|
+
if not name.endswith(".json") or name.startswith("."):
|
|
366
|
+
continue
|
|
367
|
+
p=os.path.join(dir_,name)
|
|
368
|
+
try:
|
|
369
|
+
d=json.load(open(p))
|
|
370
|
+
except Exception:
|
|
371
|
+
continue
|
|
372
|
+
if d.get("type")=="telemetry_gap":
|
|
373
|
+
existing_gap_path=p
|
|
374
|
+
existing_gap=d
|
|
375
|
+
break
|
|
376
|
+
window_h=max_age/3600.0
|
|
377
|
+
def resumen(tipos):
|
|
378
|
+
counts={}
|
|
379
|
+
for t in tipos:
|
|
380
|
+
k=t or "desconocido"
|
|
381
|
+
counts[k]=counts.get(k,0)+1
|
|
382
|
+
return ", ".join("%s x%d" % kv for kv in sorted(counts.items()))
|
|
383
|
+
encabezado="evicción por cota de la outbox (%d bytes / %.0f h)" % (max_bytes, window_h)
|
|
384
|
+
if existing_gap_path:
|
|
385
|
+
payload=existing_gap.get("payload") or {}
|
|
386
|
+
# Compat 0.10.x: un gap ya encolado puede traer {dropped_count, dropped_types};
|
|
387
|
+
# se normaliza aquí al payload del catálogo antes de acumular.
|
|
388
|
+
prev_n=payload.get("dropped", payload.get("dropped_count", 0)) or 0
|
|
389
|
+
prev_types=payload.get("dropped_types") or []
|
|
390
|
+
prev_reason=payload.get("reason") if isinstance(payload.get("reason"),str) else ""
|
|
391
|
+
base=prev_reason or (("%s: %s" % (encabezado, resumen(prev_types))) if prev_types else "")
|
|
392
|
+
texto=("%s; %s" % (base, resumen(dropped))) if base else ("%s: %s" % (encabezado, resumen(dropped)))
|
|
393
|
+
gap=existing_gap
|
|
394
|
+
gap["payload"]={"dropped": prev_n+len(dropped), "window_h": window_h, "reason": texto[:2000]}
|
|
395
|
+
fname=existing_gap_path
|
|
396
|
+
fd,tmp=tempfile.mkstemp(dir=dir_,prefix=".outbox.",suffix=".tmp")
|
|
397
|
+
else:
|
|
398
|
+
cid=str(uuid.uuid4())
|
|
399
|
+
ts=datetime.datetime.now(datetime.timezone.utc)
|
|
400
|
+
gap={"client_event_id":cid,"type":"telemetry_gap",
|
|
401
|
+
"payload":{"dropped":len(dropped),"window_h":window_h,
|
|
402
|
+
"reason":("%s: %s" % (encabezado, resumen(dropped)))[:2000]},
|
|
403
|
+
"enqueued_at":ts.strftime("%Y-%m-%dT%H:%M:%S.%fZ")}
|
|
404
|
+
fname=os.path.join(dir_, ts.strftime("%Y%m%d%H%M%S%f")+"-"+cid+".json")
|
|
405
|
+
fd,tmp=tempfile.mkstemp(dir=dir_,prefix=".outbox.",suffix=".tmp")
|
|
406
|
+
try:
|
|
407
|
+
with os.fdopen(fd,"w") as out:
|
|
408
|
+
json.dump(gap,out,indent=2,ensure_ascii=False)
|
|
409
|
+
os.replace(tmp,fname)
|
|
410
|
+
except Exception:
|
|
411
|
+
# No re-lanzar: la evicción YA tuvo éxito: fallar aquí no debe ocultar ese
|
|
412
|
+
# resultado ni romper el contrato de stdout (print(len(dropped)) siempre corre).
|
|
413
|
+
try: os.unlink(tmp)
|
|
414
|
+
except OSError: pass
|
|
415
|
+
print(len(dropped))
|
|
416
|
+
PY
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
RUNTIME_OUTBOX_BACKOFF_SCHEDULE="1 5 30 300"
|
|
420
|
+
|
|
421
|
+
# runtime_dispatch_outbox — despacho best-effort de la cola. No-op en modo legacy.
|
|
422
|
+
# Respeta un backoff persistido (nunca bloquea reintentando en el hilo del hook). FIFO
|
|
423
|
+
# global (el agrupado por-agregado nace con el catálogo de eventos del sub-slice C).
|
|
424
|
+
runtime_dispatch_outbox() {
|
|
425
|
+
local mode; mode="$(runtime_mode)"
|
|
426
|
+
[ "$mode" = "legacy" ] && return 0
|
|
427
|
+
|
|
428
|
+
command -v python3 >/dev/null 2>&1 || return 1
|
|
429
|
+
|
|
430
|
+
local dir; dir="$(runtime_outbox_dir)"
|
|
431
|
+
[ -d "$dir" ] || return 0
|
|
432
|
+
|
|
433
|
+
local state_file="$dir/.dispatch-state.json"
|
|
434
|
+
local now next_ok=0 attempt=0
|
|
435
|
+
now="$(date +%s)"
|
|
436
|
+
if [ -f "$state_file" ]; then
|
|
437
|
+
next_ok="$(python3 -c "import json;print(json.load(open('$state_file')).get('next_attempt_epoch',0))" 2>/dev/null || echo 0)"
|
|
438
|
+
attempt="$(python3 -c "import json;print(json.load(open('$state_file')).get('attempt',0))" 2>/dev/null || echo 0)"
|
|
439
|
+
fi
|
|
440
|
+
[ "$now" -lt "$next_ok" ] && return 0
|
|
441
|
+
|
|
442
|
+
local files=()
|
|
443
|
+
while IFS= read -r f; do files+=("$f"); done < <(find "$dir" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' | sort)
|
|
444
|
+
[ ${#files[@]} -eq 0 ] && return 0
|
|
445
|
+
|
|
446
|
+
# [#37] El hub (`EventBatchIn`) exige el sobre {"events": [...]} — el array pelado era
|
|
447
|
+
# 422 en cada intento y la cola no drenaba jamás. Cada elemento va con `event_type`
|
|
448
|
+
# (`EventIn`); la clave interna de la outbox sigue siendo `type` y se traduce AQUÍ, al
|
|
449
|
+
# armar el cuerpo: los ficheros ya encolados en consumidores (0.10.x, clave `type`)
|
|
450
|
+
# drenan sin migración y el formato en disco no cambia (lo leen otros clientes).
|
|
451
|
+
# [#44] En este MISMO punto de salida vive la capa de compat de nombres/payloads: los
|
|
452
|
+
# productores ya encolan los nombres del catálogo v2, pero un consumidor 0.10.x dejó
|
|
453
|
+
# ficheros con los nombres viejos (`verdict_reported`, `checkpoint_created`,
|
|
454
|
+
# `escalation_raised`, `progress_note_recorded`) y payloads que `extra="forbid"`
|
|
455
|
+
# rechazaría — se traducen tipo Y claves al armar el cuerpo. Los tipos SIN equivalente
|
|
456
|
+
# en el catálogo (`tool_use_recorded`, `branch_drift`, `front_integration_reported`) se
|
|
457
|
+
# apartan localmente a rejected/ sin gastar red: mandarlos era rechazo garantizado.
|
|
458
|
+
local batch
|
|
459
|
+
batch="$(python3 - "$dir/rejected" "${files[@]}" <<'PY'
|
|
460
|
+
import json,sys,os
|
|
461
|
+
rejected_dir=sys.argv[1]
|
|
462
|
+
SIN_EQUIVALENTE={"tool_use_recorded","branch_drift","front_integration_reported"}
|
|
463
|
+
def traducir(t,p,slice_id):
|
|
464
|
+
p=dict(p)
|
|
465
|
+
if t=="verdict_reported":
|
|
466
|
+
# Un veredicto de RELEASE 0.10.x (payload con release_line, sin slice) no tiene
|
|
467
|
+
# camino por /events: se deja pasar tal cual y el hub lo rechaza con razón — la
|
|
468
|
+
# forma de slice se traduce a gate_verdict {gate, verdict, evidence…}.
|
|
469
|
+
t="gate_verdict"
|
|
470
|
+
if "status" in p and "verdict" not in p:
|
|
471
|
+
p["verdict"]=p.pop("status")
|
|
472
|
+
v=p.get("verdict")
|
|
473
|
+
if isinstance(v,str) and v.lower() in ("pass","fail"):
|
|
474
|
+
p["verdict"]=v.upper()
|
|
475
|
+
p={k:v for k,v in p.items() if k in ("gate","verdict","evidence","cause","details_ref")}
|
|
476
|
+
elif t=="checkpoint_created":
|
|
477
|
+
t="checkpoint_recorded"
|
|
478
|
+
if "note" in p and "summary" not in p:
|
|
479
|
+
p["summary"]=p.pop("note")
|
|
480
|
+
sid=p.pop("slice_id",None)
|
|
481
|
+
if sid and not slice_id:
|
|
482
|
+
slice_id=sid
|
|
483
|
+
p={k:v for k,v in p.items() if k in ("branch","commit_sha","summary")}
|
|
484
|
+
elif t=="escalation_raised":
|
|
485
|
+
t="slice_escalated"
|
|
486
|
+
pref=""
|
|
487
|
+
if p.get("gate"): pref+="[gate: %s] " % p["gate"]
|
|
488
|
+
if p.get("phase"): pref+="[fase: %s] " % p["phase"]
|
|
489
|
+
cause=p.get("cause") or (pref+str(p.get("reason") or ""))
|
|
490
|
+
p={"cause": (cause.strip() or "escalada sin causa registrada")[:2000]}
|
|
491
|
+
elif t=="progress_note_recorded":
|
|
492
|
+
t="progress_noted"
|
|
493
|
+
p={k:v for k,v in p.items() if k=="note"}
|
|
494
|
+
elif t in ("slice_submitted","slice_archived"):
|
|
495
|
+
p={} # el catálogo no admite ningún campo (extra="forbid")
|
|
496
|
+
elif t=="wiring_checklist_seeded":
|
|
497
|
+
items=[]
|
|
498
|
+
for it in p.get("items") or []:
|
|
499
|
+
if isinstance(it,dict) and it.get("item_id"):
|
|
500
|
+
items.append({"item_id":it["item_id"],"kind":it.get("kind") or "","ref":it.get("ref") or ""})
|
|
501
|
+
p={"items":items}
|
|
502
|
+
elif t=="telemetry_gap" and "dropped" not in p and "dropped_count" in p:
|
|
503
|
+
tipos=p.get("dropped_types") or []
|
|
504
|
+
p={"dropped":p.get("dropped_count") or 0,"window_h":72.0,
|
|
505
|
+
"reason":("evicción por cota de la outbox del cliente; tipos descartados: "
|
|
506
|
+
+", ".join(str(x) for x in tipos))[:2000]}
|
|
507
|
+
return t,p,slice_id
|
|
508
|
+
items=[]
|
|
509
|
+
for path in sys.argv[2:]:
|
|
510
|
+
try:
|
|
511
|
+
d=json.load(open(path))
|
|
512
|
+
except Exception:
|
|
513
|
+
continue
|
|
514
|
+
t=d.get("event_type") or d.get("type") or ""
|
|
515
|
+
if t in SIN_EQUIVALENTE:
|
|
516
|
+
try:
|
|
517
|
+
os.makedirs(rejected_dir,exist_ok=True)
|
|
518
|
+
os.replace(path,os.path.join(rejected_dir,os.path.basename(path)))
|
|
519
|
+
print("⚠ %r no existe en el catálogo v2 del hub: apartado a rejected/, no se enviará (issue #44)"
|
|
520
|
+
% t, file=sys.stderr)
|
|
521
|
+
except OSError:
|
|
522
|
+
pass
|
|
523
|
+
continue
|
|
524
|
+
t,p,slice_id=traducir(t,d.get("payload") or {},d.get("slice_id"))
|
|
525
|
+
ev={"client_event_id": d.get("client_event_id") or "",
|
|
526
|
+
"event_type": t,
|
|
527
|
+
"payload": p}
|
|
528
|
+
if slice_id:
|
|
529
|
+
ev["slice_id"]=slice_id
|
|
530
|
+
items.append(ev)
|
|
531
|
+
print(json.dumps({"events": items}, ensure_ascii=False))
|
|
532
|
+
PY
|
|
533
|
+
)"
|
|
534
|
+
|
|
535
|
+
# Si todo lo pendiente eran tipos sin equivalente (ya apartados), no hay nada que enviar.
|
|
536
|
+
local n_items
|
|
537
|
+
n_items="$(printf '%s' "$batch" | python3 -c 'import json,sys;print(len(json.load(sys.stdin).get("events") or []))' 2>/dev/null || echo 0)"
|
|
538
|
+
if [ "$n_items" = "0" ]; then
|
|
539
|
+
rm -f "$state_file"
|
|
540
|
+
return 0
|
|
541
|
+
fi
|
|
542
|
+
|
|
543
|
+
local resp status
|
|
544
|
+
resp="$(runtime_post "/events" "$batch")"
|
|
545
|
+
status="$(runtime_http_status)"
|
|
546
|
+
|
|
547
|
+
if [ "$status" = "200" ]; then
|
|
548
|
+
# [#37] Ack REAL del hub: {accepted:[cid], duplicates:[cid], rejected:[{client_event_id,
|
|
549
|
+
# reason}]}. Se conserva la lectura de la forma antigua {"results":[…]} (stubs/beta).
|
|
550
|
+
# Aceptado o duplicado => entregado, se borra. Rechazado POR ELEMENTO (fuera de catálogo
|
|
551
|
+
# o payload inválido) => reenviarlo es inútil: se aparta a rejected/ con su razón en
|
|
552
|
+
# stderr. Sin mención => se conserva para el próximo despacho (nunca se asume aceptado
|
|
553
|
+
# en silencio: perder un checkpoint/verdict/submit sin confirmación real viola la cota).
|
|
554
|
+
python3 - "$resp" "$dir/rejected" "${files[@]}" <<'PY'
|
|
555
|
+
import json,sys,os
|
|
556
|
+
resp=json.loads(sys.argv[1])
|
|
557
|
+
rejected_dir=sys.argv[2]
|
|
558
|
+
paths=sys.argv[3:]
|
|
559
|
+
status={}
|
|
560
|
+
reasons={}
|
|
561
|
+
if isinstance(resp,dict) and "results" in resp:
|
|
562
|
+
for r in resp.get("results") or []:
|
|
563
|
+
status[r.get("client_event_id")]=r.get("status")
|
|
564
|
+
elif isinstance(resp,dict):
|
|
565
|
+
for cid in resp.get("accepted") or []:
|
|
566
|
+
status[cid]="accepted"
|
|
567
|
+
for cid in resp.get("duplicates") or []:
|
|
568
|
+
status[cid]="accepted"
|
|
569
|
+
for r in resp.get("rejected") or []:
|
|
570
|
+
cid=r.get("client_event_id")
|
|
571
|
+
status[cid]="rejected"
|
|
572
|
+
reasons[cid]=r.get("reason") or "sin razón"
|
|
573
|
+
for p in paths:
|
|
574
|
+
try:
|
|
575
|
+
cid=json.load(open(p)).get("client_event_id")
|
|
576
|
+
except Exception:
|
|
577
|
+
continue
|
|
578
|
+
st=status.get(cid)
|
|
579
|
+
if st=="accepted":
|
|
580
|
+
try: os.unlink(p)
|
|
581
|
+
except OSError: pass
|
|
582
|
+
elif st=="rejected":
|
|
583
|
+
print("⚠ el hub rechazó el evento %s: %s — apartado a rejected/, no se reenviará"
|
|
584
|
+
% (cid, reasons.get(cid,"sin razón")), file=sys.stderr)
|
|
585
|
+
try:
|
|
586
|
+
os.makedirs(rejected_dir,exist_ok=True)
|
|
587
|
+
os.replace(p, os.path.join(rejected_dir, os.path.basename(p)))
|
|
588
|
+
except OSError:
|
|
589
|
+
pass
|
|
590
|
+
PY
|
|
591
|
+
rm -f "$state_file"
|
|
592
|
+
return 0
|
|
593
|
+
fi
|
|
594
|
+
|
|
595
|
+
# [#37] Un 4xx del LOTE (salvo 408/429) es un rechazo del contrato, no red caída:
|
|
596
|
+
# reintentarlo para siempre solo repite el mismo error y el usuario cree que reportó.
|
|
597
|
+
# Se loggea el cuerpo, el lote entero se aparta a rejected/ y NO se agenda backoff.
|
|
598
|
+
# 408 (timeout) y 429 (rate-limit) siguen siendo transitorios, igual que 5xx y 000.
|
|
599
|
+
case "$status" in
|
|
600
|
+
408|429) : ;;
|
|
601
|
+
4*)
|
|
602
|
+
local rejected_dir f
|
|
603
|
+
rejected_dir="$dir/rejected"
|
|
604
|
+
mkdir -p "$rejected_dir"
|
|
605
|
+
echo "⛔ el hub rechazó el lote de eventos (HTTP $status): $resp" >&2
|
|
606
|
+
echo " ${#files[@]} evento(s) movidos a $rejected_dir/ — NO se reintentarán." >&2
|
|
607
|
+
echo " Revisa el contrato del cliente (docs/runtime/protocolo-cliente-runtime.md) o" >&2
|
|
608
|
+
echo " actualiza el arnés; el trabajo local sigue (fail-open)." >&2
|
|
609
|
+
for f in "${files[@]}"; do
|
|
610
|
+
mv "$f" "$rejected_dir/" 2>/dev/null
|
|
611
|
+
done
|
|
612
|
+
rm -f "$state_file"
|
|
613
|
+
return 1
|
|
614
|
+
;;
|
|
615
|
+
esac
|
|
616
|
+
|
|
617
|
+
local schedule idx max_idx delay
|
|
618
|
+
read -ra schedule <<< "$RUNTIME_OUTBOX_BACKOFF_SCHEDULE"
|
|
619
|
+
idx=$attempt
|
|
620
|
+
max_idx=$(( ${#schedule[@]} - 1 ))
|
|
621
|
+
[ "$idx" -gt "$max_idx" ] && idx=$max_idx
|
|
622
|
+
delay="${schedule[$idx]}"
|
|
623
|
+
python3 -c "import json; json.dump({'next_attempt_epoch': $now + $delay, 'attempt': $((attempt + 1))}, open('$state_file','w'))"
|
|
624
|
+
return 1
|
|
625
|
+
}
|