@trycore/spec-build-harness 0.8.5 → 0.10.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 +59 -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 +22 -0
- package/dist/commands/doctor.js +42 -0
- package/dist/commands/init.js +84 -1
- package/dist/commands/migrate.js +48 -0
- package/dist/commands/status.js +34 -0
- package/dist/lib/normalize.js +276 -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 +46 -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 +136 -0
- package/docs/runtime/plan-migracion-harness-v0.9.md +11 -0
- package/docs/runtime/protocolo-cliente-runtime.md +109 -34
- 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 +75 -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 +465 -0
- package/hooks/build/lib/runtime-ops.sh +221 -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 +164 -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 +877 -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/lib/front-plan.py +4 -0
- package/scripts/lib/graph-bundle.py +133 -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 +853 -0
- package/scripts/tests/test-install.sh +57 -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 +847 -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 +25 -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,465 @@
|
|
|
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 sigue siendo `tool_use_recorded` y `progress_note_recorded`.
|
|
265
|
+
RUNTIME_OUTBOX_PROTECTED="checkpoint_created verdict_reported slice_submitted handoff_recorded telemetry_gap branch_drift slice_archived wiring_checklist_seeded wiring_item_updated escalation_raised scaffold_confirmed design_source_declared project_kind_declared foundation_declared harness_phase_changed front_integration_reported"
|
|
266
|
+
|
|
267
|
+
runtime_outbox_dir() {
|
|
268
|
+
echo "$(config_root)/.claude/state/outbox"
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
# runtime_enqueue_event <type> <payload_json> [slice_id] — encola un evento con
|
|
272
|
+
# client_event_id propio (idempotencia server-side) y aplica la cota tras encolar.
|
|
273
|
+
# [EP-OR-08-B] `slice_id` es OPCIONAL y se escribe como clave de PRIMER NIVEL del evento
|
|
274
|
+
# (no dentro de payload): `POST /events` lo exige por elemento para los tipos de ámbito
|
|
275
|
+
# slice. Sin él, el evento se manda sin la clave — el ámbito proyecto la omite a propósito.
|
|
276
|
+
runtime_enqueue_event() {
|
|
277
|
+
local type="$1" payload="$2" slice_id="${3:-}" dir
|
|
278
|
+
dir="$(runtime_outbox_dir)"; mkdir -p "$dir"
|
|
279
|
+
command -v python3 >/dev/null 2>&1 || return 1
|
|
280
|
+
python3 - "$dir" "$type" "$payload" "$slice_id" <<'PY' 2>/dev/null || return 1
|
|
281
|
+
import json,sys,os,tempfile,uuid,datetime
|
|
282
|
+
dir_,typ,payload,slice_id=sys.argv[1],sys.argv[2],sys.argv[3],sys.argv[4]
|
|
283
|
+
cid=str(uuid.uuid4())
|
|
284
|
+
ts=datetime.datetime.now(datetime.timezone.utc)
|
|
285
|
+
d={"client_event_id":cid,"type":typ,"payload":json.loads(payload),
|
|
286
|
+
"enqueued_at":ts.strftime("%Y-%m-%dT%H:%M:%S.%fZ")}
|
|
287
|
+
if slice_id:
|
|
288
|
+
d["slice_id"]=slice_id
|
|
289
|
+
fname=os.path.join(dir_, ts.strftime("%Y%m%d%H%M%S%f")+"-"+cid+".json")
|
|
290
|
+
fd,tmp=tempfile.mkstemp(dir=dir_,prefix=".outbox.",suffix=".tmp")
|
|
291
|
+
try:
|
|
292
|
+
with os.fdopen(fd,"w") as out:
|
|
293
|
+
json.dump(d,out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
|
|
294
|
+
os.replace(tmp,fname)
|
|
295
|
+
except Exception:
|
|
296
|
+
try: os.unlink(tmp)
|
|
297
|
+
except OSError: pass
|
|
298
|
+
raise
|
|
299
|
+
PY
|
|
300
|
+
runtime_outbox_enforce_cap >/dev/null
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
# runtime_outbox_enforce_cap — cota 5MB/72h. Descarta primero los eventos NO protegidos
|
|
304
|
+
# (más viejos primero); jamás descarta checkpoint/verdict/submit/handoff/telemetry_gap/
|
|
305
|
+
# branch_drift. Si hubo descartes, encola un telemetry_gap describiéndolos. Imprime en
|
|
306
|
+
# stdout el conteo de descartados (última línea).
|
|
307
|
+
runtime_outbox_enforce_cap() {
|
|
308
|
+
local dir; dir="$(runtime_outbox_dir)"
|
|
309
|
+
[ -d "$dir" ] || { echo 0; return 0; }
|
|
310
|
+
command -v python3 >/dev/null 2>&1 || { echo 0; return 0; }
|
|
311
|
+
python3 - "$dir" "$RUNTIME_OUTBOX_MAX_BYTES" "$RUNTIME_OUTBOX_MAX_AGE_S" "$RUNTIME_OUTBOX_PROTECTED" <<'PY' 2>/dev/null
|
|
312
|
+
import json,os,sys,time,tempfile,uuid,datetime
|
|
313
|
+
dir_,max_bytes,max_age,protected=sys.argv[1],int(sys.argv[2]),int(sys.argv[3]),set(sys.argv[4].split())
|
|
314
|
+
files=[]
|
|
315
|
+
total=0
|
|
316
|
+
now=time.time()
|
|
317
|
+
for name in os.listdir(dir_):
|
|
318
|
+
if not name.endswith(".json") or name.startswith("."):
|
|
319
|
+
continue
|
|
320
|
+
p=os.path.join(dir_,name)
|
|
321
|
+
try:
|
|
322
|
+
st=os.stat(p)
|
|
323
|
+
d=json.load(open(p))
|
|
324
|
+
except Exception:
|
|
325
|
+
continue
|
|
326
|
+
files.append((st.st_mtime,p,d.get("type","")))
|
|
327
|
+
total+=st.st_size
|
|
328
|
+
files.sort()
|
|
329
|
+
dropped=[]
|
|
330
|
+
over_size=total>max_bytes
|
|
331
|
+
for mtime,p,typ in files:
|
|
332
|
+
over_age=(now-mtime)>max_age
|
|
333
|
+
if not (over_size or over_age):
|
|
334
|
+
break
|
|
335
|
+
if typ in protected:
|
|
336
|
+
continue
|
|
337
|
+
try:
|
|
338
|
+
sz=os.path.getsize(p)
|
|
339
|
+
os.unlink(p)
|
|
340
|
+
total-=sz
|
|
341
|
+
dropped.append(typ)
|
|
342
|
+
except OSError:
|
|
343
|
+
pass
|
|
344
|
+
over_size=total>max_bytes
|
|
345
|
+
if dropped:
|
|
346
|
+
# Coalescer en un telemetry_gap YA en cola (no despachado) en vez de acumular uno
|
|
347
|
+
# por descarte: telemetry_gap es protegido (nunca se evicta), así que sin coalescer
|
|
348
|
+
# crece sin límite mientras la cola siga sobre la cota.
|
|
349
|
+
existing_gap_path=None
|
|
350
|
+
existing_gap=None
|
|
351
|
+
for name in os.listdir(dir_):
|
|
352
|
+
if not name.endswith(".json") or name.startswith("."):
|
|
353
|
+
continue
|
|
354
|
+
p=os.path.join(dir_,name)
|
|
355
|
+
try:
|
|
356
|
+
d=json.load(open(p))
|
|
357
|
+
except Exception:
|
|
358
|
+
continue
|
|
359
|
+
if d.get("type")=="telemetry_gap":
|
|
360
|
+
existing_gap_path=p
|
|
361
|
+
existing_gap=d
|
|
362
|
+
break
|
|
363
|
+
if existing_gap_path:
|
|
364
|
+
payload=existing_gap.get("payload") or {}
|
|
365
|
+
payload["dropped_count"]=payload.get("dropped_count",0)+len(dropped)
|
|
366
|
+
payload["dropped_types"]=payload.get("dropped_types",[])+dropped
|
|
367
|
+
gap=existing_gap
|
|
368
|
+
gap["payload"]=payload
|
|
369
|
+
fname=existing_gap_path
|
|
370
|
+
fd,tmp=tempfile.mkstemp(dir=dir_,prefix=".outbox.",suffix=".tmp")
|
|
371
|
+
else:
|
|
372
|
+
cid=str(uuid.uuid4())
|
|
373
|
+
ts=datetime.datetime.now(datetime.timezone.utc)
|
|
374
|
+
gap={"client_event_id":cid,"type":"telemetry_gap",
|
|
375
|
+
"payload":{"dropped_count":len(dropped),"dropped_types":dropped},
|
|
376
|
+
"enqueued_at":ts.strftime("%Y-%m-%dT%H:%M:%S.%fZ")}
|
|
377
|
+
fname=os.path.join(dir_, ts.strftime("%Y%m%d%H%M%S%f")+"-"+cid+".json")
|
|
378
|
+
fd,tmp=tempfile.mkstemp(dir=dir_,prefix=".outbox.",suffix=".tmp")
|
|
379
|
+
try:
|
|
380
|
+
with os.fdopen(fd,"w") as out:
|
|
381
|
+
json.dump(gap,out,indent=2,ensure_ascii=False)
|
|
382
|
+
os.replace(tmp,fname)
|
|
383
|
+
except Exception:
|
|
384
|
+
# No re-lanzar: la evicción YA tuvo éxito: fallar aquí no debe ocultar ese
|
|
385
|
+
# resultado ni romper el contrato de stdout (print(len(dropped)) siempre corre).
|
|
386
|
+
try: os.unlink(tmp)
|
|
387
|
+
except OSError: pass
|
|
388
|
+
print(len(dropped))
|
|
389
|
+
PY
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
RUNTIME_OUTBOX_BACKOFF_SCHEDULE="1 5 30 300"
|
|
393
|
+
|
|
394
|
+
# runtime_dispatch_outbox — despacho best-effort de la cola. No-op en modo legacy.
|
|
395
|
+
# Respeta un backoff persistido (nunca bloquea reintentando en el hilo del hook). FIFO
|
|
396
|
+
# global (el agrupado por-agregado nace con el catálogo de eventos del sub-slice C).
|
|
397
|
+
runtime_dispatch_outbox() {
|
|
398
|
+
local mode; mode="$(runtime_mode)"
|
|
399
|
+
[ "$mode" = "legacy" ] && return 0
|
|
400
|
+
|
|
401
|
+
command -v python3 >/dev/null 2>&1 || return 1
|
|
402
|
+
|
|
403
|
+
local dir; dir="$(runtime_outbox_dir)"
|
|
404
|
+
[ -d "$dir" ] || return 0
|
|
405
|
+
|
|
406
|
+
local state_file="$dir/.dispatch-state.json"
|
|
407
|
+
local now next_ok=0 attempt=0
|
|
408
|
+
now="$(date +%s)"
|
|
409
|
+
if [ -f "$state_file" ]; then
|
|
410
|
+
next_ok="$(python3 -c "import json;print(json.load(open('$state_file')).get('next_attempt_epoch',0))" 2>/dev/null || echo 0)"
|
|
411
|
+
attempt="$(python3 -c "import json;print(json.load(open('$state_file')).get('attempt',0))" 2>/dev/null || echo 0)"
|
|
412
|
+
fi
|
|
413
|
+
[ "$now" -lt "$next_ok" ] && return 0
|
|
414
|
+
|
|
415
|
+
local files=()
|
|
416
|
+
while IFS= read -r f; do files+=("$f"); done < <(find "$dir" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' | sort)
|
|
417
|
+
[ ${#files[@]} -eq 0 ] && return 0
|
|
418
|
+
|
|
419
|
+
local batch
|
|
420
|
+
batch="$(python3 - "${files[@]}" <<'PY'
|
|
421
|
+
import json,sys
|
|
422
|
+
items=[]
|
|
423
|
+
for p in sys.argv[1:]:
|
|
424
|
+
try: items.append(json.load(open(p)))
|
|
425
|
+
except Exception: pass
|
|
426
|
+
print(json.dumps(items))
|
|
427
|
+
PY
|
|
428
|
+
)"
|
|
429
|
+
|
|
430
|
+
local resp status
|
|
431
|
+
resp="$(runtime_post "/events" "$batch")"
|
|
432
|
+
status="$(runtime_http_status)"
|
|
433
|
+
|
|
434
|
+
if [ "$status" = "200" ]; then
|
|
435
|
+
python3 - "$resp" "${files[@]}" <<'PY'
|
|
436
|
+
import json,sys,os
|
|
437
|
+
resp=json.loads(sys.argv[1])
|
|
438
|
+
paths=sys.argv[2:]
|
|
439
|
+
results={r.get("client_event_id"):r.get("status") for r in resp.get("results",[])}
|
|
440
|
+
for p in paths:
|
|
441
|
+
try:
|
|
442
|
+
cid=json.load(open(p)).get("client_event_id")
|
|
443
|
+
except Exception:
|
|
444
|
+
continue
|
|
445
|
+
# Sin mención en `results` => se conserva para el próximo despacho (nunca se asume
|
|
446
|
+
# aceptado en silencio: perder un checkpoint/verdict/submit sin confirmación real
|
|
447
|
+
# viola el contrato de la cota, Task 8).
|
|
448
|
+
st=results.get(cid)
|
|
449
|
+
if st in ("accepted","rejected"):
|
|
450
|
+
try: os.unlink(p)
|
|
451
|
+
except OSError: pass
|
|
452
|
+
PY
|
|
453
|
+
rm -f "$state_file"
|
|
454
|
+
return 0
|
|
455
|
+
fi
|
|
456
|
+
|
|
457
|
+
local schedule idx max_idx delay
|
|
458
|
+
read -ra schedule <<< "$RUNTIME_OUTBOX_BACKOFF_SCHEDULE"
|
|
459
|
+
idx=$attempt
|
|
460
|
+
max_idx=$(( ${#schedule[@]} - 1 ))
|
|
461
|
+
[ "$idx" -gt "$max_idx" ] && idx=$max_idx
|
|
462
|
+
delay="${schedule[$idx]}"
|
|
463
|
+
python3 -c "import json; json.dump({'next_attempt_epoch': $now + $delay, 'attempt': $((attempt + 1))}, open('$state_file','w'))"
|
|
464
|
+
return 1
|
|
465
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# runtime-ops.sh — base común de las OPERACIONES DE DOMINIO que conducen las skills
|
|
3
|
+
# [EP-OR-08-C]. Los hooks (sub-slice B) observan; esto transiciona: claim, veredictos,
|
|
4
|
+
# checkpoints, submit, archive y hechos de proyecto. Lo usan hooks/build/slice-ops.sh y
|
|
5
|
+
# hooks/build/release-ops.sh, que son INVOCADOS por las skills (no hooks registrados).
|
|
6
|
+
#
|
|
7
|
+
# Aquí vive el ÚNICO sitio del cliente bash donde se escriben rutas del runtime: si el
|
|
8
|
+
# re-volcado de OpenAPI (EP-OR-13) mueve un endpoint, se toca este fichero y nada más.
|
|
9
|
+
#
|
|
10
|
+
# Diseño fail-open, igual que runtime-client.sh: ninguna función lanza; ante red caída,
|
|
11
|
+
# python3 ausente o JSON corrupto se degrada a un valor seguro o a un rc distinguible.
|
|
12
|
+
|
|
13
|
+
source "$(dirname "${BASH_SOURCE[0]}")/projection.sh"
|
|
14
|
+
source "$(dirname "${BASH_SOURCE[0]}")/agent-context.sh"
|
|
15
|
+
|
|
16
|
+
# ── Códigos de salida (contrato con las skills; la prosa ramifica sobre estos números) ──
|
|
17
|
+
OPS_RC_OK=0
|
|
18
|
+
OPS_RC_USAGE=2
|
|
19
|
+
OPS_RC_LEGACY=3
|
|
20
|
+
OPS_RC_NO_SLICE=4
|
|
21
|
+
OPS_RC_OFFLINE=5
|
|
22
|
+
OPS_RC_REJECTED=6
|
|
23
|
+
OPS_RC_NO_WORK=7
|
|
24
|
+
|
|
25
|
+
# Tope de texto libre que el cliente manda al servidor (evidencias, notas): el protocolo §8
|
|
26
|
+
# dice que el servidor rechaza payloads sobredimensionados; se trunca aquí, marcándolo.
|
|
27
|
+
OPS_MAX_TEXT=4000
|
|
28
|
+
|
|
29
|
+
# Gates canónicos (se validan en el cliente: un nombre inventado por el modelo no llega
|
|
30
|
+
# a la red — compuerta mecánica barata, sin round-trip).
|
|
31
|
+
OPS_SLICE_GATES="dor coherence_link tdd journey_smoke fidelity api data wiring_verified dod"
|
|
32
|
+
OPS_RELEASE_GATES="security smell ux coherence stack_arch integration"
|
|
33
|
+
|
|
34
|
+
# ── Tabla de rutas ──
|
|
35
|
+
OPS_PATH_CLAIM="/tasks/next"
|
|
36
|
+
OPS_PATH_CHECKPOINTS="/checkpoints"
|
|
37
|
+
ops_path_verdicts() { echo "/slices/$1/verdicts"; }
|
|
38
|
+
ops_path_submit() { echo "/slices/$1/submit"; }
|
|
39
|
+
ops_path_release_verdicts() { echo "/releases/$1/verdicts"; }
|
|
40
|
+
ops_path_front_integration() { echo "/fronts/$1/members/$2/integration"; }
|
|
41
|
+
ops_path_mirror() { echo "/projects/$1/mirror/transitions"; }
|
|
42
|
+
ops_path_proposals() { echo "/projects/$1/context/agent-proposals"; }
|
|
43
|
+
|
|
44
|
+
ops_mode() { runtime_mode; }
|
|
45
|
+
|
|
46
|
+
# ops_slice_id — id del slice activo según la caché de proyección ("" si no hay).
|
|
47
|
+
ops_slice_id() { projection_get active_slice.slice_id ""; }
|
|
48
|
+
|
|
49
|
+
# ops_urlenc <texto> — percent-encoding para segmentos de ruta (una línea de release puede
|
|
50
|
+
# llevar espacios o barras). Sin python3 devuelve el texto tal cual: peor URL, nunca excepción.
|
|
51
|
+
ops_urlenc() {
|
|
52
|
+
command -v python3 >/dev/null 2>&1 || { printf '%s' "$1"; return; }
|
|
53
|
+
printf '%s' "$1" | python3 -c 'import sys,urllib.parse; sys.stdout.write(urllib.parse.quote(sys.stdin.read(), safe=""))' 2>/dev/null || printf '%s' "$1"
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# ops_json_str <texto> — imprime el texto como cadena JSON (comillas incluidas). El texto
|
|
57
|
+
# viaja por STDIN, nunca por argv: así ni ARG_MAX ni el quoting de bash lo limitan.
|
|
58
|
+
ops_json_str() {
|
|
59
|
+
command -v python3 >/dev/null 2>&1 || { printf '""'; return; }
|
|
60
|
+
printf '%s' "$1" | python3 -c 'import json,sys; sys.stdout.write(json.dumps(sys.stdin.read(), ensure_ascii=False))' 2>/dev/null || printf '""'
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# ops_json_str_file <fichero> [max=OPS_MAX_TEXT] — igual, leyendo de un fichero y truncando
|
|
64
|
+
# al tope (marcado en el propio texto para que el servidor y el humano lo vean).
|
|
65
|
+
ops_json_str_file() {
|
|
66
|
+
local f="$1" max="${2:-$OPS_MAX_TEXT}"
|
|
67
|
+
[ -f "$f" ] || { printf '""'; return; }
|
|
68
|
+
command -v python3 >/dev/null 2>&1 || { printf '""'; return; }
|
|
69
|
+
python3 - "$f" "$max" <<'PY' 2>/dev/null || printf '""'
|
|
70
|
+
import json,sys
|
|
71
|
+
try:
|
|
72
|
+
t=open(sys.argv[1], encoding="utf-8", errors="replace").read()
|
|
73
|
+
except Exception:
|
|
74
|
+
t=""
|
|
75
|
+
m=int(sys.argv[2])
|
|
76
|
+
if len(t) > m:
|
|
77
|
+
t = t[:m] + "\n…[truncado por el cliente]"
|
|
78
|
+
sys.stdout.write(json.dumps(t, ensure_ascii=False))
|
|
79
|
+
PY
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# ops_tmpjson <json> — materializa un JSON en un fichero temporal e imprime su ruta.
|
|
83
|
+
# Los payloads viajan a python3 y a curl POR FICHERO (regla dura del repo: nunca por argv).
|
|
84
|
+
ops_tmpjson() {
|
|
85
|
+
local f
|
|
86
|
+
f="$(mktemp)" || return 1
|
|
87
|
+
printf '%s' "$1" > "$f"
|
|
88
|
+
echo "$f"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# ops_context_hashes — sha256 EFECTIVO local de cada archivo gobernado del lock
|
|
92
|
+
# (protocolo §3 regla 3: el servidor detecta drift/manipulación local comparándolos con los
|
|
93
|
+
# suyos). Se hashea el fichero real, NUNCA se repite el sha del lock: repetirlo convertiría
|
|
94
|
+
# el reporte en un espejo del servidor y el drift sería indetectable. Fichero ausente => null.
|
|
95
|
+
ops_context_hashes() {
|
|
96
|
+
local lock root
|
|
97
|
+
lock="$(runtime_lock_path)"
|
|
98
|
+
root="$(config_root)"
|
|
99
|
+
command -v python3 >/dev/null 2>&1 || { echo "[]"; return; }
|
|
100
|
+
python3 - "$lock" "$root" <<'PY' 2>/dev/null || echo "[]"
|
|
101
|
+
import json,sys,os,hashlib
|
|
102
|
+
lock,root=sys.argv[1],sys.argv[2]
|
|
103
|
+
try:
|
|
104
|
+
files=json.load(open(lock)).get("files") or []
|
|
105
|
+
except Exception:
|
|
106
|
+
files=[]
|
|
107
|
+
out=[]
|
|
108
|
+
for f in files:
|
|
109
|
+
if not isinstance(f,dict):
|
|
110
|
+
continue
|
|
111
|
+
p=f.get("path")
|
|
112
|
+
if not isinstance(p,str) or not p:
|
|
113
|
+
continue
|
|
114
|
+
full=os.path.join(root,".claude",p)
|
|
115
|
+
sha=None
|
|
116
|
+
try:
|
|
117
|
+
h=hashlib.sha256()
|
|
118
|
+
with open(full,"rb") as fh:
|
|
119
|
+
for chunk in iter(lambda: fh.read(65536), b""):
|
|
120
|
+
h.update(chunk)
|
|
121
|
+
sha=h.hexdigest()
|
|
122
|
+
except Exception:
|
|
123
|
+
sha=None
|
|
124
|
+
out.append({"path":p,"sha256":sha})
|
|
125
|
+
print(json.dumps(out,ensure_ascii=False))
|
|
126
|
+
PY
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
# ops_local_slice_ref — identidad del slice según el FICHERO local. Solo tiene sentido en
|
|
130
|
+
# modo `dual`, donde el fichero es primario y el runtime crea la identidad por el mismo
|
|
131
|
+
# camino que el import (spec §4.1-D). En `runtime` no se usa: allí manda la caché.
|
|
132
|
+
# Imprime {} si no hay fichero, no hay python3 o no hay slice activo — nunca lanza.
|
|
133
|
+
ops_local_slice_ref() {
|
|
134
|
+
local p
|
|
135
|
+
p="$(config_root)/.claude/state/build-state.json"
|
|
136
|
+
[ -f "$p" ] || { echo "{}"; return; }
|
|
137
|
+
command -v python3 >/dev/null 2>&1 || { echo "{}"; return; }
|
|
138
|
+
python3 - "$p" <<'PY' 2>/dev/null || echo "{}"
|
|
139
|
+
import json,sys
|
|
140
|
+
try:
|
|
141
|
+
d=json.load(open(sys.argv[1]))
|
|
142
|
+
s=d.get("active_slice") or {}
|
|
143
|
+
except Exception:
|
|
144
|
+
s={}
|
|
145
|
+
print(json.dumps({"epic_code": s.get("epica"),
|
|
146
|
+
"openspec_change": s.get("openspec_change"),
|
|
147
|
+
"branch": s.get("branch"),
|
|
148
|
+
"phase": s.get("phase")}, ensure_ascii=False))
|
|
149
|
+
PY
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
# ops_enqueue_from_file <type> <payload_file> [slice_id] — encola un evento cuyo payload
|
|
153
|
+
# puede ser grande (checklist de wiring, evidencia, contenido propuesto): el payload se lee
|
|
154
|
+
# del FICHERO dentro de python3, así que no pasa por argv (a diferencia de
|
|
155
|
+
# runtime_enqueue_event, que sigue sirviendo para payloads pequeños).
|
|
156
|
+
ops_enqueue_from_file() {
|
|
157
|
+
local type="$1" pfile="$2" slice_id="${3:-}" dir
|
|
158
|
+
dir="$(runtime_outbox_dir)"
|
|
159
|
+
mkdir -p "$dir"
|
|
160
|
+
command -v python3 >/dev/null 2>&1 || return 1
|
|
161
|
+
python3 - "$dir" "$type" "$pfile" "$slice_id" <<'PY' 2>/dev/null || return 1
|
|
162
|
+
import json,sys,os,tempfile,uuid,datetime
|
|
163
|
+
dir_,typ,pfile,slice_id=sys.argv[1],sys.argv[2],sys.argv[3],sys.argv[4]
|
|
164
|
+
try:
|
|
165
|
+
payload=json.load(open(pfile))
|
|
166
|
+
except Exception:
|
|
167
|
+
payload={}
|
|
168
|
+
cid=str(uuid.uuid4())
|
|
169
|
+
ts=datetime.datetime.now(datetime.timezone.utc)
|
|
170
|
+
d={"client_event_id":cid,"type":typ,"payload":payload,
|
|
171
|
+
"enqueued_at":ts.strftime("%Y-%m-%dT%H:%M:%S.%fZ")}
|
|
172
|
+
if slice_id:
|
|
173
|
+
d["slice_id"]=slice_id
|
|
174
|
+
fname=os.path.join(dir_, ts.strftime("%Y%m%d%H%M%S%f")+"-"+cid+".json")
|
|
175
|
+
fd,tmp=tempfile.mkstemp(dir=dir_,prefix=".outbox.",suffix=".tmp")
|
|
176
|
+
try:
|
|
177
|
+
with os.fdopen(fd,"w") as out:
|
|
178
|
+
json.dump(d,out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
|
|
179
|
+
os.replace(tmp,fname)
|
|
180
|
+
except Exception:
|
|
181
|
+
try: os.unlink(tmp)
|
|
182
|
+
except OSError: pass
|
|
183
|
+
raise
|
|
184
|
+
PY
|
|
185
|
+
runtime_outbox_enforce_cap >/dev/null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# ops_mirror <kind> <payload_file> — ingesta espejo del modo `dual` (spec §4.1-D). El
|
|
189
|
+
# fichero local sigue siendo primario: un rechazo del reducer NO bloquea el trabajo, queda
|
|
190
|
+
# como discrepancia del comparador. rc 0 aceptado · 5 no se pudo (red) · 6 rechazado.
|
|
191
|
+
ops_mirror() {
|
|
192
|
+
local kind="$1" pfile="$2" pid body resp status tmp
|
|
193
|
+
pid="$(runtime_field project_id)"
|
|
194
|
+
[ -n "$pid" ] || return 5
|
|
195
|
+
command -v python3 >/dev/null 2>&1 || return 5
|
|
196
|
+
tmp="$(mktemp)" || return 5
|
|
197
|
+
python3 - "$kind" "$pfile" > "$tmp" <<'PY' 2>/dev/null
|
|
198
|
+
import json,sys,datetime
|
|
199
|
+
kind,pfile=sys.argv[1],sys.argv[2]
|
|
200
|
+
try:
|
|
201
|
+
payload=json.load(open(pfile))
|
|
202
|
+
except Exception:
|
|
203
|
+
payload={}
|
|
204
|
+
print(json.dumps({"kind":kind,"payload":payload,"origin":"mirror",
|
|
205
|
+
"occurred_at":datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")},
|
|
206
|
+
ensure_ascii=False))
|
|
207
|
+
PY
|
|
208
|
+
body="$(cat "$tmp" 2>/dev/null)"
|
|
209
|
+
rm -f "$tmp"
|
|
210
|
+
[ -n "$body" ] || return 5
|
|
211
|
+
resp="$(runtime_post "$(ops_path_mirror "$pid")" "$body")"
|
|
212
|
+
status="$(runtime_http_status)"
|
|
213
|
+
case "$status" in
|
|
214
|
+
200|201|202) return 0 ;;
|
|
215
|
+
409|422)
|
|
216
|
+
echo "⚠ el espejo rechazó «${kind}»: $resp" >&2
|
|
217
|
+
echo " (queda como discrepancia del comparador dual; el trabajo local NO se bloquea)" >&2
|
|
218
|
+
return 6 ;;
|
|
219
|
+
*) return 5 ;;
|
|
220
|
+
esac
|
|
221
|
+
}
|