feat(tools): add cockpit TUIs, ops scripts, CAD tooling, Mistral agents
Cockpit (54 scripts): - Intelligence/agent TUIs, Mistral agents/studio TUI, e2e agent tests - Mesh health, sync preflight, dirtyset cleanup, SSH healthcheck - Mascarade runtime health, incident registry, dispatch mesh - Machine registry, log ops, product contract audit - Yiacad refonte/backend/proofs/operator TUIs - Sentinelle cron, daily alignment, operator summaries - Fab package, PCB AI fab, artifact WMS index TUIs Ops: - Deploy mascarade tower runtime - Full operator lane sync CAD: - KiCad yiacad plugin, host MCP integration - CAD stack scripts Other: - Mistral finetune tools - Evals framework - Mesh contract checker - Updated AI scripts (zeroclaw, integrations) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,9 +26,21 @@ bash tools/ai/zeroclaw_integrations_status.sh --json
|
||||
bash tools/ai/zeroclaw_integrations_import_n8n.sh --json
|
||||
```
|
||||
|
||||
Current runtime bootstrap:
|
||||
|
||||
- `bash tools/ai/zeroclaw_integrations_up.sh` auto-provisions the local `mascarade-n8n` container if Docker is running but the container is still missing.
|
||||
- Official runtime basis: `docker.n8n.io/n8nio/n8n` on port `5678`, with a persistent volume per container name.
|
||||
- Readiness is validated through `http://127.0.0.1:5678/healthz` in addition to the editor root URL, because first boot and editor startup can lag behind the health endpoint.
|
||||
- Workflow activation path: reuse the tracked workflow when it is already active, otherwise prefer `n8n publish:workflow --id=<ID>` after import, with legacy fallback `n8n update:workflow --id=<ID> --active=true`.
|
||||
- Activation proof now checks `n8n list:workflow --active=true --onlyId`, so `active=true` is no longer assumed.
|
||||
- Runtime scripts fail fast on local Docker CLI timeouts and report a blocker instead of hanging indefinitely.
|
||||
- If the local DB already contains the tracked workflow with the exact stored nodes/connections and `active=true`, the import script now skips the CLI path and returns success immediately.
|
||||
- Recovery note: if local SQLite state is corrupted or still contains an obsolete workflow revision, preserve the volume backup and reset only the n8n DB files before reprovisioning the container.
|
||||
|
||||
Tracked smoke artifact:
|
||||
|
||||
- `tools/ai/integrations/n8n/kill_life_smoke_workflow.json`
|
||||
- current trigger: yearly `Schedule Trigger`, so the workflow stays activable without creating noisy frequent runs
|
||||
|
||||
Historical workflow names:
|
||||
|
||||
|
||||
@@ -4,10 +4,19 @@
|
||||
"active": false,
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "manual-trigger",
|
||||
"name": "Manual Trigger",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"parameters": {
|
||||
"rule": {
|
||||
"interval": [
|
||||
{
|
||||
"field": "cronExpression",
|
||||
"expression": "0 0 0 1 1 *"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "yearly-trigger",
|
||||
"name": "Yearly Trigger",
|
||||
"type": "n8n-nodes-base.scheduleTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
260,
|
||||
@@ -27,7 +36,7 @@
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Manual Trigger": {
|
||||
"Yearly Trigger": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
|
||||
@@ -4,8 +4,8 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="${ZEROCLAW_ROOT_DIR:-$(cd -- "$SCRIPT_DIR/../.." && pwd)}"
|
||||
ZEROCLAW_BIN="${ZEROCLAW_BIN:-$ROOT_DIR/zeroclaw/target/release/zeroclaw}"
|
||||
RTC_REPO="${ZEROCLAW_RTC_REPO:-$HOME/RTC_BL_PHONE}"
|
||||
ZACUS_REPO="${ZEROCLAW_ZACUS_REPO:-$HOME/le-mystere-professeur-zacus}"
|
||||
RTC_REPO="${ZEROCLAW_RTC_REPO:-$HOME/RTC_HW}"
|
||||
ZACUS_REPO="${ZEROCLAW_ZACUS_REPO:-$HOME/ZACUS_HW}"
|
||||
HARDWARE_ONLY=0
|
||||
|
||||
usage() {
|
||||
|
||||
@@ -16,8 +16,8 @@ load_local_env
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="${ZEROCLAW_ROOT_DIR:-$(cd -- "$SCRIPT_DIR/../.." && pwd)}"
|
||||
ZEROCLAW_BIN="${ZEROCLAW_BIN:-$ROOT_DIR/zeroclaw/target/release/zeroclaw}"
|
||||
RTC_REPO="${ZEROCLAW_RTC_REPO:-$HOME/RTC_BL_PHONE}"
|
||||
ZACUS_REPO="${ZEROCLAW_ZACUS_REPO:-$HOME/le-mystere-professeur-zacus}"
|
||||
RTC_REPO="${ZEROCLAW_RTC_REPO:-$HOME/RTC_HW}"
|
||||
ZACUS_REPO="${ZEROCLAW_ZACUS_REPO:-$HOME/ZACUS_HW}"
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
RTC_REPO="${ZEROCLAW_RTC_REPO:-$HOME/RTC_BL_PHONE}"
|
||||
ZACUS_REPO="${ZEROCLAW_ZACUS_REPO:-$HOME/le-mystere-professeur-zacus}"
|
||||
RTC_REPO="${ZEROCLAW_RTC_REPO:-$HOME/RTC_HW}"
|
||||
ZACUS_REPO="${ZEROCLAW_ZACUS_REPO:-$HOME/ZACUS_HW}"
|
||||
|
||||
RTC_FW_DIR="$RTC_REPO"
|
||||
ZACUS_FW_DIR="$ZACUS_REPO/hardware/firmware"
|
||||
|
||||
@@ -4,6 +4,20 @@ set -euo pipefail
|
||||
N8N_CONTAINER="${ZEROCLAW_N8N_CONTAINER:-mascarade-n8n}"
|
||||
YES=0
|
||||
|
||||
docker_available() {
|
||||
command -v docker >/dev/null 2>&1 || return 1
|
||||
if [[ -n "${DOCKER_HOST:-}" ]]; then
|
||||
if [[ "$DOCKER_HOST" == unix://* ]]; then
|
||||
local socket_path
|
||||
socket_path="${DOCKER_HOST#unix://}"
|
||||
[ -S "$socket_path" ] || return 1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
[ -S "/Users/electron/.docker/run/docker.sock" ] || [ -S "/var/run/docker.sock" ] || [ -S "${HOME}/.docker/run/docker.sock" ] || return 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: bash tools/ai/zeroclaw_integrations_down.sh [options]
|
||||
@@ -42,6 +56,12 @@ done
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ! docker_available; then
|
||||
echo "already_stopped=${N8N_CONTAINER}"
|
||||
echo "reason=docker_unavailable"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if docker ps --filter "name=^/${N8N_CONTAINER}$" --format '{{.Names}}' | grep -qx "${N8N_CONTAINER}"; then
|
||||
docker stop "${N8N_CONTAINER}" >/dev/null
|
||||
echo "stopped=${N8N_CONTAINER}"
|
||||
|
||||
@@ -8,8 +8,59 @@ N8N_CONTAINER="${ZEROCLAW_N8N_CONTAINER:-mascarade-n8n}"
|
||||
INPUT_FILE="${ZEROCLAW_N8N_WORKFLOW_FILE:-${ROOT_DIR}/tools/ai/integrations/n8n/kill_life_smoke_workflow.json}"
|
||||
CONTAINER_INPUT_PATH="${ZEROCLAW_N8N_CONTAINER_INPUT:-/home/node/kill-life-import.json}"
|
||||
PUBLISH=1
|
||||
N8N_CLI_TIMEOUT_SECS="${ZEROCLAW_N8N_CLI_TIMEOUT_SECS:-45}"
|
||||
DOCKER_TIMEOUT_SECS="${ZEROCLAW_N8N_DOCKER_TIMEOUT_SECS:-20}"
|
||||
OUTPUT_JSON=0
|
||||
|
||||
emit_result() {
|
||||
local import_action="$1"
|
||||
local publish_action="$2"
|
||||
local active_value="$3"
|
||||
local reason_value="${4:-}"
|
||||
if [[ "${OUTPUT_JSON}" == "1" ]]; then
|
||||
ACTIVE="${active_value}" python3 - <<PY
|
||||
import json
|
||||
import os
|
||||
payload = {
|
||||
"workflow_id": ${workflow_id@Q},
|
||||
"input_file": ${INPUT_FILE@Q},
|
||||
"container": ${N8N_CONTAINER@Q},
|
||||
"import_action": ${import_action@Q},
|
||||
"publish_action": ${publish_action@Q},
|
||||
"active": os.environ["ACTIVE"] == "true",
|
||||
}
|
||||
reason = ${reason_value@Q}
|
||||
if reason:
|
||||
payload["reason"] = reason
|
||||
print(json.dumps(payload, ensure_ascii=True))
|
||||
PY
|
||||
return 0
|
||||
fi
|
||||
echo "workflow_id=${workflow_id}"
|
||||
echo "input_file=${INPUT_FILE}"
|
||||
echo "container=${N8N_CONTAINER}"
|
||||
echo "import_action=${import_action}"
|
||||
echo "publish_action=${publish_action}"
|
||||
echo "active=${active_value}"
|
||||
if [[ -n "${reason_value}" ]]; then
|
||||
echo "reason=${reason_value}"
|
||||
fi
|
||||
}
|
||||
|
||||
docker_available() {
|
||||
command -v docker >/dev/null 2>&1 || return 1
|
||||
if [[ -n "${DOCKER_HOST:-}" ]]; then
|
||||
if [[ "$DOCKER_HOST" == unix://* ]]; then
|
||||
local socket_path
|
||||
socket_path="${DOCKER_HOST#unix://}"
|
||||
[ -S "$socket_path" ] || return 1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
[ -S "/Users/electron/.docker/run/docker.sock" ] || [ -S "/var/run/docker.sock" ] || [ -S "${HOME}/.docker/run/docker.sock" ] || return 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: bash tools/ai/zeroclaw_integrations_import_n8n.sh [options]
|
||||
@@ -26,9 +77,106 @@ Env overrides:
|
||||
ZEROCLAW_N8N_CONTAINER
|
||||
ZEROCLAW_N8N_WORKFLOW_FILE
|
||||
ZEROCLAW_N8N_CONTAINER_INPUT
|
||||
ZEROCLAW_N8N_CLI_TIMEOUT_SECS
|
||||
ZEROCLAW_N8N_DOCKER_TIMEOUT_SECS
|
||||
EOF
|
||||
}
|
||||
|
||||
docker_exec_timeout() {
|
||||
python3 - "${N8N_CLI_TIMEOUT_SECS}" "$@" <<'PY'
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
timeout = float(sys.argv[1])
|
||||
cmd = sys.argv[2:]
|
||||
try:
|
||||
proc = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout, check=False)
|
||||
except subprocess.TimeoutExpired:
|
||||
sys.stderr.write("cli_timeout\n")
|
||||
raise SystemExit(124)
|
||||
|
||||
sys.stdout.write(proc.stdout)
|
||||
sys.stderr.write(proc.stderr)
|
||||
raise SystemExit(proc.returncode)
|
||||
PY
|
||||
}
|
||||
|
||||
docker_cmd_timeout() {
|
||||
python3 - "${DOCKER_TIMEOUT_SECS}" "$@" <<'PY'
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
timeout = float(sys.argv[1])
|
||||
cmd = sys.argv[2:]
|
||||
try:
|
||||
proc = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout, check=False)
|
||||
except subprocess.TimeoutExpired:
|
||||
sys.stderr.write("docker_timeout\n")
|
||||
raise SystemExit(124)
|
||||
|
||||
sys.stdout.write(proc.stdout)
|
||||
sys.stderr.write(proc.stderr)
|
||||
raise SystemExit(proc.returncode)
|
||||
PY
|
||||
}
|
||||
|
||||
recent_activation_failure() {
|
||||
local logs=""
|
||||
|
||||
if ! logs="$(docker_cmd_timeout docker logs --since 60s "${N8N_CONTAINER}" 2>&1 || true)"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
grep -Fq "Activation of workflow \"Kill LIFE n8n smoke\" (${workflow_id}) did fail" <<< "${logs}"
|
||||
}
|
||||
|
||||
inspect_current_workflow_state() {
|
||||
local temp_dir=""
|
||||
temp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "${temp_dir}"' RETURN
|
||||
|
||||
if ! docker_cmd_timeout docker cp "${N8N_CONTAINER}:/home/node/.n8n/database.sqlite" "${temp_dir}/database.sqlite" >/dev/null 2>/dev/null; then
|
||||
return 1
|
||||
fi
|
||||
docker_cmd_timeout docker cp "${N8N_CONTAINER}:/home/node/.n8n/database.sqlite-wal" "${temp_dir}/database.sqlite-wal" >/dev/null 2>/dev/null || true
|
||||
docker_cmd_timeout docker cp "${N8N_CONTAINER}:/home/node/.n8n/database.sqlite-shm" "${temp_dir}/database.sqlite-shm" >/dev/null 2>/dev/null || true
|
||||
|
||||
python3 - "${INPUT_FILE}" "${temp_dir}/database.sqlite" <<'PY'
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
workflow_file = Path(sys.argv[1])
|
||||
db_path = Path(sys.argv[2])
|
||||
expected = json.loads(workflow_file.read_text(encoding="utf-8"))
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cur = conn.cursor()
|
||||
row = cur.execute(
|
||||
"select active, nodes, connections from workflow_entity where id = ?",
|
||||
(expected["id"],),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
|
||||
payload = {
|
||||
"present": False,
|
||||
"active": False,
|
||||
"matches": False,
|
||||
}
|
||||
if row:
|
||||
active, nodes_raw, connections_raw = row
|
||||
payload["present"] = True
|
||||
payload["active"] = bool(active)
|
||||
try:
|
||||
nodes = json.loads(nodes_raw or "[]")
|
||||
connections = json.loads(connections_raw or "{}")
|
||||
payload["matches"] = nodes == expected.get("nodes", []) and connections == expected.get("connections", {})
|
||||
except json.JSONDecodeError:
|
||||
payload["matches"] = False
|
||||
print(json.dumps(payload, ensure_ascii=True))
|
||||
PY
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--input)
|
||||
@@ -76,46 +224,69 @@ PY
|
||||
|
||||
bash "${ROOT_DIR}/tools/ai/zeroclaw_integrations_up.sh" >/dev/null
|
||||
|
||||
existing_ids="$(docker exec "${N8N_CONTAINER}" n8n list:workflow --onlyId 2>/dev/null || true)"
|
||||
import_action="skipped"
|
||||
|
||||
if ! printf '%s\n' "${existing_ids}" | grep -Fxq "${workflow_id}"; then
|
||||
docker exec -i "${N8N_CONTAINER}" sh -lc "cat > '${CONTAINER_INPUT_PATH}'" < "${INPUT_FILE}"
|
||||
docker exec "${N8N_CONTAINER}" n8n import:workflow --input="${CONTAINER_INPUT_PATH}" >/dev/null
|
||||
import_action="imported"
|
||||
fi
|
||||
|
||||
publish_action="skipped"
|
||||
if [[ "${PUBLISH}" == "1" ]]; then
|
||||
docker exec "${N8N_CONTAINER}" n8n publish:workflow --id="${workflow_id}" >/dev/null
|
||||
publish_action="published"
|
||||
fi
|
||||
|
||||
active_ids="$(docker exec "${N8N_CONTAINER}" n8n list:workflow --active=true --onlyId 2>/dev/null || true)"
|
||||
is_active=false
|
||||
if printf '%s\n' "${active_ids}" | grep -Fxq "${workflow_id}"; then
|
||||
is_active=true
|
||||
fi
|
||||
|
||||
if [[ "${OUTPUT_JSON}" == "1" ]]; then
|
||||
ACTIVE="${is_active}" python3 - <<PY
|
||||
import json
|
||||
import os
|
||||
print(json.dumps({
|
||||
"workflow_id": ${workflow_id@Q},
|
||||
"input_file": ${INPUT_FILE@Q},
|
||||
"container": ${N8N_CONTAINER@Q},
|
||||
"import_action": ${import_action@Q},
|
||||
"publish_action": ${publish_action@Q},
|
||||
"active": os.environ["ACTIVE"] == "true",
|
||||
}, ensure_ascii=True))
|
||||
PY
|
||||
if ! docker_available; then
|
||||
emit_result "skipped" "skipped" "false" "docker_unavailable"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "workflow_id=${workflow_id}"
|
||||
echo "input_file=${INPUT_FILE}"
|
||||
echo "container=${N8N_CONTAINER}"
|
||||
echo "import_action=${import_action}"
|
||||
echo "publish_action=${publish_action}"
|
||||
echo "active=${is_active}"
|
||||
workflow_state_json="$(inspect_current_workflow_state || true)"
|
||||
if [[ -n "${workflow_state_json}" ]]; then
|
||||
if python3 - "${workflow_state_json}" "${PUBLISH}" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
state = json.loads(sys.argv[1])
|
||||
publish = sys.argv[2] == "1"
|
||||
ok = state.get("present") and state.get("matches") and (state.get("active") or not publish)
|
||||
raise SystemExit(0 if ok else 1)
|
||||
PY
|
||||
then
|
||||
active_value="false"
|
||||
if python3 - "${workflow_state_json}" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
state = json.loads(sys.argv[1])
|
||||
raise SystemExit(0 if state.get("active") else 1)
|
||||
PY
|
||||
then
|
||||
active_value="true"
|
||||
fi
|
||||
emit_result "skipped" "skipped" "${active_value}"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
docker_cmd_timeout docker cp "${INPUT_FILE}" "${N8N_CONTAINER}:${CONTAINER_INPUT_PATH}" >/dev/null
|
||||
import_action="skipped"
|
||||
publish_action="skipped"
|
||||
is_active=false
|
||||
|
||||
docker_exec_timeout docker exec -u node "${N8N_CONTAINER}" n8n import:workflow --input="${CONTAINER_INPUT_PATH}" >/dev/null
|
||||
import_action="imported"
|
||||
|
||||
if [[ "${PUBLISH}" == "1" ]]; then
|
||||
if docker_exec_timeout docker exec -u node "${N8N_CONTAINER}" n8n publish:workflow --id="${workflow_id}" >/dev/null; then
|
||||
publish_action="published"
|
||||
elif docker_exec_timeout docker exec -u node "${N8N_CONTAINER}" n8n update:workflow --id="${workflow_id}" --active=true >/dev/null; then
|
||||
publish_action="activated"
|
||||
else
|
||||
publish_action="failed"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${PUBLISH}" == "1" ]]; then
|
||||
if ! recent_activation_failure; then
|
||||
is_active=true
|
||||
fi
|
||||
else
|
||||
is_active=true
|
||||
fi
|
||||
|
||||
if [[ "${PUBLISH}" == "1" && "${is_active}" != "true" ]]; then
|
||||
if [[ "${publish_action}" == "skipped" ]]; then
|
||||
publish_action="failed"
|
||||
fi
|
||||
emit_result "${import_action}" "${publish_action}" "${is_active}" "workflow_not_active_after_publish"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
emit_result "${import_action}" "${publish_action}" "${is_active}"
|
||||
|
||||
@@ -45,6 +45,7 @@ tools/ai/zeroclaw_integrations_status.sh
|
||||
tools/ai/zeroclaw_integrations_up.sh
|
||||
tools/cockpit/README.md
|
||||
tools/cockpit/lot_chain.sh
|
||||
tools/cockpit/run_next_lots_autonomously.sh
|
||||
ai-agentic-embedded-base/specs/03_plan.md
|
||||
ai-agentic-embedded-base/specs/04_tasks.md
|
||||
ai-agentic-embedded-base/specs/README.md
|
||||
@@ -62,6 +63,7 @@ status_text() {
|
||||
printf 'dirty_status:\n'
|
||||
git -C "$ROOT_DIR" status --short -- "${paths[@]}" || true
|
||||
printf '\ncanonical_commands:\n'
|
||||
printf -- '- bash tools/ai/zeroclaw_integrations_status.sh --json\n'
|
||||
printf -- '- bash tools/ai/zeroclaw_integrations_lot.sh verify\n'
|
||||
printf -- '- bash tools/run_autonomous_next_lots.sh run\n'
|
||||
printf -- '- bash tools/cockpit/lot_chain.sh all --yes\n'
|
||||
@@ -87,6 +89,7 @@ print(json.dumps({
|
||||
"paths": json.loads(os.environ["PATHS_JSON"]),
|
||||
"dirty_status": json.loads(os.environ["DIRTY_JSON"]),
|
||||
"canonical_commands": [
|
||||
"bash tools/ai/zeroclaw_integrations_status.sh --json",
|
||||
"bash tools/ai/zeroclaw_integrations_lot.sh verify",
|
||||
"bash tools/run_autonomous_next_lots.sh run",
|
||||
"bash tools/cockpit/lot_chain.sh all --yes",
|
||||
@@ -100,6 +103,10 @@ run_verify() {
|
||||
local syntax_ok=0
|
||||
local status_json_output=""
|
||||
local import_json_output=""
|
||||
local verify_output=""
|
||||
local verify_rc=0
|
||||
local status_rc=0
|
||||
local import_rc=0
|
||||
|
||||
syntax_cmd=(
|
||||
bash -n
|
||||
@@ -122,30 +129,124 @@ run_verify() {
|
||||
|
||||
"${syntax_cmd[@]}"
|
||||
syntax_ok=1
|
||||
status_json_output="$("${status_cmd[@]}")"
|
||||
import_json_output="$("${import_cmd[@]}")"
|
||||
if ! status_json_output="$("${status_cmd[@]}" 2>&1)"; then
|
||||
status_rc=$?
|
||||
fi
|
||||
status_json_output="$(
|
||||
RAW_OUTPUT="${status_json_output}" STAGE_NAME="status" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
if [[ "$OUTPUT_JSON" == "1" ]]; then
|
||||
SYNTAX_OK="$syntax_ok" \
|
||||
raw = os.environ["RAW_OUTPUT"]
|
||||
stage = os.environ["STAGE_NAME"]
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
payload = {
|
||||
"status": "blocked",
|
||||
"reason": raw.strip() or f"{stage} command failed",
|
||||
"stage": stage,
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=True))
|
||||
PY
|
||||
)"
|
||||
|
||||
if [[ "${status_rc}" -eq 0 ]]; then
|
||||
if ! import_json_output="$("${import_cmd[@]}" 2>&1)"; then
|
||||
import_rc=$?
|
||||
fi
|
||||
else
|
||||
import_rc=1
|
||||
import_json_output='{"workflow_id":"kill-life-n8n-smoke","import_action":"skipped","publish_action":"skipped","active":false,"reason":"status_failed"}'
|
||||
fi
|
||||
import_json_output="$(
|
||||
RAW_OUTPUT="${import_json_output}" STAGE_NAME="import" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
raw = os.environ["RAW_OUTPUT"]
|
||||
stage = os.environ["STAGE_NAME"]
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
payload = {
|
||||
"workflow_id": "kill-life-n8n-smoke",
|
||||
"import_action": "failed",
|
||||
"publish_action": "failed",
|
||||
"active": False,
|
||||
"reason": raw.strip() or f"{stage} command failed",
|
||||
"stage": stage,
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=True))
|
||||
PY
|
||||
)"
|
||||
verify_output="$(
|
||||
STATUS_JSON="${status_json_output}" \
|
||||
IMPORT_JSON="${import_json_output}" \
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
print(json.dumps({
|
||||
status = json.loads(os.environ["STATUS_JSON"])
|
||||
workflow = json.loads(os.environ["IMPORT_JSON"])
|
||||
blockers = []
|
||||
overall = "ready"
|
||||
|
||||
status_state = str(status.get("status", "")).strip().lower()
|
||||
if status_state in {"blocked", "degraded"}:
|
||||
overall = "blocked"
|
||||
blockers.append(str(status.get("reason", status_state)))
|
||||
else:
|
||||
if not status.get("container_running", False):
|
||||
overall = "blocked"
|
||||
blockers.append("n8n container not running")
|
||||
if not status.get("internal_http_ok", False):
|
||||
overall = "blocked"
|
||||
blockers.append("n8n internal health probe failed")
|
||||
if not status.get("host_http_ok", False):
|
||||
overall = "blocked"
|
||||
blockers.append("n8n host HTTP probe failed")
|
||||
|
||||
if not workflow.get("active", False):
|
||||
overall = "blocked"
|
||||
blockers.append("tracked smoke workflow is not active")
|
||||
|
||||
if workflow.get("reason"):
|
||||
overall = "blocked"
|
||||
blockers.append(str(workflow["reason"]))
|
||||
|
||||
payload = {
|
||||
"lot_id": "zeroclaw-integrations-n8n",
|
||||
"syntax_ok": os.environ["SYNTAX_OK"] == "1",
|
||||
"status": json.loads(os.environ["STATUS_JSON"]),
|
||||
"import": json.loads(os.environ["IMPORT_JSON"]),
|
||||
}, ensure_ascii=True))
|
||||
"syntax_ok": True,
|
||||
"overall_status": overall,
|
||||
"blockers": list(dict.fromkeys(blockers)),
|
||||
"status": status,
|
||||
"import": workflow,
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=True))
|
||||
PY
|
||||
return 0
|
||||
)"
|
||||
|
||||
if [[ "${status_rc}" -ne 0 || "${import_rc}" -ne 0 ]]; then
|
||||
verify_rc=1
|
||||
elif ! python3 - <<'PY' "${verify_output}"
|
||||
import json
|
||||
import sys
|
||||
payload = json.loads(sys.argv[1])
|
||||
raise SystemExit(0 if payload.get("overall_status") == "ready" else 1)
|
||||
PY
|
||||
then
|
||||
verify_rc=1
|
||||
fi
|
||||
|
||||
if [[ "$OUTPUT_JSON" == "1" ]]; then
|
||||
printf '%s\n' "${verify_output}"
|
||||
return "${verify_rc}"
|
||||
fi
|
||||
|
||||
printf 'syntax_ok=true\n'
|
||||
printf 'status=%s\n' "${status_json_output}"
|
||||
printf 'import=%s\n' "${import_json_output}"
|
||||
printf 'verify=%s\n' "${verify_output}"
|
||||
return "${verify_rc}"
|
||||
}
|
||||
|
||||
if [[ $# -gt 0 ]]; then
|
||||
|
||||
@@ -3,7 +3,69 @@ set -euo pipefail
|
||||
|
||||
N8N_CONTAINER="${ZEROCLAW_N8N_CONTAINER:-mascarade-n8n}"
|
||||
N8N_URL="${ZEROCLAW_N8N_URL:-http://127.0.0.1:5678/}"
|
||||
N8N_HEALTH_URL="${ZEROCLAW_N8N_HEALTH_URL:-${N8N_URL%/}/healthz}"
|
||||
OUTPUT_JSON=0
|
||||
TRACKED_WORKFLOW_ID="${ZEROCLAW_N8N_WORKFLOW_ID:-kill-life-n8n-smoke}"
|
||||
DOCKER_TIMEOUT_SECS="${ZEROCLAW_N8N_DOCKER_TIMEOUT_SECS:-10}"
|
||||
STATUS_VALUE="blocked"
|
||||
STATUS_REASON="n8n_http_unreachable"
|
||||
RUNTIME_ALERTS_CSV=""
|
||||
|
||||
docker_available() {
|
||||
command -v docker >/dev/null 2>&1 || return 1
|
||||
if [[ -n "${DOCKER_HOST:-}" ]]; then
|
||||
if [[ "$DOCKER_HOST" == unix://* ]]; then
|
||||
local socket_path
|
||||
socket_path="${DOCKER_HOST#unix://}"
|
||||
[ -S "$socket_path" ] || return 1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
[ -S "/Users/electron/.docker/run/docker.sock" ] || [ -S "/var/run/docker.sock" ] || [ -S "${HOME}/.docker/run/docker.sock" ] || return 1
|
||||
}
|
||||
|
||||
docker_cmd_timeout() {
|
||||
python3 - "${DOCKER_TIMEOUT_SECS}" "$@" <<'PY'
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
timeout = float(sys.argv[1])
|
||||
cmd = sys.argv[2:]
|
||||
try:
|
||||
proc = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout, check=False)
|
||||
except subprocess.TimeoutExpired:
|
||||
sys.stderr.write("docker_timeout\n")
|
||||
raise SystemExit(124)
|
||||
|
||||
sys.stdout.write(proc.stdout)
|
||||
sys.stderr.write(proc.stderr)
|
||||
raise SystemExit(proc.returncode)
|
||||
PY
|
||||
}
|
||||
|
||||
probe_host_http() {
|
||||
curl --max-time 5 -fsSI "${N8N_URL}" >/dev/null 2>&1 || \
|
||||
curl --max-time 5 -fsS "${N8N_HEALTH_URL}" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
collect_runtime_alerts() {
|
||||
local logs=""
|
||||
local -a alerts=()
|
||||
|
||||
if ! logs="$(docker_cmd_timeout docker logs --tail 120 "${N8N_CONTAINER}" 2>&1 || true)"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if grep -Fq "Database connection timed out" <<< "${logs}"; then
|
||||
alerts+=("n8n_database_timeout")
|
||||
fi
|
||||
if grep -Fq "has no node to start the workflow" <<< "${logs}"; then
|
||||
alerts+=("workflow_activation_invalid")
|
||||
fi
|
||||
|
||||
printf '%s\n' "${alerts[@]}"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
@@ -18,6 +80,9 @@ Options:
|
||||
Env overrides:
|
||||
ZEROCLAW_N8N_CONTAINER
|
||||
ZEROCLAW_N8N_URL
|
||||
ZEROCLAW_N8N_HEALTH_URL
|
||||
ZEROCLAW_N8N_WORKFLOW_ID
|
||||
ZEROCLAW_N8N_DOCKER_TIMEOUT_SECS
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -39,8 +104,6 @@ while [[ $# -gt 0 ]]; do
|
||||
shift
|
||||
done
|
||||
|
||||
container_line="$(docker ps -a --filter "name=^/${N8N_CONTAINER}$" --format '{{.Names}}|{{.Status}}' | head -n 1)"
|
||||
running_line="$(docker ps --filter "name=^/${N8N_CONTAINER}$" --format '{{.Names}}|{{.Status}}' | head -n 1)"
|
||||
container_exists=false
|
||||
container_running=false
|
||||
container_status="missing"
|
||||
@@ -48,27 +111,76 @@ internal_http_ok=false
|
||||
host_http_ok=false
|
||||
workflow_ids=""
|
||||
active_ids=""
|
||||
docker_unavailable=0
|
||||
workflow_probe_status="not_queried"
|
||||
active_probe_status="not_queried"
|
||||
|
||||
if [[ -n "${container_line}" ]]; then
|
||||
if docker_available; then
|
||||
if ! container_line="$(docker_cmd_timeout docker ps -a --filter "name=^/${N8N_CONTAINER}$" --format '{{.Names}}|{{.Status}}' 2>/dev/null | head -n 1)"; then
|
||||
container_status="docker cli timeout"
|
||||
container_line=""
|
||||
fi
|
||||
if ! running_line="$(docker_cmd_timeout docker ps --filter "name=^/${N8N_CONTAINER}$" --format '{{.Names}}|{{.Status}}' 2>/dev/null | head -n 1)"; then
|
||||
container_status="docker cli timeout"
|
||||
running_line=""
|
||||
fi
|
||||
|
||||
if [[ "${container_status}" != "docker cli timeout" && -n "${container_line}" ]]; then
|
||||
container_exists=true
|
||||
container_status="${container_line#*|}"
|
||||
fi
|
||||
|
||||
if [[ "${container_status}" != "docker cli timeout" && -n "${running_line}" ]]; then
|
||||
container_running=true
|
||||
fi
|
||||
|
||||
else
|
||||
container_exists=false
|
||||
container_running=false
|
||||
container_status="docker unavailable"
|
||||
docker_unavailable=1
|
||||
fi
|
||||
|
||||
if probe_host_http; then
|
||||
host_http_ok=true
|
||||
internal_http_ok=true
|
||||
container_exists=true
|
||||
container_status="${container_line#*|}"
|
||||
fi
|
||||
|
||||
if [[ -n "${running_line}" ]]; then
|
||||
container_running=true
|
||||
if [[ "${container_status}" == "missing" ]]; then
|
||||
container_status="host http ok (container unverified)"
|
||||
elif [[ "${container_status}" == "docker cli timeout" ]]; then
|
||||
container_status="docker cli timeout (host http ok)"
|
||||
elif [[ "${container_status}" == "docker unavailable" ]]; then
|
||||
container_status="docker unavailable (host http ok)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${container_running}" == true ]]; then
|
||||
if docker exec "${N8N_CONTAINER}" sh -lc 'which wget >/dev/null 2>&1 && wget -q -O /dev/null http://127.0.0.1:5678/'; then
|
||||
internal_http_ok=true
|
||||
if [[ "${host_http_ok}" == true ]]; then
|
||||
if [[ "${container_status}" == docker\ cli\ timeout* || "${container_status}" == docker\ unavailable* ]]; then
|
||||
STATUS_VALUE="degraded"
|
||||
STATUS_REASON="runtime_reachable_docker_unavailable"
|
||||
else
|
||||
STATUS_VALUE="ready"
|
||||
STATUS_REASON=""
|
||||
fi
|
||||
elif [[ "${container_status}" == "docker cli timeout" ]]; then
|
||||
STATUS_VALUE="blocked"
|
||||
STATUS_REASON="docker_cli_timeout"
|
||||
elif [[ "${docker_unavailable}" == "1" ]]; then
|
||||
STATUS_VALUE="degraded"
|
||||
STATUS_REASON="docker_unavailable"
|
||||
fi
|
||||
|
||||
if curl -fsSI "${N8N_URL}" >/dev/null 2>&1; then
|
||||
host_http_ok=true
|
||||
if [[ "${host_http_ok}" != true && "${container_running}" == true ]]; then
|
||||
RUNTIME_ALERTS_CSV="$(
|
||||
collect_runtime_alerts \
|
||||
| python3 -c 'import json,sys; items=[line.strip() for line in sys.stdin if line.strip()]; print(",".join(items))'
|
||||
)"
|
||||
if [[ ",${RUNTIME_ALERTS_CSV}," == *",n8n_database_timeout,"* ]]; then
|
||||
STATUS_REASON="n8n_database_timeout"
|
||||
elif [[ ",${RUNTIME_ALERTS_CSV}," == *",workflow_activation_invalid,"* ]]; then
|
||||
STATUS_REASON="workflow_activation_invalid"
|
||||
fi
|
||||
|
||||
workflow_ids="$(docker exec "${N8N_CONTAINER}" n8n list:workflow --onlyId 2>/dev/null | sed '/^$/d' | paste -sd, - || true)"
|
||||
active_ids="$(docker exec "${N8N_CONTAINER}" n8n list:workflow --active=true --onlyId 2>/dev/null | sed '/^$/d' | paste -sd, - || true)"
|
||||
fi
|
||||
|
||||
if [[ "${OUTPUT_JSON}" == "1" ]]; then
|
||||
@@ -80,6 +192,8 @@ if [[ "${OUTPUT_JSON}" == "1" ]]; then
|
||||
import json
|
||||
import os
|
||||
print(json.dumps({
|
||||
"status": ${STATUS_VALUE@Q},
|
||||
"reason": ${STATUS_REASON@Q},
|
||||
"container": ${N8N_CONTAINER@Q},
|
||||
"container_exists": os.environ["CONTAINER_EXISTS"] == "true",
|
||||
"container_running": os.environ["CONTAINER_RUNNING"] == "true",
|
||||
@@ -87,6 +201,11 @@ print(json.dumps({
|
||||
"internal_http_ok": os.environ["INTERNAL_HTTP_OK"] == "true",
|
||||
"host_http_ok": os.environ["HOST_HTTP_OK"] == "true",
|
||||
"n8n_url": ${N8N_URL@Q},
|
||||
"n8n_health_url": ${N8N_HEALTH_URL@Q},
|
||||
"tracked_workflow_id": ${TRACKED_WORKFLOW_ID@Q},
|
||||
"runtime_alerts": [item for item in ${RUNTIME_ALERTS_CSV@Q}.split(",") if item],
|
||||
"workflow_probe_status": ${workflow_probe_status@Q},
|
||||
"active_probe_status": ${active_probe_status@Q},
|
||||
"workflow_ids": [item for item in ${workflow_ids@Q}.split(",") if item],
|
||||
"active_workflow_ids": [item for item in ${active_ids@Q}.split(",") if item],
|
||||
}, ensure_ascii=True))
|
||||
@@ -98,8 +217,15 @@ echo "container=${N8N_CONTAINER}"
|
||||
echo "exists=${container_exists}"
|
||||
echo "running=${container_running}"
|
||||
echo "status=${container_status}"
|
||||
echo "overall_status=${STATUS_VALUE}"
|
||||
echo "reason=${STATUS_REASON:-"(none)"}"
|
||||
echo "internal_http_ok=${internal_http_ok}"
|
||||
echo "host_http_ok=${host_http_ok}"
|
||||
echo "n8n_url=${N8N_URL}"
|
||||
echo "n8n_health_url=${N8N_HEALTH_URL}"
|
||||
echo "runtime_alerts=${RUNTIME_ALERTS_CSV:-"(none)"}"
|
||||
echo "tracked_workflow_id=${TRACKED_WORKFLOW_ID}"
|
||||
echo "workflow_probe_status=${workflow_probe_status}"
|
||||
echo "active_probe_status=${active_probe_status}"
|
||||
echo "workflow_ids=${workflow_ids:-"(none)"}"
|
||||
echo "active_workflow_ids=${active_ids:-"(none)"}"
|
||||
|
||||
@@ -6,8 +6,80 @@ ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
N8N_CONTAINER="${ZEROCLAW_N8N_CONTAINER:-mascarade-n8n}"
|
||||
WAIT_SECS="${ZEROCLAW_N8N_WAIT_SECS:-30}"
|
||||
N8N_IMAGE="${ZEROCLAW_N8N_IMAGE:-docker.n8n.io/n8nio/n8n}"
|
||||
N8N_VOLUME="${ZEROCLAW_N8N_VOLUME:-${N8N_CONTAINER}-data}"
|
||||
N8N_PORT="${ZEROCLAW_N8N_PORT:-5678}"
|
||||
N8N_URL="${ZEROCLAW_N8N_URL:-http://127.0.0.1:${N8N_PORT}/}"
|
||||
N8N_HEALTH_URL="${ZEROCLAW_N8N_HEALTH_URL:-${N8N_URL%/}/healthz}"
|
||||
N8N_TIMEZONE="${ZEROCLAW_N8N_TIMEZONE:-${TZ:-Europe/Paris}}"
|
||||
AUTO_PROVISION="${ZEROCLAW_N8N_AUTOPROVISION:-1}"
|
||||
DOCKER_TIMEOUT_SECS="${ZEROCLAW_N8N_DOCKER_TIMEOUT_SECS:-20}"
|
||||
OUTPUT_JSON=0
|
||||
|
||||
emit_runtime_json() {
|
||||
local status_value="$1"
|
||||
local reason_value="$2"
|
||||
python3 - <<PY
|
||||
import json
|
||||
print(json.dumps({
|
||||
"status": ${status_value@Q},
|
||||
"reason": ${reason_value@Q},
|
||||
"container": ${N8N_CONTAINER@Q},
|
||||
"container_state": "unverified",
|
||||
"n8n_url": ${N8N_URL@Q},
|
||||
"n8n_health_url": ${N8N_HEALTH_URL@Q},
|
||||
}, ensure_ascii=True))
|
||||
PY
|
||||
}
|
||||
|
||||
probe_http_ready() {
|
||||
curl --max-time 5 -fsSI "${N8N_URL}" >/dev/null 2>&1 || \
|
||||
curl --max-time 5 -fsS "${N8N_HEALTH_URL}" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
emit_runtime_error() {
|
||||
local reason_value="$1"
|
||||
if [[ "${OUTPUT_JSON}" == "1" ]]; then
|
||||
emit_runtime_json "blocked" "${reason_value}"
|
||||
exit 0
|
||||
fi
|
||||
echo "${reason_value}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
docker_available() {
|
||||
command -v docker >/dev/null 2>&1 || return 1
|
||||
if [[ -n "${DOCKER_HOST:-}" ]]; then
|
||||
if [[ "$DOCKER_HOST" == unix://* ]]; then
|
||||
local socket_path
|
||||
socket_path="${DOCKER_HOST#unix://}"
|
||||
[ -S "$socket_path" ] || return 1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
[ -S "/Users/electron/.docker/run/docker.sock" ] || [ -S "/var/run/docker.sock" ] || [ -S "${HOME}/.docker/run/docker.sock" ] || return 1
|
||||
}
|
||||
|
||||
docker_cmd_timeout() {
|
||||
python3 - "${DOCKER_TIMEOUT_SECS}" "$@" <<'PY'
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
timeout = float(sys.argv[1])
|
||||
cmd = sys.argv[2:]
|
||||
try:
|
||||
proc = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout, check=False)
|
||||
except subprocess.TimeoutExpired:
|
||||
sys.stderr.write("docker_timeout\n")
|
||||
raise SystemExit(124)
|
||||
|
||||
sys.stdout.write(proc.stdout)
|
||||
sys.stderr.write(proc.stderr)
|
||||
raise SystemExit(proc.returncode)
|
||||
PY
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: bash tools/ai/zeroclaw_integrations_up.sh [options]
|
||||
@@ -21,6 +93,14 @@ Options:
|
||||
Env overrides:
|
||||
ZEROCLAW_N8N_CONTAINER
|
||||
ZEROCLAW_N8N_WAIT_SECS
|
||||
ZEROCLAW_N8N_IMAGE
|
||||
ZEROCLAW_N8N_VOLUME
|
||||
ZEROCLAW_N8N_PORT
|
||||
ZEROCLAW_N8N_URL
|
||||
ZEROCLAW_N8N_HEALTH_URL
|
||||
ZEROCLAW_N8N_TIMEZONE
|
||||
ZEROCLAW_N8N_AUTOPROVISION
|
||||
ZEROCLAW_N8N_DOCKER_TIMEOUT_SECS
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -42,22 +122,78 @@ while [[ $# -gt 0 ]]; do
|
||||
shift
|
||||
done
|
||||
|
||||
running_line="$(docker ps --filter "name=^/${N8N_CONTAINER}$" --format '{{.Names}}' | head -n 1)"
|
||||
existing_line="$(docker ps -a --filter "name=^/${N8N_CONTAINER}$" --format '{{.Names}}' | head -n 1)"
|
||||
if ! docker_available; then
|
||||
if probe_http_ready; then
|
||||
if [[ "${OUTPUT_JSON}" == "1" ]]; then
|
||||
bash "${ROOT_DIR}/tools/ai/zeroclaw_integrations_status.sh" --json
|
||||
else
|
||||
bash "${ROOT_DIR}/tools/ai/zeroclaw_integrations_status.sh"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${OUTPUT_JSON}" == "1" ]]; then
|
||||
emit_runtime_json "degraded" "docker_unavailable"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "status=degraded"
|
||||
echo "container=${N8N_CONTAINER}"
|
||||
echo "container_state=skipped"
|
||||
echo "reason=docker_unavailable"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if probe_http_ready; then
|
||||
if [[ "${OUTPUT_JSON}" == "1" ]]; then
|
||||
bash "${ROOT_DIR}/tools/ai/zeroclaw_integrations_status.sh" --json
|
||||
else
|
||||
bash "${ROOT_DIR}/tools/ai/zeroclaw_integrations_status.sh"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! running_line="$(docker_cmd_timeout docker ps --filter "name=^/${N8N_CONTAINER}$" --format '{{.Names}}' 2>/dev/null | head -n 1)"; then
|
||||
emit_runtime_error "docker_cli_timeout"
|
||||
fi
|
||||
if ! existing_line="$(docker_cmd_timeout docker ps -a --filter "name=^/${N8N_CONTAINER}$" --format '{{.Names}}' 2>/dev/null | head -n 1)"; then
|
||||
emit_runtime_error "docker_cli_timeout"
|
||||
fi
|
||||
|
||||
if [[ -n "${running_line}" ]]; then
|
||||
:
|
||||
if ! probe_http_ready; then
|
||||
if ! docker_cmd_timeout docker restart "${N8N_CONTAINER}" >/dev/null 2>/dev/null; then
|
||||
emit_runtime_error "docker_cli_timeout"
|
||||
fi
|
||||
fi
|
||||
elif [[ -n "${existing_line}" ]]; then
|
||||
docker start "${N8N_CONTAINER}" >/dev/null
|
||||
if ! docker_cmd_timeout docker start "${N8N_CONTAINER}" >/dev/null 2>/dev/null; then
|
||||
emit_runtime_error "docker_cli_timeout"
|
||||
fi
|
||||
else
|
||||
echo "n8n container not found: ${N8N_CONTAINER}" >&2
|
||||
echo "Expected an already-provisioned companion runtime (for example mascarade-n8n)." >&2
|
||||
exit 1
|
||||
if [[ "${AUTO_PROVISION}" != "1" ]]; then
|
||||
echo "n8n container not found: ${N8N_CONTAINER}" >&2
|
||||
echo "Expected an already-provisioned companion runtime (for example mascarade-n8n)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker_cmd_timeout docker volume create "${N8N_VOLUME}" >/dev/null 2>/dev/null; then
|
||||
emit_runtime_error "docker_cli_timeout"
|
||||
fi
|
||||
if ! docker_cmd_timeout docker run -d \
|
||||
--name "${N8N_CONTAINER}" \
|
||||
-p "${N8N_PORT}:5678" \
|
||||
-e GENERIC_TIMEZONE="${N8N_TIMEZONE}" \
|
||||
-e TZ="${N8N_TIMEZONE}" \
|
||||
-e N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true \
|
||||
-v "${N8N_VOLUME}:/home/node/.n8n" \
|
||||
"${N8N_IMAGE}" >/dev/null 2>/dev/null; then
|
||||
emit_runtime_error "docker_cli_timeout"
|
||||
fi
|
||||
fi
|
||||
|
||||
deadline=$((SECONDS + WAIT_SECS))
|
||||
while (( SECONDS < deadline )); do
|
||||
if docker exec "${N8N_CONTAINER}" sh -lc 'which wget >/dev/null 2>&1 && wget -q -O /dev/null http://127.0.0.1:5678/'; then
|
||||
if probe_http_ready; then
|
||||
if [[ "${OUTPUT_JSON}" == "1" ]]; then
|
||||
bash "${ROOT_DIR}/tools/ai/zeroclaw_integrations_status.sh" --json
|
||||
else
|
||||
@@ -68,5 +204,10 @@ while (( SECONDS < deadline )); do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [[ "${OUTPUT_JSON}" == "1" ]]; then
|
||||
bash "${ROOT_DIR}/tools/ai/zeroclaw_integrations_status.sh" --json
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "n8n container did not become ready within ${WAIT_SECS}s: ${N8N_CONTAINER}" >&2
|
||||
exit 1
|
||||
|
||||
@@ -1163,8 +1163,8 @@ cat >"$INDEX_FILE" <<EOF
|
||||
<div class="inline-row">
|
||||
<select id="prRepoSelect" class="select">
|
||||
<option value="electron-rare/Kill_LIFE">Kill_LIFE</option>
|
||||
<option value="electron-rare/RTC_BL_PHONE">RTC_BL_PHONE</option>
|
||||
<option value="electron-rare/le-mystere-professeur-zacus">Zacus</option>
|
||||
<option value="electron-rare/RTC_HW">RTC_HW</option>
|
||||
<option value="electron-rare/ZACUS_HW">Zacus</option>
|
||||
</select>
|
||||
<input id="prNumberInput" class="input" type="number" min="1" placeholder="PR #" />
|
||||
</div>
|
||||
@@ -1631,8 +1631,8 @@ cat >"$INDEX_FILE" <<EOF
|
||||
const payload = {
|
||||
repos: [
|
||||
"electron-rare/Kill_LIFE",
|
||||
"electron-rare/RTC_BL_PHONE",
|
||||
"electron-rare/le-mystere-professeur-zacus"
|
||||
"electron-rare/RTC_HW",
|
||||
"electron-rare/ZACUS_HW"
|
||||
],
|
||||
strict: {
|
||||
allow_draft: false,
|
||||
@@ -2153,11 +2153,11 @@ cat >"$INDEX_FILE" <<EOF
|
||||
}
|
||||
const rtcPrompt =
|
||||
general +
|
||||
"\\nContexte cible: RTC_BL_PHONE sur ESP32 Audio Kit." +
|
||||
"\\nContexte cible: RTC_HW sur ESP32 Audio Kit." +
|
||||
"\\nAttendu: diagnostic audio/Bluetooth/WiFi/WebServer + erreurs recentes + prochaine action concise.";
|
||||
const zacusPrompt =
|
||||
general +
|
||||
"\\nContexte cible: le-mystere-professeur-zacus sur Freenove ESP32-S3 (usbmodem)." +
|
||||
"\\nContexte cible: ZACUS_HW sur Freenove ESP32-S3 (usbmodem)." +
|
||||
"\\nAttendu: diagnostic UI/story/audio/network + erreurs recentes + prochaine action concise.";
|
||||
document.getElementById("rtcPromptInput").value = rtcPrompt;
|
||||
document.getElementById("zacusPromptInput").value = zacusPrompt;
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -61,7 +62,9 @@ def compact_repo_paths(value: str) -> str:
|
||||
return text.replace(root_with_sep, "")
|
||||
if text == str(ROOT):
|
||||
return "."
|
||||
return text
|
||||
repo_name = re.escape(ROOT.name)
|
||||
pattern = re.compile(rf"(^|[\s:(\"'`])(?:[A-Za-z]:)?[^ \t\n|\"'`)]*[\\/]{repo_name}[\\/]")
|
||||
return pattern.sub(lambda match: match.group(1), text)
|
||||
|
||||
|
||||
def compact_artifact_list_signal(value: str) -> str:
|
||||
|
||||
@@ -10,10 +10,12 @@ import subprocess
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from dataclasses import field
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PLAN_DOC = ROOT / "docs" / "plans" / "18_plan_enchainement_autonome_des_lots_utiles.md"
|
||||
TODO_DOC = ROOT / "docs" / "plans" / "18_todo_enchainement_autonome_des_lots_utiles.md"
|
||||
INTELLIGENCE_MEMORY_JSON = ROOT / "artifacts" / "cockpit" / "intelligence_program" / "latest.json"
|
||||
|
||||
NOISE_PATHS: tuple[str, ...] = (
|
||||
"docs/plans/18_plan_enchainement_autonome_des_lots_utiles.md",
|
||||
@@ -42,7 +44,14 @@ class Lot:
|
||||
priority: int
|
||||
paths: tuple[str, ...]
|
||||
plan_refs: tuple[str, ...]
|
||||
owner_repo: str
|
||||
owner_agent: str
|
||||
handoff_write_set: tuple[str, ...]
|
||||
validations: tuple[Validation, ...]
|
||||
owner_subagent: str | None = None
|
||||
owner_team: str = "PM"
|
||||
dependencies: tuple[str, ...] = field(default_factory=tuple)
|
||||
rollback_plan: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
LOTS: tuple[Lot, ...] = (
|
||||
@@ -83,6 +92,19 @@ LOTS: tuple[Lot, ...] = (
|
||||
"specs/zeroclaw_dual_hw_todo.md",
|
||||
"docs/plans/18_plan_enchainement_autonome_des_lots_utiles.md",
|
||||
),
|
||||
owner_repo="Kill_LIFE",
|
||||
owner_agent="PM-Mesh",
|
||||
owner_team="PM",
|
||||
dependencies=(),
|
||||
rollback_plan=(
|
||||
"Revenir à la lane `zeroclaw` manuelle si le container n8n échoue de manière persistante.",
|
||||
"Rejouer `bash tools/run_autonomous_next_lots.sh run --no-write` puis arbitrer manuellement.",
|
||||
),
|
||||
handoff_write_set=(
|
||||
"specs/zeroclaw_dual_hw_todo.md",
|
||||
"tools/cockpit/lot_chain.sh",
|
||||
"tools/cockpit/run_next_lots_autonomously.sh",
|
||||
),
|
||||
validations=(
|
||||
Validation(
|
||||
name="zeroclaw_integrations_lot_verify",
|
||||
@@ -94,6 +116,105 @@ LOTS: tuple[Lot, ...] = (
|
||||
),
|
||||
),
|
||||
),
|
||||
Lot(
|
||||
key="mesh-governance",
|
||||
title="Gouvernance mesh tri-repo",
|
||||
description=(
|
||||
"Versionner le contrat tri-repo, verrouiller les ownerships agents, "
|
||||
"et outiller le preflight de synchro continue sans ecraser le travail "
|
||||
"des autres contributeurs."
|
||||
),
|
||||
priority=7,
|
||||
paths=(
|
||||
"README.md",
|
||||
"docs/index.md",
|
||||
"docs/REFACTOR_MANIFEST_2026-03-20.md",
|
||||
"docs/TRI_REPO_MESH_CONTRACT_2026-03-20.md",
|
||||
"docs/AGENTIC_LANDSCAPE.md",
|
||||
"docs/MACHINE_SYNC_STATUS_2026-03-20.md",
|
||||
"docs/MCP_SETUP.md",
|
||||
"docs/plans/12_plan_gestion_des_agents.md",
|
||||
"docs/plans/19_todo_mesh_tri_repo.md",
|
||||
"specs/03_plan.md",
|
||||
"specs/04_tasks.md",
|
||||
"specs/mesh_contracts.md",
|
||||
"specs/contracts/agent_handoff.schema.json",
|
||||
"specs/contracts/repo_snapshot.schema.json",
|
||||
"specs/contracts/workflow_handshake.schema.json",
|
||||
"specs/contracts/examples/agent_handoff.mesh.json",
|
||||
"specs/contracts/examples/repo_snapshot.mesh.json",
|
||||
"specs/contracts/examples/workflow_handshake.mesh.json",
|
||||
"tools/cockpit/README.md",
|
||||
"tools/cockpit/refonte_tui.sh",
|
||||
"tools/cockpit/mesh_sync_preflight.sh",
|
||||
"tools/cockpit/ssh_healthcheck.sh",
|
||||
"tools/specs/mesh_contract_check.py",
|
||||
".github/workflows/mesh_contracts.yml",
|
||||
),
|
||||
plan_refs=(
|
||||
"docs/TRI_REPO_MESH_CONTRACT_2026-03-20.md",
|
||||
"docs/plans/12_plan_gestion_des_agents.md",
|
||||
"specs/03_plan.md",
|
||||
),
|
||||
owner_repo="Kill_LIFE",
|
||||
owner_agent="PM-Mesh",
|
||||
owner_team="PM",
|
||||
dependencies=("zeroclaw-integrations",),
|
||||
rollback_plan=(
|
||||
"Retirer le lot `mesh-governance` du déclenchement auto si la convergence reste bloquée.",
|
||||
"Conserver le contrat mesh en mode lecture seule et documenter la dérogation.",
|
||||
),
|
||||
handoff_write_set=(
|
||||
"docs/TRI_REPO_MESH_CONTRACT_2026-03-20.md",
|
||||
"docs/MACHINE_SYNC_STATUS_2026-03-20.md",
|
||||
"docs/plans/19_todo_mesh_tri_repo.md",
|
||||
"tools/cockpit/mesh_sync_preflight.sh",
|
||||
"tools/cockpit/ssh_healthcheck.sh",
|
||||
),
|
||||
validations=(
|
||||
Validation(
|
||||
name="mesh_sync_preflight",
|
||||
cmd=("bash", "tools/cockpit/mesh_sync_preflight.sh", "--json"),
|
||||
),
|
||||
Validation(
|
||||
name="ssh_healthcheck",
|
||||
cmd=("bash", "tools/cockpit/ssh_healthcheck.sh", "--json"),
|
||||
),
|
||||
Validation(
|
||||
name="mesh_agent_handoff_contract",
|
||||
cmd=(
|
||||
"python3",
|
||||
"tools/specs/mesh_contract_check.py",
|
||||
"--schema",
|
||||
"specs/contracts/agent_handoff.schema.json",
|
||||
"--instance",
|
||||
"specs/contracts/examples/agent_handoff.mesh.json",
|
||||
),
|
||||
),
|
||||
Validation(
|
||||
name="mesh_repo_snapshot_contract",
|
||||
cmd=(
|
||||
"python3",
|
||||
"tools/specs/mesh_contract_check.py",
|
||||
"--schema",
|
||||
"specs/contracts/repo_snapshot.schema.json",
|
||||
"--instance",
|
||||
"specs/contracts/examples/repo_snapshot.mesh.json",
|
||||
),
|
||||
),
|
||||
Validation(
|
||||
name="mesh_workflow_handshake_contract",
|
||||
cmd=(
|
||||
"python3",
|
||||
"tools/specs/mesh_contract_check.py",
|
||||
"--schema",
|
||||
"specs/contracts/workflow_handshake.schema.json",
|
||||
"--instance",
|
||||
"specs/contracts/examples/workflow_handshake.mesh.json",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Lot(
|
||||
key="mcp-runtime",
|
||||
title="Alignement MCP runtime local",
|
||||
@@ -117,6 +238,20 @@ LOTS: tuple[Lot, ...] = (
|
||||
"docs/plans/15_plan_mcp_runtime_alignment.md",
|
||||
"docs/plans/17_plan_target_architecture_mcp_agentics_2028.md",
|
||||
),
|
||||
owner_repo="Kill_LIFE",
|
||||
owner_agent="Runtime-Companion",
|
||||
owner_team="Architect-Firmware",
|
||||
dependencies=("zeroclaw-integrations",),
|
||||
rollback_plan=(
|
||||
"Désactiver les changements MCP introduits dans le lot et revenir à la configuration de référence.",
|
||||
"Conserver la preuve de doc dans `docs/MCP_SETUP.md` et relancer uniquement en mode validation.",
|
||||
),
|
||||
handoff_write_set=(
|
||||
"docs/MCP_SETUP.md",
|
||||
"tools/bootstrap_mac_mcp.sh",
|
||||
"tools/lib/runtime_home.sh",
|
||||
"tools/run_github_dispatch_mcp.sh",
|
||||
),
|
||||
validations=(
|
||||
Validation(
|
||||
name="bootstrap_codex_dry_run",
|
||||
@@ -161,6 +296,19 @@ LOTS: tuple[Lot, ...] = (
|
||||
"docs/plans/16_plan_cad_modeling_stack.md",
|
||||
"docs/plans/17_plan_target_architecture_mcp_agentics_2028.md",
|
||||
),
|
||||
owner_repo="Kill_LIFE",
|
||||
owner_agent="Embedded-CAD",
|
||||
owner_team="Architect-Firmware",
|
||||
dependencies=("mcp-runtime",),
|
||||
rollback_plan=(
|
||||
"Retour automatique au mode container-only (`tools/hw/cad_stack.sh`).",
|
||||
"Conserver les chemins explicites de script pour éviter une dérive host-first.",
|
||||
),
|
||||
handoff_write_set=(
|
||||
"docs/MCP_SETUP.md",
|
||||
"tools/hw/cad_stack.sh",
|
||||
"tools/hw/run_kicad_mcp.sh",
|
||||
),
|
||||
validations=(
|
||||
Validation(
|
||||
name="kicad_doctor",
|
||||
@@ -180,6 +328,72 @@ LOTS: tuple[Lot, ...] = (
|
||||
),
|
||||
),
|
||||
),
|
||||
Lot(
|
||||
key="yiacad-fusion",
|
||||
title="Fusion KiCad + FreeCAD IA-native",
|
||||
description=(
|
||||
"Mettre en place le lot YiACAD (`prepare`, `smoke`, `status`, "
|
||||
"`logs`, `clean-logs`) et la synthese operateur associee sans "
|
||||
"fusion automatique de `main`."
|
||||
),
|
||||
priority=25,
|
||||
paths=(
|
||||
"README.md",
|
||||
"SYNTHESE_AGENTIQUE.md",
|
||||
"docs/AGENTIC_LANDSCAPE.md",
|
||||
"docs/AI_WORKFLOWS.md",
|
||||
"docs/CAD_AI_NATIVE_FORK_STRATEGY.md",
|
||||
"docs/OSS_AI_NATIVE_CAD_RESEARCH_2026-03-20.md",
|
||||
"docs/WEB_RESEARCH_OPEN_SOURCE_2026-03-20.md",
|
||||
"docs/plans/12_plan_gestion_des_agents.md",
|
||||
"docs/plans/18_plan_enchainement_autonome_des_lots_utiles.md",
|
||||
"docs/plans/18_todo_enchainement_autonome_des_lots_utiles.md",
|
||||
"docs/plans/19_todo_mesh_tri_repo.md",
|
||||
"specs/03_plan.md",
|
||||
"specs/04_tasks.md",
|
||||
"tools/cad/ai_native_forks.sh",
|
||||
"tools/cad/yiacad_fusion_lot.sh",
|
||||
"tools/cockpit/refonte_tui.sh",
|
||||
"tools/cockpit/render_weekly_refonte_summary.sh",
|
||||
),
|
||||
plan_refs=(
|
||||
"docs/CAD_AI_NATIVE_FORK_STRATEGY.md",
|
||||
"docs/OSS_AI_NATIVE_CAD_RESEARCH_2026-03-20.md",
|
||||
"docs/plans/18_todo_enchainement_autonome_des_lots_utiles.md",
|
||||
),
|
||||
owner_repo="Kill_LIFE",
|
||||
owner_agent="CAD-Fusion",
|
||||
owner_team="CAD-Fusion",
|
||||
dependencies=("cad-mcp-host",),
|
||||
rollback_plan=(
|
||||
"Geler la branche `kill-life-ai-native` et revenir au mode container-only (`tools/hw/cad_stack.sh`).",
|
||||
"Conserver les artefacts YiACAD (`artifacts/cad-fusion`) et la synthese hebdomadaire comme preuve.",
|
||||
"Basculer l'execution en mode `status` + `clean-logs` si le smoke bloque.",
|
||||
),
|
||||
handoff_write_set=(
|
||||
"docs/CAD_AI_NATIVE_FORK_STRATEGY.md",
|
||||
"docs/OSS_AI_NATIVE_CAD_RESEARCH_2026-03-20.md",
|
||||
"tools/cad/ai_native_forks.sh",
|
||||
"tools/cad/yiacad_fusion_lot.sh",
|
||||
"tools/cockpit/refonte_tui.sh",
|
||||
"tools/cockpit/render_weekly_refonte_summary.sh",
|
||||
),
|
||||
validations=(
|
||||
Validation(
|
||||
name="yiacad_prepare",
|
||||
cmd=("bash", "tools/cad/yiacad_fusion_lot.sh", "--action", "prepare"),
|
||||
),
|
||||
Validation(
|
||||
name="yiacad_status",
|
||||
cmd=("bash", "tools/cad/yiacad_fusion_lot.sh", "--action", "status"),
|
||||
),
|
||||
Validation(
|
||||
name="yiacad_smoke",
|
||||
cmd=("bash", "tools/cad/yiacad_fusion_lot.sh", "--action", "smoke"),
|
||||
required=False,
|
||||
),
|
||||
),
|
||||
),
|
||||
Lot(
|
||||
key="python-local",
|
||||
title="Execution Python repo-locale",
|
||||
@@ -194,6 +408,19 @@ LOTS: tuple[Lot, ...] = (
|
||||
"tools/test_python.sh",
|
||||
),
|
||||
plan_refs=("docs/plans/15_plan_mcp_runtime_alignment.md",),
|
||||
owner_repo="Kill_LIFE",
|
||||
owner_agent="Firmware",
|
||||
owner_team="Architect-Firmware",
|
||||
dependencies=("mcp-runtime", "cad-mcp-host"),
|
||||
rollback_plan=(
|
||||
"Revenir à l'exécution Python système si la venv locale est instable.",
|
||||
"Limiter les changements au lot `tools/test_python.sh` jusqu'à stabilisation.",
|
||||
),
|
||||
handoff_write_set=(
|
||||
"tools/test_python.sh",
|
||||
"tools/run_validate_specs_mcp.sh",
|
||||
"tools/validate_specs_mcp_smoke.py",
|
||||
),
|
||||
validations=(
|
||||
Validation(
|
||||
name="python_stable_suite",
|
||||
@@ -225,6 +452,29 @@ def now_label() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def summarize_lot_status(results: list[dict[str, object]]) -> str:
|
||||
if not results:
|
||||
return "pending"
|
||||
if any(item["status"] == "blocked" for item in results):
|
||||
return "blocked"
|
||||
if any(item["status"] == "advisory" for item in results):
|
||||
return "degraded"
|
||||
return "done"
|
||||
|
||||
|
||||
def lot_handoff_info(lot: Lot, results: list[dict[str, object]]) -> dict[str, object]:
|
||||
return {
|
||||
"lot_id": lot.key,
|
||||
"owner_repo": lot.owner_repo,
|
||||
"owner_agent": lot.owner_agent,
|
||||
"owner_subagent": lot.owner_subagent,
|
||||
"owner_team": lot.owner_team,
|
||||
"write_set": list(lot.handoff_write_set),
|
||||
"status": summarize_lot_status(results),
|
||||
"evidence": list(lot.plan_refs),
|
||||
}
|
||||
|
||||
|
||||
def run_cmd(cmd: tuple[str, ...]) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
list(cmd),
|
||||
@@ -309,6 +559,10 @@ def output_tail(text: str) -> str:
|
||||
|
||||
def classify_blocker(output: str) -> str | None:
|
||||
lower = output.lower()
|
||||
if "docker_timeout" in lower or "docker cli timeout" in lower or "timed out while checking" in lower:
|
||||
return "Docker CLI locale bloquee ou en timeout sur cette machine."
|
||||
if "cli_timeout" in lower:
|
||||
return "CLI locale en timeout pendant la validation."
|
||||
if any(token in lower for token in ("auth", "token", "permission", "credential", "secret")):
|
||||
return "Secrets/identifiants manquants pour une validation live."
|
||||
if "docker" in lower:
|
||||
@@ -345,6 +599,21 @@ def execute_lot(lot: Lot) -> list[dict[str, object]]:
|
||||
return results
|
||||
|
||||
|
||||
def load_intelligence_snapshot() -> dict[str, object] | None:
|
||||
if INTELLIGENCE_MEMORY_JSON.exists():
|
||||
try:
|
||||
return json.loads(INTELLIGENCE_MEMORY_JSON.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
proc = run_cmd(("bash", "tools/cockpit/intelligence_tui.sh", "--action", "memory", "--json"))
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
return json.loads(proc.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def render_plan(
|
||||
branch: str,
|
||||
dirty_paths: list[str],
|
||||
@@ -352,6 +621,7 @@ def render_plan(
|
||||
behind: int,
|
||||
lots: list[Lot],
|
||||
results: dict[str, list[dict[str, object]]],
|
||||
intelligence: dict[str, object] | None,
|
||||
) -> str:
|
||||
lines = [
|
||||
"# 18) Plan d'enchainement autonome des lots utiles",
|
||||
@@ -360,6 +630,15 @@ def render_plan(
|
||||
"",
|
||||
"Ce plan est regenere localement par `tools/autonomous_next_lots.py`.",
|
||||
"",
|
||||
"## Cycle lot opérationnel",
|
||||
"",
|
||||
"1. detection: `bash tools/run_autonomous_next_lots.sh status`",
|
||||
"2. run: `bash tools/run_autonomous_next_lots.sh run`",
|
||||
"3. proof JSON: `bash tools/run_autonomous_next_lots.sh json`",
|
||||
"4. mise a jour plan/todo",
|
||||
"5. revue owner + routing lot suivant",
|
||||
"6. synthese operateur + lot exit checklist",
|
||||
"",
|
||||
"## Objectif",
|
||||
"",
|
||||
"Detecter les deltas utiles a traiter, prioriser le prochain lot executable,",
|
||||
@@ -387,6 +666,26 @@ def render_plan(
|
||||
lines.append(f"- `{path}`")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Gouvernance intelligence")
|
||||
lines.append("")
|
||||
if intelligence:
|
||||
lines.append(f"- status: `{intelligence.get('status', 'unknown')}`")
|
||||
lines.append(f"- open_task_count: `{intelligence.get('open_task_count', 0)}`")
|
||||
memory_artifacts = intelligence.get("memory_artifacts") or {}
|
||||
json_path = memory_artifacts.get("json") or str(INTELLIGENCE_MEMORY_JSON.relative_to(ROOT))
|
||||
lines.append(f"- memory: `{json_path}`")
|
||||
next_steps = intelligence.get("next_steps") or []
|
||||
if next_steps:
|
||||
lines.append("- next actions:")
|
||||
for step in next_steps[:3]:
|
||||
lines.append(f" - {step}")
|
||||
else:
|
||||
lines.append("- intelligence snapshot: unavailable")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Matrice lot -> owner -> dépendances -> rollback")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Lots detectes")
|
||||
lines.append("")
|
||||
if not lots:
|
||||
@@ -401,6 +700,15 @@ def render_plan(
|
||||
for index, lot in enumerate(lots, start=1):
|
||||
lines.append(f"### {index}. `{lot.key}` — {lot.title}")
|
||||
lines.append("")
|
||||
lines.append(f"- owner: team={lot.owner_team}, agent={lot.owner_agent}, repo={lot.owner_repo}")
|
||||
if lot.dependencies:
|
||||
lines.append("- dependencies: " + ", ".join(f"`{dep}`" for dep in lot.dependencies))
|
||||
else:
|
||||
lines.append("- dependencies: `none`")
|
||||
if lot.rollback_plan:
|
||||
lines.append("- rollback:")
|
||||
for entry in lot.rollback_plan:
|
||||
lines.append(f" - {entry}")
|
||||
lines.append(lot.description)
|
||||
lines.append("")
|
||||
refs = ", ".join(f"`{ref}`" for ref in lot.plan_refs)
|
||||
@@ -415,6 +723,10 @@ def render_plan(
|
||||
)
|
||||
else:
|
||||
lines.append("- validations: non executees sur ce cycle")
|
||||
lines.append(
|
||||
f"- handoff: lot_id={lot.key}, owner_repo={lot.owner_repo}, owner_agent={lot.owner_agent}, owner_subagent={lot.owner_subagent or 'none'}, status={summarize_lot_status(lot_results)}"
|
||||
)
|
||||
lines.append(f"- write_set: {', '.join(lot.handoff_write_set)}")
|
||||
lines.append("")
|
||||
|
||||
blockers = sorted(
|
||||
@@ -439,11 +751,16 @@ def render_plan(
|
||||
lines.append("- `bash tools/run_autonomous_next_lots.sh status`")
|
||||
lines.append("- `bash tools/run_autonomous_next_lots.sh run`")
|
||||
lines.append("- `bash tools/run_autonomous_next_lots.sh json`")
|
||||
lines.append("- `bash tools/cockpit/render_weekly_refonte_summary.sh`")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_todo(lots: list[Lot], results: dict[str, list[dict[str, object]]]) -> str:
|
||||
def render_todo(
|
||||
lots: list[Lot],
|
||||
results: dict[str, list[dict[str, object]]],
|
||||
intelligence: dict[str, object] | None,
|
||||
) -> str:
|
||||
lines = [
|
||||
"# 18) TODO enchainement autonome des lots utiles",
|
||||
"",
|
||||
@@ -462,6 +779,18 @@ def render_todo(lots: list[Lot], results: dict[str, list[dict[str, object]]]) ->
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
if intelligence:
|
||||
lines.append("## `intelligence-governance`")
|
||||
lines.append("")
|
||||
lines.append(f"- status: {intelligence.get('status', 'unknown')}")
|
||||
lines.append(f"- open_task_count: {intelligence.get('open_task_count', 0)}")
|
||||
for step in (intelligence.get("next_steps") or [])[:3]:
|
||||
lines.append(f"- next: {step}")
|
||||
memory_artifacts = intelligence.get("memory_artifacts") or {}
|
||||
if memory_artifacts.get("json"):
|
||||
lines.append(f"- evidence: {memory_artifacts['json']}")
|
||||
lines.append("")
|
||||
|
||||
for lot in lots:
|
||||
lines.append(f"## `{lot.key}` — {lot.title}")
|
||||
lines.append("")
|
||||
@@ -476,6 +805,17 @@ def render_todo(lots: list[Lot], results: dict[str, list[dict[str, object]]]) ->
|
||||
summary = str(item["summary"] or "aucun detail")
|
||||
lines.append(f"- {status}: `{cmd}`")
|
||||
lines.append(f" resume: {summary}")
|
||||
lines.append(f"- handoff lot_id: {lot.key}")
|
||||
lines.append(f" owner_repo={lot.owner_repo}")
|
||||
lines.append(f" owner_agent={lot.owner_agent}")
|
||||
lines.append(f" owner_team={lot.owner_team}")
|
||||
if lot.dependencies:
|
||||
lines.append(" dependencies=" + ", ".join(f"{dep}" for dep in lot.dependencies))
|
||||
else:
|
||||
lines.append(" dependencies=none")
|
||||
if lot.rollback_plan:
|
||||
lines.append(" rollback=" + " | ".join(lot.rollback_plan))
|
||||
lines.append(f" write_set={', '.join(lot.handoff_write_set)}")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -497,8 +837,10 @@ def main() -> int:
|
||||
for lot in lots:
|
||||
results[lot.key] = execute_lot(lot)
|
||||
|
||||
plan_text = render_plan(branch, dirty_paths, ahead, behind, lots, results)
|
||||
todo_text = render_todo(lots, results)
|
||||
intelligence = load_intelligence_snapshot()
|
||||
|
||||
plan_text = render_plan(branch, dirty_paths, ahead, behind, lots, results, intelligence)
|
||||
todo_text = render_todo(lots, results, intelligence)
|
||||
should_write = not args.no_write and args.mode != "json"
|
||||
if should_write:
|
||||
write_docs(plan_text, todo_text)
|
||||
@@ -514,12 +856,21 @@ def main() -> int:
|
||||
"title": lot.title,
|
||||
"priority": lot.priority,
|
||||
"plan_refs": list(lot.plan_refs),
|
||||
"handoff": lot_handoff_info(lot, results.get(lot.key, [])),
|
||||
"dependencies": list(lot.dependencies),
|
||||
"rollback_plan": list(lot.rollback_plan),
|
||||
"results": results.get(lot.key, []),
|
||||
}
|
||||
for lot in lots
|
||||
],
|
||||
"plan_doc": str(PLAN_DOC.relative_to(ROOT)),
|
||||
"todo_doc": str(TODO_DOC.relative_to(ROOT)),
|
||||
"intelligence": {
|
||||
"status": intelligence.get("status", "unknown") if intelligence else "unknown",
|
||||
"open_task_count": intelligence.get("open_task_count", 0) if intelligence else 0,
|
||||
"next_steps": (intelligence.get("next_steps", []) if intelligence else []),
|
||||
"memory_artifacts": intelligence.get("memory_artifacts", {}) if intelligence else {},
|
||||
},
|
||||
}
|
||||
|
||||
if args.mode == "json":
|
||||
|
||||
@@ -209,9 +209,8 @@ if [[ -z "${MASCARADE_DIR:-}" ]]; then
|
||||
MASCARADE_DIR="$(
|
||||
kill_life_resolve_mascarade_dir \
|
||||
"$REPO_DIR" \
|
||||
"core/mascarade/integrations/knowledge_base.py" \
|
||||
"core/mascarade/integrations/github_dispatch.py" \
|
||||
"finetune/kicad_mcp_server"
|
||||
"core/mascarade" \
|
||||
"finetune"
|
||||
)"
|
||||
fi
|
||||
[[ -n "$MASCARADE_DIR" ]] || { echo "Unable to resolve mascarade companion path" >&2; exit 1; }
|
||||
|
||||
Executable
+335
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
BASE_DIR="${KILL_LIFE_CAD_AI_BASE_DIR:-$ROOT_DIR/.runtime-home/cad-ai-native-forks}"
|
||||
KICAD_UPSTREAM="KiCad/KiCad"
|
||||
FREECAD_UPSTREAM="FreeCAD/FreeCAD"
|
||||
TARGET_OWNER="${KILL_LIFE_FORK_OWNER:-electron-rare}"
|
||||
TARGET_BRANCH="${KILL_LIFE_FORK_BRANCH:-kill-life-ai-native}"
|
||||
CREATE_REMOTE_FORKS=0
|
||||
DRY_RUN=0
|
||||
FORCE=0
|
||||
PROJECTS="kicad freecad"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: tools/cad/ai_native_forks.sh [options]
|
||||
|
||||
Prépare des forks locaux KiCad/FreeCAD pour le lane IA-native Kill_LIFE.
|
||||
|
||||
Options:
|
||||
--owner OWNER Propriétaire GitHub du fork (défaut: electron-rare)
|
||||
--branch BRANCH Branche locale cible (défaut: kill-life-ai-native)
|
||||
--base-dir DIR Dossier base local (défaut: .runtime-home/cad-ai-native-forks)
|
||||
--projects LIST Projets à traiter, ex: "kicad freecad" (défaut: kicad freecad)
|
||||
--create-forks Crée les forks GitHub via `gh repo fork` si absents
|
||||
--dry-run Affiche les opérations sans écrire
|
||||
--force Écrase les répertoires existants
|
||||
-h, --help Aide
|
||||
|
||||
Env:
|
||||
KILL_LIFE_CAD_AI_BASE_DIR Override du dossier base local
|
||||
KILL_LIFE_FORK_OWNER Override du propriétaire du fork
|
||||
KILL_LIFE_FORK_BRANCH Override de la branche IA-native
|
||||
EOF
|
||||
}
|
||||
|
||||
log() {
|
||||
printf '[kill_life:cad-forks] %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
die() {
|
||||
printf '[kill_life:cad-forks][err] %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ensure_dir() {
|
||||
local dir="$1"
|
||||
if [ -d "$dir" ] && [ "$FORCE" -eq 1 ]; then
|
||||
rm -rf "$dir"
|
||||
fi
|
||||
mkdir -p "$dir"
|
||||
}
|
||||
|
||||
run_or_dry() {
|
||||
local cmd="$*"
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
log "dry-run: $cmd"
|
||||
return 0
|
||||
fi
|
||||
eval "$cmd"
|
||||
}
|
||||
|
||||
gh_auth_check() {
|
||||
command -v gh >/dev/null 2>&1 || die "gh CLI requis pour la création de forks"
|
||||
if ! gh auth status >/dev/null 2>&1; then
|
||||
die "gh CLI non authentifié. Lance `gh auth login` puis relance."
|
||||
fi
|
||||
}
|
||||
|
||||
create_fork_target() {
|
||||
local upstream_slug="$1"
|
||||
local target_repo="$2"
|
||||
|
||||
if gh repo view "$target_repo" >/dev/null 2>&1; then
|
||||
log "Fork existant: $target_repo"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Fork manquant: $target_repo"
|
||||
if ! gh repo fork "$upstream_slug" --org "$TARGET_OWNER" 2>/tmp/.kill_life_gh_fork_err; then
|
||||
if grep -qi "Fork\\.organization is invalid" /tmp/.kill_life_gh_fork_err; then
|
||||
log "Target owner is user account; retry without --org"
|
||||
if ! gh repo fork "$upstream_slug" 2>/tmp/.kill_life_gh_fork_err; then
|
||||
cat /tmp/.kill_life_gh_fork_err >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
cat /tmp/.kill_life_gh_fork_err >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_fork_repo() {
|
||||
local upstream_slug="$1"
|
||||
local target_owner="$2"
|
||||
local target_repo="$3"
|
||||
|
||||
if gh repo view "$target_repo" >/dev/null 2>&1; then
|
||||
printf '%s' "$target_repo"
|
||||
return 0
|
||||
fi
|
||||
|
||||
gh api \
|
||||
"repos/$upstream_slug/forks" \
|
||||
--paginate \
|
||||
--jq ".[] | select(.owner.login == \"$target_owner\") | .full_name" \
|
||||
| head -n 1
|
||||
}
|
||||
|
||||
ensure_remote() {
|
||||
local dir="$1"
|
||||
local name="$2"
|
||||
local url="$3"
|
||||
|
||||
if git -C "$dir" remote get-url "$name" >/dev/null 2>&1; then
|
||||
run_or_dry "git -C $dir remote set-url $name $url"
|
||||
return
|
||||
fi
|
||||
run_or_dry "git -C $dir remote add $name $url"
|
||||
}
|
||||
|
||||
ensure_remote_if_missing() {
|
||||
local dir="$1"
|
||||
local name="$2"
|
||||
local url="$3"
|
||||
|
||||
if git -C "$dir" remote get-url "$name" >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
run_or_dry "git -C $dir remote add $name $url"
|
||||
}
|
||||
|
||||
sync_remote_push_url() {
|
||||
local dir="$1"
|
||||
local name="$2"
|
||||
local url="$3"
|
||||
run_or_dry "git -C $dir remote set-url --push $name $url"
|
||||
}
|
||||
|
||||
sync_remote_push_url_if_missing() {
|
||||
local dir="$1"
|
||||
local name="$2"
|
||||
local url="$3"
|
||||
|
||||
if git -C "$dir" remote get-url "$name" >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
run_or_dry "git -C $dir remote set-url --push $name $url"
|
||||
}
|
||||
|
||||
derive_repo_name() {
|
||||
local slug="$1"
|
||||
printf '%s' "${slug##*/}"
|
||||
}
|
||||
|
||||
derive_repo_remote_url() {
|
||||
local owner="$1"
|
||||
local slug="$2"
|
||||
printf '%s' "https://github.com/$owner/$slug.git"
|
||||
}
|
||||
|
||||
ensure_project_repo() {
|
||||
local project="$1"
|
||||
local upstream_slug="$2"
|
||||
local upstream_url="$3"
|
||||
local repo_name
|
||||
local target_repo
|
||||
|
||||
repo_name="$(derive_repo_name "$upstream_slug")"
|
||||
target_repo="$TARGET_OWNER/$repo_name"
|
||||
local target_url
|
||||
target_url="$(derive_repo_remote_url "$TARGET_OWNER" "$repo_name")"
|
||||
local project_dir="$BASE_DIR/$project-ki"
|
||||
local default_branch
|
||||
local remote_head
|
||||
local source_url
|
||||
local resolved_target_repo
|
||||
local use_fork=1
|
||||
|
||||
log "=== $project ($upstream_slug) ==="
|
||||
ensure_dir "$BASE_DIR"
|
||||
|
||||
if [ "$CREATE_REMOTE_FORKS" -eq 1 ]; then
|
||||
gh_auth_check
|
||||
create_fork_target "$upstream_slug" "$target_repo" || die "Échec de création/référence du fork $target_repo"
|
||||
resolved_target_repo="$(resolve_fork_repo "$upstream_slug" "$TARGET_OWNER" "$target_repo")"
|
||||
if [ -z "$resolved_target_repo" ]; then
|
||||
die "Impossible de résoudre le nom du fork pour $target_repo"
|
||||
fi
|
||||
target_repo="$resolved_target_repo"
|
||||
target_url="$(derive_repo_remote_url "${resolved_target_repo%/*}" "${resolved_target_repo#*/}")"
|
||||
source_url="$target_url"
|
||||
else
|
||||
source_url="$upstream_url"
|
||||
use_fork=0
|
||||
fi
|
||||
|
||||
if [ -d "$project_dir/.git" ]; then
|
||||
log "Repo existante détectée: $project_dir"
|
||||
else
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
log "Création clone prévue: $project_dir <- $source_url"
|
||||
else
|
||||
if [ "$use_fork" -eq 1 ]; then
|
||||
if [ -d "$project_dir" ]; then
|
||||
rm -rf "$project_dir"
|
||||
fi
|
||||
fi
|
||||
log "Clone: $source_url"
|
||||
run_or_dry "git clone --filter=blob:none --depth=1 $source_url $project_dir"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" -eq 1 ] && [ ! -d "$project_dir/.git" ]; then
|
||||
log "skip remotes (dry-run + no local repo yet)"
|
||||
return
|
||||
fi
|
||||
|
||||
local canonical_origin
|
||||
if [ "$use_fork" -eq 1 ]; then
|
||||
ensure_remote "$project_dir" origin "$target_url"
|
||||
sync_remote_push_url "$project_dir" origin "$target_url"
|
||||
ensure_remote "$project_dir" upstream "$upstream_url"
|
||||
sync_remote_push_url "$project_dir" upstream "$upstream_url"
|
||||
else
|
||||
ensure_remote_if_missing "$project_dir" origin "$upstream_url"
|
||||
sync_remote_push_url_if_missing "$project_dir" origin "$upstream_url"
|
||||
ensure_remote "$project_dir" upstream "$upstream_url"
|
||||
sync_remote_push_url "$project_dir" upstream "$upstream_url"
|
||||
fi
|
||||
|
||||
remote_head="$(git -C $project_dir remote show origin 2>/dev/null | awk '/HEAD branch:/{print $NF}' | tr -d '[:space:]')"
|
||||
default_branch="${remote_head:-main}"
|
||||
if [ -z "$default_branch" ] || [ "$default_branch" = "(unknown)" ]; then
|
||||
default_branch="main"
|
||||
fi
|
||||
|
||||
log "default branch=$default_branch"
|
||||
run_or_dry "git -C $project_dir fetch --all --prune"
|
||||
if git -C "$project_dir" show-ref --verify --quiet "refs/heads/$TARGET_BRANCH"; then
|
||||
run_or_dry "git -C $project_dir switch $TARGET_BRANCH"
|
||||
else
|
||||
run_or_dry "git -C $project_dir switch -c $TARGET_BRANCH origin/$default_branch"
|
||||
fi
|
||||
|
||||
log "Statut $project: $(git -C $project_dir rev-parse --abbrev-ref HEAD) sur $(git -C $project_dir rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--owner)
|
||||
[ "$#" -ge 2 ] || die "--owner requires a value"
|
||||
TARGET_OWNER="$2"
|
||||
shift 2
|
||||
;;
|
||||
--branch)
|
||||
[ "$#" -ge 2 ] || die "--branch requires a value"
|
||||
TARGET_BRANCH="$2"
|
||||
shift 2
|
||||
;;
|
||||
--base-dir)
|
||||
[ "$#" -ge 2 ] || die "--base-dir requires a value"
|
||||
BASE_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--projects)
|
||||
[ "$#" -ge 2 ] || die "--projects requires a value"
|
||||
PROJECTS="$2"
|
||||
shift 2
|
||||
;;
|
||||
--create-forks)
|
||||
CREATE_REMOTE_FORKS=1
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=1
|
||||
shift
|
||||
;;
|
||||
--force)
|
||||
FORCE=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
break
|
||||
;;
|
||||
*)
|
||||
die "Option inconnue: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir -p "$BASE_DIR"
|
||||
manifest_path="$BASE_DIR/manifest.md"
|
||||
cat >"$manifest_path" <<EOF
|
||||
# CAD AI-native fork manifest
|
||||
BASE_DIR=$BASE_DIR
|
||||
OWNER=$TARGET_OWNER
|
||||
BRANCH=$TARGET_BRANCH
|
||||
DRY_RUN=$DRY_RUN
|
||||
CREATE_REMOTE_FORKS=$CREATE_REMOTE_FORKS
|
||||
EOF
|
||||
|
||||
for project in $PROJECTS; do
|
||||
case "$project" in
|
||||
kicad)
|
||||
ensure_project_repo "$project" "$KICAD_UPSTREAM" "$(derive_repo_remote_url "KiCad" "KiCad")"
|
||||
;;
|
||||
freecad)
|
||||
ensure_project_repo "$project" "$FREECAD_UPSTREAM" "$(derive_repo_remote_url "FreeCAD" "FreeCAD")"
|
||||
;;
|
||||
*)
|
||||
die "Projet inconnu: $project"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
{
|
||||
echo "## Remotes"
|
||||
for project in $PROJECTS; do
|
||||
if [ "$DRY_RUN" -eq 0 ] && [ -d "$BASE_DIR/$project-ki/.git" ]; then
|
||||
echo "### $project"
|
||||
git -C "$BASE_DIR/$project-ki" remote -v
|
||||
echo
|
||||
fi
|
||||
done
|
||||
} >>"$manifest_path"
|
||||
|
||||
log "Manifest écrit dans $manifest_path"
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
KICAD_SRC="${ROOT_DIR}/tools/cad/integrations/kicad/yiacad_kicad_plugin"
|
||||
FREECAD_SRC="${ROOT_DIR}/tools/cad/integrations/freecad/YiACADWorkbench"
|
||||
|
||||
KICAD_PLUGIN_DIR="${KICAD_PLUGIN_DIR:-${HOME}/Library/Application Support/kicad/scripting/plugins}"
|
||||
FREECAD_MOD_DIR="${FREECAD_MOD_DIR:-${HOME}/Library/Application Support/FreeCAD/Mod}"
|
||||
|
||||
COMMAND="status"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: bash tools/cad/install_yiacad_native_gui.sh <status|install|uninstall>
|
||||
|
||||
Install or inspect the YiACAD native GUI surfaces for KiCad and FreeCAD.
|
||||
|
||||
Env overrides:
|
||||
KICAD_PLUGIN_DIR
|
||||
FREECAD_MOD_DIR
|
||||
EOF
|
||||
}
|
||||
|
||||
link_target() {
|
||||
local link_path="$1"
|
||||
if [[ -L "$link_path" ]]; then
|
||||
readlink "$link_path"
|
||||
elif [[ -e "$link_path" ]]; then
|
||||
printf '%s' "<non-symlink>"
|
||||
else
|
||||
printf '%s' "<missing>"
|
||||
fi
|
||||
}
|
||||
|
||||
status() {
|
||||
printf 'ROOT_DIR=%s\n' "$ROOT_DIR"
|
||||
printf 'KICAD_PLUGIN_DIR=%s\n' "$KICAD_PLUGIN_DIR"
|
||||
printf 'FREECAD_MOD_DIR=%s\n' "$FREECAD_MOD_DIR"
|
||||
printf 'KICAD_LINK=%s\n' "$(link_target "${KICAD_PLUGIN_DIR}/yiacad_kicad_plugin")"
|
||||
printf 'FREECAD_LINK=%s\n' "$(link_target "${FREECAD_MOD_DIR}/YiACADWorkbench")"
|
||||
}
|
||||
|
||||
install_links() {
|
||||
mkdir -p "$KICAD_PLUGIN_DIR" "$FREECAD_MOD_DIR"
|
||||
ln -sfn "$KICAD_SRC" "${KICAD_PLUGIN_DIR}/yiacad_kicad_plugin"
|
||||
ln -sfn "$FREECAD_SRC" "${FREECAD_MOD_DIR}/YiACADWorkbench"
|
||||
status
|
||||
}
|
||||
|
||||
uninstall_links() {
|
||||
rm -f "${KICAD_PLUGIN_DIR}/yiacad_kicad_plugin"
|
||||
rm -f "${FREECAD_MOD_DIR}/YiACADWorkbench"
|
||||
status
|
||||
}
|
||||
|
||||
if [[ $# -gt 0 ]]; then
|
||||
COMMAND="$1"
|
||||
fi
|
||||
|
||||
case "$COMMAND" in
|
||||
status)
|
||||
status
|
||||
;;
|
||||
install)
|
||||
install_links
|
||||
;;
|
||||
uninstall)
|
||||
uninstall_links
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo "Unknown command: $COMMAND" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1 @@
|
||||
# YiACAD FreeCAD workbench bootstrap placeholder.
|
||||
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import FreeCADGui # type: ignore
|
||||
|
||||
from . import yiacad_freecad_gui
|
||||
|
||||
|
||||
class _OpenPanelCommand:
|
||||
def GetResources(self):
|
||||
return {
|
||||
"MenuText": "YiACAD AI Panel",
|
||||
"ToolTip": "Open the YiACAD AI dialog for FreeCAD",
|
||||
"Pixmap": os.path.join(os.path.dirname(__file__), "Resources", "icons", "yiacad_ai.svg"),
|
||||
}
|
||||
|
||||
def Activated(self):
|
||||
yiacad_freecad_gui.show_dialog()
|
||||
|
||||
def IsActive(self):
|
||||
return True
|
||||
|
||||
|
||||
class _StatusCommand:
|
||||
def GetResources(self):
|
||||
return {
|
||||
"MenuText": "YiACAD Status",
|
||||
"ToolTip": "Show the latest YiACAD status snapshot",
|
||||
"Pixmap": os.path.join(os.path.dirname(__file__), "Resources", "icons", "yiacad_ai.svg"),
|
||||
}
|
||||
|
||||
def Activated(self):
|
||||
yiacad_freecad_gui.show_status_message()
|
||||
|
||||
def IsActive(self):
|
||||
return True
|
||||
|
||||
|
||||
class _ArtifactsCommand:
|
||||
def GetResources(self):
|
||||
return {
|
||||
"MenuText": "YiACAD Artifacts",
|
||||
"ToolTip": "Open the local Kill_LIFE artifacts folder",
|
||||
"Pixmap": os.path.join(os.path.dirname(__file__), "Resources", "icons", "yiacad_ai.svg"),
|
||||
}
|
||||
|
||||
def Activated(self):
|
||||
yiacad_freecad_gui.open_artifacts()
|
||||
|
||||
def IsActive(self):
|
||||
return True
|
||||
|
||||
|
||||
class YiACADWorkbench(FreeCADGui.Workbench): # type: ignore[misc]
|
||||
MenuText = "YiACAD AI"
|
||||
ToolTip = "AI-native CAD utilities for FreeCAD linked to Kill_LIFE"
|
||||
Icon = os.path.join(os.path.dirname(__file__), "Resources", "icons", "yiacad_ai.svg")
|
||||
|
||||
def Initialize(self):
|
||||
FreeCADGui.addCommand("YiACAD_OpenPanel", _OpenPanelCommand())
|
||||
FreeCADGui.addCommand("YiACAD_Status", _StatusCommand())
|
||||
FreeCADGui.addCommand("YiACAD_Artifacts", _ArtifactsCommand())
|
||||
self.appendToolbar("YiACAD AI", ["YiACAD_OpenPanel", "YiACAD_Status", "YiACAD_Artifacts"])
|
||||
self.appendMenu("YiACAD AI", ["YiACAD_OpenPanel", "YiACAD_Status", "YiACAD_Artifacts"])
|
||||
|
||||
def GetClassName(self):
|
||||
return "Gui::PythonWorkbench"
|
||||
|
||||
|
||||
FreeCADGui.addWorkbench(YiACADWorkbench())
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect x="6" y="8" width="52" height="48" rx="8" fill="#10283a"/>
|
||||
<rect x="14" y="16" width="16" height="16" rx="3" fill="#f6c445"/>
|
||||
<rect x="34" y="16" width="16" height="16" rx="3" fill="#5bc0eb"/>
|
||||
<rect x="14" y="36" width="36" height="10" rx="3" fill="#f2f4f8"/>
|
||||
<circle cx="52" cy="42" r="6" fill="#ee6c4d"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1,154 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import FreeCAD # type: ignore
|
||||
import FreeCADGui # type: ignore
|
||||
|
||||
try:
|
||||
from PySide2 import QtWidgets # type: ignore
|
||||
except Exception: # pragma: no cover
|
||||
from PySide import QtGui as QtWidgets # type: ignore
|
||||
|
||||
|
||||
def _candidate_roots() -> list[Path]:
|
||||
candidates: list[Path] = []
|
||||
if os.environ.get("KILL_LIFE_ROOT"):
|
||||
candidates.append(Path(os.environ["KILL_LIFE_ROOT"]).expanduser())
|
||||
here = Path(__file__).resolve()
|
||||
candidates.extend(here.parents)
|
||||
return candidates
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
for candidate in _candidate_roots():
|
||||
bridge = candidate / "tools" / "cad" / "yiacad_ai_bridge.py"
|
||||
if bridge.exists():
|
||||
return candidate
|
||||
raise RuntimeError("Unable to locate Kill_LIFE root for YiACAD bridge")
|
||||
|
||||
|
||||
def bridge_script() -> Path:
|
||||
return repo_root() / "tools" / "cad" / "yiacad_ai_bridge.py"
|
||||
|
||||
|
||||
def run_bridge(args: list[str]) -> dict:
|
||||
proc = subprocess.run(
|
||||
["python3", str(bridge_script()), *args],
|
||||
cwd=repo_root(),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "YiACAD bridge failed")
|
||||
return json.loads(proc.stdout.strip() or "{}")
|
||||
|
||||
|
||||
def current_document_path() -> str:
|
||||
doc = FreeCAD.ActiveDocument
|
||||
if doc is None:
|
||||
return ""
|
||||
return str(getattr(doc, "FileName", "") or "")
|
||||
|
||||
|
||||
def selection_summary() -> list[str]:
|
||||
selected = []
|
||||
for item in FreeCADGui.Selection.getSelectionEx():
|
||||
if getattr(item, "ObjectName", None):
|
||||
selected.append(str(item.ObjectName))
|
||||
return selected
|
||||
|
||||
|
||||
def _status_summary() -> str:
|
||||
payload = run_bridge(["status"])
|
||||
lines = payload.get("yiacad_status_excerpt") or []
|
||||
latest_request = payload.get("latest_request") or "(none)"
|
||||
excerpt = "\n".join(lines[:8]) if lines else "No YiACAD status snapshot yet."
|
||||
return f"Latest request:\n{latest_request}\n\nStatus:\n{excerpt}"
|
||||
|
||||
|
||||
def show_status_message() -> None:
|
||||
QtWidgets.QMessageBox.information(None, "YiACAD Status", _status_summary())
|
||||
|
||||
|
||||
def open_artifacts() -> None:
|
||||
subprocess.Popen(["open", str(repo_root() / "artifacts")])
|
||||
|
||||
|
||||
class YiACADDialog(QtWidgets.QDialog):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(None)
|
||||
self.setWindowTitle("YiACAD AI for FreeCAD")
|
||||
self.resize(560, 420)
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
|
||||
self.intent = QtWidgets.QComboBox(self)
|
||||
self.intent.addItems(
|
||||
[
|
||||
"model-assist",
|
||||
"parametric-refactor",
|
||||
"step-export-review",
|
||||
"ecad-mcad-sync",
|
||||
]
|
||||
)
|
||||
self.source = QtWidgets.QLineEdit(self)
|
||||
self.source.setReadOnly(True)
|
||||
self.source.setText(current_document_path())
|
||||
self.prompt = QtWidgets.QPlainTextEdit(self)
|
||||
self.prompt.setPlainText("Describe the FreeCAD task to queue for YiACAD.")
|
||||
|
||||
layout.addWidget(QtWidgets.QLabel("Intent", self))
|
||||
layout.addWidget(self.intent)
|
||||
layout.addWidget(QtWidgets.QLabel("Document / source path", self))
|
||||
layout.addWidget(self.source)
|
||||
layout.addWidget(QtWidgets.QLabel("Prompt", self))
|
||||
layout.addWidget(self.prompt)
|
||||
|
||||
button_row = QtWidgets.QHBoxLayout()
|
||||
queue_button = QtWidgets.QPushButton("Queue AI Request", self)
|
||||
status_button = QtWidgets.QPushButton("YiACAD Status", self)
|
||||
artifacts_button = QtWidgets.QPushButton("Open Artifacts", self)
|
||||
close_button = QtWidgets.QPushButton("Close", self)
|
||||
|
||||
queue_button.clicked.connect(self.on_queue)
|
||||
status_button.clicked.connect(lambda: show_status_message())
|
||||
artifacts_button.clicked.connect(lambda: open_artifacts())
|
||||
close_button.clicked.connect(self.close)
|
||||
|
||||
for button in (queue_button, status_button, artifacts_button, close_button):
|
||||
button_row.addWidget(button)
|
||||
|
||||
layout.addLayout(button_row)
|
||||
|
||||
def on_queue(self) -> None:
|
||||
payload = run_bridge(
|
||||
[
|
||||
"request",
|
||||
"--surface",
|
||||
"freecad",
|
||||
"--intent",
|
||||
self.intent.currentText(),
|
||||
"--prompt",
|
||||
self.prompt.toPlainText().strip(),
|
||||
"--source-path",
|
||||
self.source.text().strip(),
|
||||
"--selection-json",
|
||||
json.dumps(selection_summary(), ensure_ascii=True),
|
||||
]
|
||||
)
|
||||
QtWidgets.QMessageBox.information(
|
||||
self,
|
||||
"YiACAD",
|
||||
f"Queued request:\n{payload.get('request_path', '')}",
|
||||
)
|
||||
|
||||
|
||||
def show_dialog() -> None:
|
||||
dialog = YiACADDialog()
|
||||
dialog.exec_()
|
||||
@@ -0,0 +1,3 @@
|
||||
from .yiacad_action import YiACADActionPlugin
|
||||
|
||||
YiACADActionPlugin().register()
|
||||
@@ -0,0 +1,4 @@
|
||||
from ._common import fetch_status_payload
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(fetch_status_payload())
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _candidate_roots() -> list[Path]:
|
||||
candidates: list[Path] = []
|
||||
if os.environ.get("KILL_LIFE_ROOT"):
|
||||
candidates.append(Path(os.environ["KILL_LIFE_ROOT"]).expanduser())
|
||||
here = Path(__file__).resolve()
|
||||
candidates.extend(here.parents)
|
||||
return candidates
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
for candidate in _candidate_roots():
|
||||
bridge = candidate / "tools" / "cad" / "yiacad_ai_bridge.py"
|
||||
if bridge.exists():
|
||||
return candidate
|
||||
raise RuntimeError("Unable to locate Kill_LIFE root for YiACAD bridge")
|
||||
|
||||
|
||||
def bridge_script() -> Path:
|
||||
return repo_root() / "tools" / "cad" / "yiacad_ai_bridge.py"
|
||||
|
||||
|
||||
def run_bridge(args: list[str]) -> dict:
|
||||
proc = subprocess.run(
|
||||
["python3", str(bridge_script()), *args],
|
||||
cwd=repo_root(),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "YiACAD bridge failed")
|
||||
return json.loads(proc.stdout.strip() or "{}")
|
||||
|
||||
|
||||
def queue_request(surface: str, intent: str, prompt: str, source_path: str, selection: list[str]) -> dict:
|
||||
return run_bridge(
|
||||
[
|
||||
"request",
|
||||
"--surface",
|
||||
surface,
|
||||
"--intent",
|
||||
intent,
|
||||
"--prompt",
|
||||
prompt,
|
||||
"--source-path",
|
||||
source_path,
|
||||
"--selection-json",
|
||||
json.dumps(selection, ensure_ascii=True),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def fetch_status_payload() -> dict:
|
||||
return run_bridge(["status"])
|
||||
|
||||
|
||||
def open_path(path: Path) -> None:
|
||||
subprocess.Popen(["open", str(path)])
|
||||
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._common import fetch_status_payload, open_path, queue_request, repo_root
|
||||
|
||||
try:
|
||||
import pcbnew # type: ignore
|
||||
import wx # type: ignore
|
||||
except Exception: # pragma: no cover
|
||||
pcbnew = None
|
||||
wx = None
|
||||
|
||||
|
||||
INTENTS = [
|
||||
"board-review",
|
||||
"erc-drc-assist",
|
||||
"bom-footprint-audit",
|
||||
"ecad-mcad-sync",
|
||||
]
|
||||
|
||||
|
||||
def _board_path() -> str:
|
||||
if pcbnew is None:
|
||||
return ""
|
||||
try:
|
||||
board = pcbnew.GetBoard()
|
||||
if board is None:
|
||||
return ""
|
||||
return board.GetFileName() or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _selection_summary() -> list[str]:
|
||||
path = _board_path()
|
||||
return [path] if path else []
|
||||
|
||||
|
||||
class YiACADDialog(wx.Dialog): # type: ignore[misc]
|
||||
def __init__(self) -> None:
|
||||
super().__init__(None, title="YiACAD AI for KiCad", size=(540, 420))
|
||||
panel = wx.Panel(self)
|
||||
sizer = wx.BoxSizer(wx.VERTICAL)
|
||||
|
||||
self.intent = wx.Choice(panel, choices=INTENTS)
|
||||
self.intent.SetSelection(0)
|
||||
self.source = wx.TextCtrl(panel, value=_board_path(), style=wx.TE_READONLY)
|
||||
self.prompt = wx.TextCtrl(panel, style=wx.TE_MULTILINE, value="Describe the KiCad task to queue for YiACAD.")
|
||||
|
||||
sizer.Add(wx.StaticText(panel, label="Intent"), 0, wx.ALL, 8)
|
||||
sizer.Add(self.intent, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 8)
|
||||
sizer.Add(wx.StaticText(panel, label="Board / source path"), 0, wx.ALL, 8)
|
||||
sizer.Add(self.source, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 8)
|
||||
sizer.Add(wx.StaticText(panel, label="Prompt"), 0, wx.ALL, 8)
|
||||
sizer.Add(self.prompt, 1, wx.EXPAND | wx.LEFT | wx.RIGHT, 8)
|
||||
|
||||
button_row = wx.BoxSizer(wx.HORIZONTAL)
|
||||
queue_btn = wx.Button(panel, label="Queue AI Request")
|
||||
status_btn = wx.Button(panel, label="YiACAD Status")
|
||||
artifacts_btn = wx.Button(panel, label="Open Artifacts")
|
||||
close_btn = wx.Button(panel, label="Close")
|
||||
|
||||
queue_btn.Bind(wx.EVT_BUTTON, self.on_queue)
|
||||
status_btn.Bind(wx.EVT_BUTTON, self.on_status)
|
||||
artifacts_btn.Bind(wx.EVT_BUTTON, self.on_artifacts)
|
||||
close_btn.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.ID_OK))
|
||||
|
||||
for button in (queue_btn, status_btn, artifacts_btn, close_btn):
|
||||
button_row.Add(button, 0, wx.ALL, 6)
|
||||
|
||||
sizer.Add(button_row, 0, wx.ALIGN_RIGHT | wx.ALL, 8)
|
||||
panel.SetSizer(sizer)
|
||||
|
||||
def on_queue(self, _event) -> None:
|
||||
payload = queue_request(
|
||||
"kicad",
|
||||
self.intent.GetStringSelection(),
|
||||
self.prompt.GetValue().strip(),
|
||||
self.source.GetValue().strip(),
|
||||
_selection_summary(),
|
||||
)
|
||||
wx.MessageBox(
|
||||
f"Queued request:\n{payload.get('request_path', '')}",
|
||||
"YiACAD",
|
||||
wx.OK | wx.ICON_INFORMATION,
|
||||
)
|
||||
|
||||
def on_status(self, _event) -> None:
|
||||
payload = fetch_status_payload()
|
||||
lines = payload.get("yiacad_status_excerpt") or []
|
||||
summary = "\n".join(lines[:8]) if lines else "No YiACAD status snapshot yet."
|
||||
latest_request = payload.get("latest_request") or "(none)"
|
||||
wx.MessageBox(
|
||||
f"Latest request:\n{latest_request}\n\nStatus:\n{summary}",
|
||||
"YiACAD Status",
|
||||
wx.OK | wx.ICON_INFORMATION,
|
||||
)
|
||||
|
||||
def on_artifacts(self, _event) -> None:
|
||||
open_path(repo_root() / "artifacts")
|
||||
|
||||
|
||||
class YiACADActionPlugin(pcbnew.ActionPlugin if pcbnew is not None else object): # type: ignore[misc]
|
||||
def defaults(self) -> None:
|
||||
self.name = "YiACAD AI Bridge"
|
||||
self.category = "Kill_LIFE AI-native"
|
||||
self.description = "Queue KiCad AI tasks into YiACAD and inspect the latest CAD status."
|
||||
self.show_toolbar_button = False
|
||||
|
||||
def Run(self) -> None:
|
||||
if wx is None:
|
||||
raise RuntimeError("wx is unavailable in the current KiCad runtime")
|
||||
dialog = YiACADDialog()
|
||||
dialog.ShowModal()
|
||||
dialog.Destroy()
|
||||
@@ -0,0 +1 @@
|
||||
proof fixture for YiACAD backend proof
|
||||
@@ -0,0 +1 @@
|
||||
proof fixture for YiACAD backend proof
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
KICAD_PLUGIN_DIR="${KICAD_PLUGIN_DIR:-${HOME}/Library/Application Support/kicad/scripting/plugins}"
|
||||
FREECAD_MOD_DIR="${FREECAD_MOD_DIR:-${HOME}/Library/Application Support/FreeCAD/Mod}"
|
||||
KICAD_SURFACE="${ROOT_DIR}/.runtime-home/cad-ai-native-forks/kicad-ki/scripting/plugins/yiacad_kicad_plugin"
|
||||
FREECAD_SURFACE="${ROOT_DIR}/.runtime-home/cad-ai-native-forks/freecad-ki/src/Mod/YiACADWorkbench"
|
||||
|
||||
mkdir -p "${KICAD_PLUGIN_DIR}" "${FREECAD_MOD_DIR}"
|
||||
ln -sfn "${KICAD_SURFACE}" "${KICAD_PLUGIN_DIR}/yiacad_kicad_plugin"
|
||||
ln -sfn "${FREECAD_SURFACE}" "${FREECAD_MOD_DIR}/YiACADWorkbench"
|
||||
|
||||
printf 'ROOT_DIR=%s\n' "${ROOT_DIR}"
|
||||
printf 'KICAD_SURFACE=%s\n' "${KICAD_SURFACE}"
|
||||
printf 'FREECAD_SURFACE=%s\n' "${FREECAD_SURFACE}"
|
||||
printf 'KICAD_PLUGIN_DIR=%s\n' "${KICAD_PLUGIN_DIR}"
|
||||
printf 'FREECAD_MOD_DIR=%s\n' "${FREECAD_MOD_DIR}"
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shared request bridge for KiCad and FreeCAD AI-native helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
REQUEST_DIR = ROOT / "artifacts" / "cad-ai-requests"
|
||||
YIACAD_STATUS = ROOT / "artifacts" / "cad-fusion" / "yiacad-fusion-last-status.md"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
req = sub.add_parser("request")
|
||||
req.add_argument("--surface", choices=("kicad", "freecad"), required=True)
|
||||
req.add_argument("--intent", required=True)
|
||||
req.add_argument("--prompt", default="")
|
||||
req.add_argument("--source-path", default="")
|
||||
req.add_argument("--selection-json", default="[]")
|
||||
|
||||
sub.add_parser("status")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def ensure_request_dir() -> None:
|
||||
REQUEST_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def selection_from_json(raw: str) -> list[Any]:
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return [raw] if raw else []
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
return [payload]
|
||||
|
||||
|
||||
def latest_request() -> Path | None:
|
||||
ensure_request_dir()
|
||||
files = sorted(REQUEST_DIR.glob("*.json"), key=lambda item: item.stat().st_mtime, reverse=True)
|
||||
return files[0] if files else None
|
||||
|
||||
|
||||
def latest_status_excerpt() -> list[str]:
|
||||
if not YIACAD_STATUS.exists():
|
||||
return []
|
||||
lines = [line.rstrip() for line in YIACAD_STATUS.read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
return lines[:12]
|
||||
|
||||
|
||||
def command_request(args: argparse.Namespace) -> int:
|
||||
ensure_request_dir()
|
||||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
request_path = REQUEST_DIR / f"{stamp}_{args.surface}_{args.intent}.json"
|
||||
payload = {
|
||||
"id": request_path.stem,
|
||||
"created_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"surface": args.surface,
|
||||
"intent": args.intent,
|
||||
"prompt": args.prompt,
|
||||
"source_path": args.source_path,
|
||||
"selection": selection_from_json(args.selection_json),
|
||||
"status_hint": str(YIACAD_STATUS),
|
||||
}
|
||||
request_path.write_text(json.dumps(payload, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "queued",
|
||||
"request_path": str(request_path),
|
||||
"surface": args.surface,
|
||||
"intent": args.intent,
|
||||
},
|
||||
ensure_ascii=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def command_status(_: argparse.Namespace) -> int:
|
||||
last_request = latest_request()
|
||||
payload = {
|
||||
"request_dir": str(REQUEST_DIR),
|
||||
"latest_request": str(last_request) if last_request else "",
|
||||
"yiacad_status": str(YIACAD_STATUS),
|
||||
"yiacad_status_exists": YIACAD_STATUS.exists(),
|
||||
"yiacad_status_excerpt": latest_status_excerpt(),
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=True))
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
if args.command == "request":
|
||||
return command_request(args)
|
||||
if args.command == "status":
|
||||
return command_status(args)
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Local YiACAD backend helpers shared by native shells, TUI and CAD utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
ARTIFACTS_ROOT = ROOT / "artifacts" / "cad-ai-native"
|
||||
FUSION_STATUS = ROOT / "artifacts" / "cad-fusion" / "yiacad-fusion-last-status.md"
|
||||
|
||||
|
||||
def utc_timestamp() -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
def resolve_path(value: str | Path | None) -> Path | None:
|
||||
if not value:
|
||||
return None
|
||||
path = Path(value).expanduser()
|
||||
try:
|
||||
return path.resolve()
|
||||
except FileNotFoundError:
|
||||
return path.resolve(strict=False)
|
||||
|
||||
|
||||
def first_existing_path(*candidates: str | Path | None) -> Path | None:
|
||||
for candidate in candidates:
|
||||
path = resolve_path(candidate)
|
||||
if path and path.exists():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def infer_surface(
|
||||
*,
|
||||
board: str | Path | None = None,
|
||||
schematic: str | Path | None = None,
|
||||
freecad_document: str | Path | None = None,
|
||||
) -> str:
|
||||
has_board = bool(first_existing_path(board))
|
||||
has_schematic = bool(first_existing_path(schematic))
|
||||
has_freecad = bool(first_existing_path(freecad_document))
|
||||
if has_board and not has_schematic and not has_freecad:
|
||||
return "kicad-pcb"
|
||||
if has_schematic and not has_board and not has_freecad:
|
||||
return "kicad-sch"
|
||||
if has_freecad and not has_board and not has_schematic:
|
||||
return "freecad-workbench"
|
||||
return "tui"
|
||||
|
||||
|
||||
def build_context_ref(
|
||||
*,
|
||||
source_path: str | Path | None = None,
|
||||
board: str | Path | None = None,
|
||||
schematic: str | Path | None = None,
|
||||
freecad_document: str | Path | None = None,
|
||||
) -> str | None:
|
||||
candidate = first_existing_path(board, schematic, freecad_document, source_path)
|
||||
if not candidate:
|
||||
for fallback in (board, schematic, freecad_document, source_path):
|
||||
candidate = resolve_path(fallback)
|
||||
if candidate:
|
||||
break
|
||||
if not candidate:
|
||||
return None
|
||||
workspace = candidate if candidate.is_dir() else candidate.parent
|
||||
leaf = candidate.name if candidate.is_dir() else candidate.stem
|
||||
return f"project:{workspace.name}/{leaf}"
|
||||
|
||||
|
||||
def build_context_record(
|
||||
surface: str,
|
||||
*,
|
||||
source_path: str | Path | None = None,
|
||||
board: str | Path | None = None,
|
||||
schematic: str | Path | None = None,
|
||||
freecad_document: str | Path | None = None,
|
||||
artifacts_dir: str | Path | None = None,
|
||||
) -> dict:
|
||||
source = resolve_path(source_path)
|
||||
board_path = resolve_path(board)
|
||||
schematic_path = resolve_path(schematic)
|
||||
freecad_path = resolve_path(freecad_document)
|
||||
artifacts_path = resolve_path(artifacts_dir)
|
||||
return {
|
||||
"component": "yiacad-context",
|
||||
"generated_at": utc_timestamp(),
|
||||
"surface": surface,
|
||||
"context_ref": build_context_ref(
|
||||
source_path=source,
|
||||
board=board_path,
|
||||
schematic=schematic_path,
|
||||
freecad_document=freecad_path,
|
||||
),
|
||||
"paths": {
|
||||
"source_path": str(source) if source else None,
|
||||
"board": str(board_path) if board_path else None,
|
||||
"schematic": str(schematic_path) if schematic_path else None,
|
||||
"freecad_document": str(freecad_path) if freecad_path else None,
|
||||
"artifacts_dir": str(artifacts_path) if artifacts_path else None,
|
||||
},
|
||||
"runtime": {
|
||||
"root": str(ROOT),
|
||||
"artifacts_root": str(ARTIFACTS_ROOT),
|
||||
"fusion_status_path": str(FUSION_STATUS) if FUSION_STATUS.exists() else None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def artifact_entry(path: str | Path, kind: str, label: str | None = None) -> dict:
|
||||
resolved = resolve_path(path)
|
||||
return {
|
||||
"kind": kind,
|
||||
"path": str(resolved) if resolved else str(path),
|
||||
"label": label,
|
||||
}
|
||||
|
||||
|
||||
def output_status_from_returncodes(returncodes: list[int]) -> tuple[str, str]:
|
||||
if not returncodes:
|
||||
return ("blocked", "error")
|
||||
if any(code not in (0, 5) for code in returncodes):
|
||||
return ("blocked", "error")
|
||||
if any(code == 5 for code in returncodes):
|
||||
return ("degraded", "warning")
|
||||
return ("done", "info")
|
||||
|
||||
|
||||
def build_uiux_output(
|
||||
*,
|
||||
surface: str,
|
||||
action: str,
|
||||
execution_mode: str,
|
||||
status: str,
|
||||
severity: str,
|
||||
summary: str,
|
||||
details: str | None,
|
||||
context_ref: str | None,
|
||||
artifacts: list[dict],
|
||||
next_steps: list[str],
|
||||
latency_ms: int | None = None,
|
||||
) -> dict:
|
||||
return {
|
||||
"component": "yiacad",
|
||||
"surface": surface,
|
||||
"action": action,
|
||||
"execution_mode": execution_mode,
|
||||
"status": status,
|
||||
"severity": severity,
|
||||
"summary": summary,
|
||||
"details": details,
|
||||
"generated_at": utc_timestamp(),
|
||||
"context_ref": context_ref,
|
||||
"provider": None,
|
||||
"model": None,
|
||||
"latency_ms": latency_ms,
|
||||
"artifacts": artifacts,
|
||||
"next_steps": next_steps,
|
||||
}
|
||||
|
||||
|
||||
def write_json(path: str | Path, payload: object) -> Path:
|
||||
target = Path(path)
|
||||
target.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
return target
|
||||
|
||||
|
||||
def write_context_record(path: str | Path, payload: dict) -> Path:
|
||||
return write_json(path, payload)
|
||||
|
||||
|
||||
def write_uiux_output(path: str | Path, payload: dict) -> Path:
|
||||
return write_json(path, payload)
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""YiACAD backend client with service-first transport and direct fallback."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SERVICE_SCRIPT = ROOT / "tools" / "cad" / "yiacad_backend_service.py"
|
||||
NATIVE_OPS = ROOT / "tools" / "cad" / "yiacad_native_ops.py"
|
||||
SERVICE_ARTIFACTS = ROOT / "artifacts" / "cad-ai-native" / "service"
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 38435
|
||||
|
||||
|
||||
def ensure_dir(path: Path) -> Path:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def service_url(host: str, port: int, suffix: str) -> str:
|
||||
return f"http://{host}:{port}{suffix}"
|
||||
|
||||
|
||||
def http_json(url: str, payload: dict | None = None, timeout: float = 1.5) -> dict:
|
||||
data = None
|
||||
method = "GET"
|
||||
headers = {}
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
method = "POST"
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
|
||||
def service_health(host: str, port: int) -> dict | None:
|
||||
try:
|
||||
return http_json(service_url(host, port, "/health"))
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def start_service(host: str, port: int) -> None:
|
||||
ensure_dir(SERVICE_ARTIFACTS)
|
||||
stamp = time.strftime("%Y%m%d_%H%M%S")
|
||||
log_path = SERVICE_ARTIFACTS / f"backend_service_{stamp}.log"
|
||||
with log_path.open("a", encoding="utf-8") as handle:
|
||||
subprocess.Popen(
|
||||
[sys.executable, str(SERVICE_SCRIPT), "--host", host, "--port", str(port)],
|
||||
stdout=handle,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
|
||||
def ensure_service(host: str, port: int) -> bool:
|
||||
if service_health(host, port):
|
||||
return True
|
||||
start_service(host, port)
|
||||
for _ in range(10):
|
||||
time.sleep(0.25)
|
||||
if service_health(host, port):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def direct_fallback(argv: list[str]) -> int:
|
||||
proc = subprocess.run([sys.executable, str(NATIVE_OPS), *argv], text=True, capture_output=True)
|
||||
if proc.stdout:
|
||||
print(proc.stdout.strip())
|
||||
elif proc.stderr:
|
||||
print(proc.stderr.strip())
|
||||
return proc.returncode
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="YiACAD backend client")
|
||||
parser.add_argument("--host", default=DEFAULT_HOST, help="Service host")
|
||||
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Service port")
|
||||
parser.add_argument("--json-output", action="store_true", help="Emit JSON output")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
subparsers.add_parser("health", help="Check YiACAD backend service health")
|
||||
|
||||
status = subparsers.add_parser("status", help="YiACAD status")
|
||||
status.add_argument("--source-path", default="")
|
||||
|
||||
erc = subparsers.add_parser("kicad-erc-drc", help="Run ERC/DRC through service")
|
||||
erc.add_argument("--source-path", default="")
|
||||
erc.add_argument("--board", default="")
|
||||
erc.add_argument("--schematic", default="")
|
||||
|
||||
bom = subparsers.add_parser("bom-review", help="Run BOM review through service")
|
||||
bom.add_argument("--source-path", default="")
|
||||
bom.add_argument("--schematic", default="")
|
||||
|
||||
sync = subparsers.add_parser("ecad-mcad-sync", help="Run ECAD/MCAD sync through service")
|
||||
sync.add_argument("--source-path", default="")
|
||||
sync.add_argument("--board", default="")
|
||||
sync.add_argument("--schematic", default="")
|
||||
sync.add_argument("--freecad-document", default="")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def payload_from_args(args: argparse.Namespace) -> dict:
|
||||
payload = {"command": args.command}
|
||||
for key in ("source_path", "board", "schematic", "freecad_document"):
|
||||
if hasattr(args, key):
|
||||
payload[key] = getattr(args, key)
|
||||
return payload
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
if args.command == "health":
|
||||
payload = service_health(args.host, args.port)
|
||||
if payload:
|
||||
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
return 1
|
||||
|
||||
payload = payload_from_args(args)
|
||||
direct_argv = [args.command]
|
||||
for key in ("source_path", "board", "schematic", "freecad_document"):
|
||||
if key in payload and payload[key]:
|
||||
direct_argv.extend([f"--{key.replace('_', '-')}", payload[key]])
|
||||
if args.json_output:
|
||||
direct_argv.append("--json-output")
|
||||
|
||||
if not ensure_service(args.host, args.port):
|
||||
return direct_fallback(direct_argv)
|
||||
|
||||
try:
|
||||
response = http_json(service_url(args.host, args.port, "/run"), payload)
|
||||
if args.json_output:
|
||||
print(json.dumps(response, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(response.get("summary", json.dumps(response, ensure_ascii=False)))
|
||||
return 0 if response.get("status") != "blocked" else 1
|
||||
except urllib.error.HTTPError:
|
||||
return direct_fallback(direct_argv)
|
||||
except Exception: # noqa: BLE001
|
||||
return direct_fallback(direct_argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env python3
|
||||
"""YiACAD local backend service for native surfaces and TUI clients."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yiacad_native_ops as native_ops
|
||||
from yiacad_backend import artifact_entry, build_uiux_output, utc_timestamp, write_json
|
||||
except ImportError:
|
||||
from tools.cad import yiacad_native_ops as native_ops
|
||||
from tools.cad.yiacad_backend import artifact_entry, build_uiux_output, utc_timestamp, write_json
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SERVICE_ARTIFACTS = ROOT / "artifacts" / "cad-ai-native" / "service"
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 38435
|
||||
|
||||
|
||||
def ensure_dir(path: Path) -> Path:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def uiux_contract_from_failure(command: str, error_text: str, request_path: Path | None = None) -> dict:
|
||||
artifacts = []
|
||||
if request_path:
|
||||
artifacts.append(artifact_entry(request_path, "evidence", "YiACAD backend request"))
|
||||
return build_uiux_output(
|
||||
surface="service",
|
||||
action=command,
|
||||
execution_mode="background",
|
||||
status="blocked",
|
||||
severity="error",
|
||||
summary="YiACAD backend service could not fulfill the request.",
|
||||
details=error_text,
|
||||
context_ref=None,
|
||||
artifacts=artifacts,
|
||||
next_steps=["inspect backend request", "retry the action", "fallback to direct runner"],
|
||||
latency_ms=None,
|
||||
)
|
||||
|
||||
|
||||
def build_args(command: str, payload: dict) -> argparse.Namespace:
|
||||
common = {"command": command, "json_output": True}
|
||||
if command == "status":
|
||||
return argparse.Namespace(**common)
|
||||
if command == "kicad-erc-drc":
|
||||
return argparse.Namespace(
|
||||
**common,
|
||||
source_path=payload.get("source_path", ""),
|
||||
board=payload.get("board", ""),
|
||||
schematic=payload.get("schematic", ""),
|
||||
)
|
||||
if command == "bom-review":
|
||||
return argparse.Namespace(
|
||||
**common,
|
||||
source_path=payload.get("source_path", ""),
|
||||
schematic=payload.get("schematic", ""),
|
||||
)
|
||||
if command == "ecad-mcad-sync":
|
||||
return argparse.Namespace(
|
||||
**common,
|
||||
source_path=payload.get("source_path", ""),
|
||||
board=payload.get("board", ""),
|
||||
schematic=payload.get("schematic", ""),
|
||||
freecad_document=payload.get("freecad_document", ""),
|
||||
)
|
||||
raise KeyError(f"Unsupported YiACAD command: {command}")
|
||||
|
||||
|
||||
def dispatch_command(command: str, payload: dict) -> tuple[int, dict]:
|
||||
mapping = {
|
||||
"status": native_ops.command_status,
|
||||
"kicad-erc-drc": native_ops.command_kicad_erc_drc,
|
||||
"bom-review": native_ops.command_bom_review,
|
||||
"ecad-mcad-sync": native_ops.command_ecad_mcad_sync,
|
||||
}
|
||||
handler = mapping[command]
|
||||
args = build_args(command, payload)
|
||||
stdout = io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
rc = handler(args)
|
||||
body = stdout.getvalue().strip()
|
||||
if body:
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
if isinstance(parsed, dict):
|
||||
return rc, parsed
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
contract = build_uiux_output(
|
||||
surface="service",
|
||||
action=command,
|
||||
execution_mode="background",
|
||||
status="done" if rc == 0 else "blocked",
|
||||
severity="info" if rc == 0 else "error",
|
||||
summary=f"YiACAD backend service executed `{command}`.",
|
||||
details=body or "No structured response body was returned.",
|
||||
context_ref=None,
|
||||
artifacts=[],
|
||||
next_steps=["inspect service response", "retry via direct runner if needed"],
|
||||
latency_ms=None,
|
||||
)
|
||||
return rc, contract
|
||||
|
||||
|
||||
class YiacadBackendHandler(BaseHTTPRequestHandler):
|
||||
server_version = "YiACADBackend/0.1"
|
||||
|
||||
def _json_response(self, status_code: int, payload: dict) -> None:
|
||||
raw = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status_code)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
if self.path != "/health":
|
||||
self._json_response(404, {"status": "not_found"})
|
||||
return
|
||||
payload = {
|
||||
"status": "done",
|
||||
"component": "yiacad-backend-service",
|
||||
"generated_at": utc_timestamp(),
|
||||
"pid": os.getpid(),
|
||||
"host": self.server.server_address[0],
|
||||
"port": self.server.server_address[1],
|
||||
"artifacts_dir": str(SERVICE_ARTIFACTS),
|
||||
}
|
||||
write_json(SERVICE_ARTIFACTS / "latest_health.json", payload)
|
||||
self._json_response(200, payload)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if self.path != "/run":
|
||||
self._json_response(404, {"status": "not_found"})
|
||||
return
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
raw = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
||||
started_at = time.time()
|
||||
request_path = ensure_dir(SERVICE_ARTIFACTS) / f"request_{int(started_at)}.json"
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
write_json(request_path, payload)
|
||||
command = payload["command"]
|
||||
rc, response = dispatch_command(command, payload)
|
||||
response["service"] = {
|
||||
"mode": "http",
|
||||
"generated_at": utc_timestamp(),
|
||||
"latency_ms": int((time.time() - started_at) * 1000),
|
||||
}
|
||||
response.setdefault("artifacts", []).append(
|
||||
artifact_entry(request_path, "evidence", "YiACAD backend request")
|
||||
)
|
||||
write_json(SERVICE_ARTIFACTS / "latest_response.json", response)
|
||||
self._json_response(200 if rc == 0 else 207, response)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
response = uiux_contract_from_failure("service.dispatch", str(exc), request_path)
|
||||
response["service"] = {
|
||||
"mode": "http",
|
||||
"generated_at": utc_timestamp(),
|
||||
"latency_ms": int((time.time() - started_at) * 1000),
|
||||
}
|
||||
write_json(SERVICE_ARTIFACTS / "latest_response.json", response)
|
||||
self._json_response(500, response)
|
||||
|
||||
def log_message(self, format: str, *args) -> None: # noqa: A003
|
||||
log_path = ensure_dir(SERVICE_ARTIFACTS) / "server.log"
|
||||
with log_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write("%s - %s\n" % (self.log_date_time_string(), format % args))
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="YiACAD backend HTTP service")
|
||||
parser.add_argument("--host", default=DEFAULT_HOST, help="Bind host")
|
||||
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Bind port")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
ensure_dir(SERVICE_ARTIFACTS)
|
||||
server = ThreadingHTTPServer((args.host, args.port), YiacadBackendHandler)
|
||||
write_json(
|
||||
SERVICE_ARTIFACTS / "latest_server.json",
|
||||
{
|
||||
"status": "done",
|
||||
"component": "yiacad-backend-service",
|
||||
"generated_at": utc_timestamp(),
|
||||
"pid": os.getpid(),
|
||||
"host": args.host,
|
||||
"port": args.port,
|
||||
"artifacts_dir": str(SERVICE_ARTIFACTS),
|
||||
},
|
||||
)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+287
@@ -0,0 +1,287 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
BASE_DIR="${KILL_LIFE_CAD_AI_BASE_DIR:-${ROOT_DIR}/.runtime-home/cad-ai-native-forks}"
|
||||
DEFAULT_OWNER="${KILL_LIFE_FORK_OWNER:-electron-rare}"
|
||||
DEFAULT_BRANCH="${KILL_LIFE_FORK_BRANCH:-kill-life-ai-native}"
|
||||
LOG_DIR="${ROOT_DIR}/artifacts/cad-fusion"
|
||||
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
|
||||
LOT_ID="yiacad-fusion"
|
||||
OWNER_TEAM="CAD-Fusion"
|
||||
OWNER_AGENT="CAD-Fusion"
|
||||
LAST_LOG_FILE="${LOG_DIR}/yiacad-fusion-last.log"
|
||||
LAST_STATUS_FILE="${LOG_DIR}/yiacad-fusion-last-status.md"
|
||||
ACTION="prepare"
|
||||
YES=0
|
||||
DAYS_KEEP=14
|
||||
OWNER="$DEFAULT_OWNER"
|
||||
BRANCH="$DEFAULT_BRANCH"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF_USAGE'
|
||||
Usage: tools/cad/yiacad_fusion_lot.sh [options]
|
||||
|
||||
Lane d'orchestration YiACAD (KiCad + FreeCAD + OpenSCAD IA-native).
|
||||
|
||||
Options:
|
||||
--action <prepare|smoke|status|logs|clean-logs>
|
||||
Action. Défaut: prepare
|
||||
--base-dir DIR Dossier de base des clones forks (défaut: .runtime-home/cad-ai-native-forks)
|
||||
--owner OWNER Propriétaire du fork GitHub (défaut: electron-rare)
|
||||
--branch BRANCH Branche IA-native (défaut: kill-life-ai-native)
|
||||
--days N Rétention des logs pour clean-logs (défaut: 14)
|
||||
--yes Confirmer suppression (clean-logs)
|
||||
-h, --help Aide
|
||||
|
||||
Examples:
|
||||
bash tools/cad/yiacad_fusion_lot.sh --action prepare
|
||||
bash tools/cad/yiacad_fusion_lot.sh --action smoke
|
||||
bash tools/cad/yiacad_fusion_lot.sh --action status
|
||||
bash tools/cad/yiacad_fusion_lot.sh --action logs
|
||||
bash tools/cad/yiacad_fusion_lot.sh --action clean-logs --days 7 --yes
|
||||
EOF_USAGE
|
||||
}
|
||||
|
||||
ensure_dirs() {
|
||||
mkdir -p "$LOG_DIR"
|
||||
}
|
||||
|
||||
log_file() {
|
||||
printf '%s\n' "${LOG_DIR}/yiacad-fusion_${TIMESTAMP}.log"
|
||||
}
|
||||
|
||||
status_file() {
|
||||
printf '%s\n' "${LOG_DIR}/yiacad-fusion-status_${TIMESTAMP}.md"
|
||||
}
|
||||
|
||||
run_and_log() {
|
||||
local label="$1"
|
||||
shift
|
||||
local lf
|
||||
local rc=0
|
||||
lf="$(log_file)"
|
||||
printf '[%s] START %s\n' "$(date '+%Y-%m-%d %H:%M:%S %z')" "$label" >>"$lf"
|
||||
if "$@" >>"$lf" 2>&1; then
|
||||
printf '[%s] OK %s\n' "$(date '+%Y-%m-%d %H:%M:%S %z')" "$label" >>"$lf"
|
||||
return 0
|
||||
else
|
||||
rc="$?"
|
||||
fi
|
||||
|
||||
printf '[%s] FAIL %s (code=%s)\n' "$(date '+%Y-%m-%d %H:%M:%S %z')" "$label" "$rc" >>"$lf"
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
write_repo_snapshot() {
|
||||
local repo_name="$1"
|
||||
local sf="$2"
|
||||
local repo_dir="$BASE_DIR/${repo_name}-ki"
|
||||
|
||||
{
|
||||
printf '### %s\n' "$repo_name"
|
||||
if [[ -d "$repo_dir/.git" ]]; then
|
||||
local remote default_branch local_branch head
|
||||
remote="$(cd "$repo_dir" && git remote -v | awk '$1 == "origin" { print $2; exit }')"
|
||||
default_branch="$(cd "$repo_dir" && git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's#refs/remotes/origin/##')"
|
||||
local_branch="$(cd "$repo_dir" && git rev-parse --abbrev-ref HEAD)"
|
||||
head="$(cd "$repo_dir" && git rev-parse --short HEAD)"
|
||||
printf -- '- status: ok\n'
|
||||
printf -- '- local_branch: %s\n' "$local_branch"
|
||||
printf -- '- head: %s\n' "$head"
|
||||
printf -- '- remote_head: %s\n' "${default_branch:-unknown}"
|
||||
printf -- '- origin: %s\n' "${remote:-unknown}"
|
||||
printf -- '- remotes:\n'
|
||||
while IFS= read -r line; do
|
||||
printf -- ' - %s\n' "$line"
|
||||
done < <(cd "$repo_dir" && git remote -v | awk '{print $1 " -> " $2}' | sort -u)
|
||||
return
|
||||
fi
|
||||
|
||||
printf '- status: missing\n'
|
||||
} >>"$sf"
|
||||
}
|
||||
|
||||
write_status_snapshot() {
|
||||
local sf
|
||||
local manifest_path="${BASE_DIR}/manifest.md"
|
||||
|
||||
sf="$(status_file)"
|
||||
{
|
||||
printf '# YiACAD status\n\n'
|
||||
printf -- '- lot_id: %s\n' "$LOT_ID"
|
||||
printf -- '- owner_team: %s\n' "$OWNER_TEAM"
|
||||
printf -- '- owner_agent: %s\n' "$OWNER_AGENT"
|
||||
printf -- '- root: %s\n' "$ROOT_DIR"
|
||||
printf -- '- base_dir: %s\n' "$BASE_DIR"
|
||||
printf -- '- owner: %s\n' "$OWNER"
|
||||
printf -- '- branch: %s\n' "$BRANCH"
|
||||
printf -- '- manifest: %s\n' "$manifest_path"
|
||||
if [[ -f "$manifest_path" ]]; then
|
||||
printf -- '- manifest_state: present\n'
|
||||
else
|
||||
printf -- '- manifest_state: missing\n'
|
||||
fi
|
||||
printf -- '- log_dir: %s\n' "$LOG_DIR"
|
||||
printf -- '- generated_at: %s\n' "$(date '+%Y-%m-%d %H:%M:%S %z')"
|
||||
printf '\n'
|
||||
} >"$sf"
|
||||
write_repo_snapshot kicad "$sf"
|
||||
write_repo_snapshot freecad "$sf"
|
||||
cp "$sf" "$LAST_STATUS_FILE"
|
||||
}
|
||||
|
||||
run_prepare() {
|
||||
ensure_dirs
|
||||
local args=(
|
||||
bash "$ROOT_DIR/tools/cad/ai_native_forks.sh"
|
||||
--owner "$OWNER"
|
||||
--branch "$BRANCH"
|
||||
--base-dir "$BASE_DIR"
|
||||
--projects "kicad freecad"
|
||||
)
|
||||
run_and_log "YiACAD prepare forks" "${args[@]}"
|
||||
write_status_snapshot
|
||||
cp "$(log_file)" "$LAST_LOG_FILE"
|
||||
cat "$LAST_STATUS_FILE"
|
||||
}
|
||||
|
||||
run_smoke() {
|
||||
ensure_dirs
|
||||
local -a smoke_failures=()
|
||||
|
||||
run_and_log "CAD stack doctor" bash "$ROOT_DIR/tools/hw/cad_stack.sh" doctor-mcp || smoke_failures+=("CAD stack doctor")
|
||||
run_and_log "KiCad MCP host doctor" bash "$ROOT_DIR/tools/hw/run_kicad_mcp.sh" --doctor || smoke_failures+=("KiCad MCP host doctor")
|
||||
run_and_log "KiCad MCP host smoke" python3 "$ROOT_DIR/tools/hw/kicad_host_mcp_smoke.py" --json --quick || smoke_failures+=("KiCad MCP host smoke")
|
||||
run_and_log "FreeCAD MCP smoke" python3 "$ROOT_DIR/tools/freecad_mcp_smoke.py" --json --quick || smoke_failures+=("FreeCAD MCP smoke")
|
||||
run_and_log "OpenSCAD MCP smoke" python3 "$ROOT_DIR/tools/openscad_mcp_smoke.py" --json --quick || smoke_failures+=("OpenSCAD MCP smoke")
|
||||
run_and_log "FreeCAD MCP doctor" bash "$ROOT_DIR/tools/run_freecad_mcp.sh" --doctor || smoke_failures+=("FreeCAD MCP doctor")
|
||||
run_and_log "OpenSCAD MCP doctor" bash "$ROOT_DIR/tools/run_openscad_mcp.sh" --doctor || smoke_failures+=("OpenSCAD MCP doctor")
|
||||
write_status_snapshot
|
||||
cp "$(log_file)" "$LAST_LOG_FILE"
|
||||
if [[ "${#smoke_failures[@]}" -gt 0 ]]; then
|
||||
{
|
||||
printf '\n## smoke_failures\n'
|
||||
for failure in "${smoke_failures[@]}"; do
|
||||
printf -- '- %s\n' "$failure"
|
||||
done
|
||||
} >>"$LAST_STATUS_FILE"
|
||||
cat "$LAST_STATUS_FILE"
|
||||
return 1
|
||||
fi
|
||||
cat "$LAST_STATUS_FILE"
|
||||
}
|
||||
|
||||
show_status() {
|
||||
ensure_dirs
|
||||
write_status_snapshot
|
||||
cat "$LAST_STATUS_FILE"
|
||||
echo
|
||||
if [[ -f "$LAST_LOG_FILE" ]]; then
|
||||
echo "Last log: $LAST_LOG_FILE"
|
||||
else
|
||||
echo "No run log yet."
|
||||
fi
|
||||
}
|
||||
|
||||
list_logs() {
|
||||
echo "=== YiACAD logs (artifacts/cad-fusion) ==="
|
||||
local files
|
||||
files="$(
|
||||
find "$LOG_DIR" -maxdepth 1 -type f \
|
||||
\( -name 'yiacad-fusion_*.log' -o -name 'yiacad-fusion-status_*.md' -o -name 'yiacad-fusion-last.log' -o -name 'yiacad-fusion-last-status.md' \) \
|
||||
| sort | tail -n 40
|
||||
)"
|
||||
if [[ -z "$files" ]]; then
|
||||
echo "Aucun log YiACAD trouvé dans $LOG_DIR"
|
||||
return 0
|
||||
fi
|
||||
printf '%s\n' "$files"
|
||||
}
|
||||
|
||||
clean_logs() {
|
||||
local days="${1:-$DAYS_KEEP}"
|
||||
if [[ "$days" -lt 1 ]]; then
|
||||
echo "--days doit être >= 1"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local stale_count
|
||||
stale_count="$(find "$LOG_DIR" -maxdepth 1 -type f \( -name 'yiacad-fusion_*.log' -o -name 'yiacad-fusion-status_*.md' \) -mtime +"$days" | wc -l | tr -d ' ')"
|
||||
if [[ "$stale_count" == "0" ]]; then
|
||||
echo "No log candidate older than ${days} day(s)."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$YES" -ne 1 ]]; then
|
||||
echo "${stale_count} log files older than ${days} day(s) would be deleted. Re-run with --yes."
|
||||
return 0
|
||||
fi
|
||||
|
||||
find "$LOG_DIR" -maxdepth 1 -type f \( -name 'yiacad-fusion_*.log' -o -name 'yiacad-fusion-status_*.md' \) -mtime +"$days" -delete
|
||||
echo "Deleted ${stale_count} logs older than ${days} day(s)."
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--base-dir)
|
||||
BASE_DIR="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--owner)
|
||||
OWNER="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--branch)
|
||||
BRANCH="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--days)
|
||||
DAYS_KEEP="${2:-14}"
|
||||
shift 2
|
||||
;;
|
||||
--yes)
|
||||
YES=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Option inconnue: $1" >&2
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
ensure_dirs
|
||||
case "$ACTION" in
|
||||
prepare)
|
||||
run_prepare
|
||||
;;
|
||||
smoke)
|
||||
run_smoke
|
||||
;;
|
||||
status)
|
||||
show_status
|
||||
;;
|
||||
logs)
|
||||
list_logs
|
||||
;;
|
||||
clean-logs)
|
||||
clean_logs "$DAYS_KEEP"
|
||||
;;
|
||||
*)
|
||||
echo "Action inconnue: $ACTION" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+680
@@ -0,0 +1,680 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Concrete YiACAD native CAD utilities for KiCad and FreeCAD surfaces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
try:
|
||||
from yiacad_backend import (
|
||||
artifact_entry,
|
||||
build_context_record,
|
||||
build_uiux_output,
|
||||
infer_surface,
|
||||
output_status_from_returncodes,
|
||||
write_context_record,
|
||||
write_uiux_output,
|
||||
)
|
||||
except ImportError:
|
||||
from tools.cad.yiacad_backend import (
|
||||
artifact_entry,
|
||||
build_context_record,
|
||||
build_uiux_output,
|
||||
infer_surface,
|
||||
output_status_from_returncodes,
|
||||
write_context_record,
|
||||
write_uiux_output,
|
||||
)
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
ARTIFACTS_ROOT = ROOT / "artifacts" / "cad-ai-native"
|
||||
FUSION_STATUS = ROOT / "artifacts" / "cad-fusion" / "yiacad-fusion-last-status.md"
|
||||
|
||||
|
||||
def now_stamp() -> str:
|
||||
return time.strftime("%Y%m%dT%H%M%S")
|
||||
|
||||
|
||||
def ensure_dir(path: Path) -> Path:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def make_run_dir(label: str) -> Path:
|
||||
return ensure_dir(ARTIFACTS_ROOT / f"{now_stamp()}-{label}")
|
||||
|
||||
|
||||
def write_text(path: Path, text: str) -> None:
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def write_json(path: Path, payload: object) -> None:
|
||||
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def run(cmd: list[str], *, cwd: Path | None = None, ok_codes: Iterable[int] = (0,)) -> dict:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
payload = {
|
||||
"cmd": cmd,
|
||||
"returncode": proc.returncode,
|
||||
"stdout": proc.stdout,
|
||||
"stderr": proc.stderr,
|
||||
"ok": proc.returncode in set(ok_codes),
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
def resolve_kicad_cli() -> str:
|
||||
mac = Path("/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli")
|
||||
if mac.exists():
|
||||
return str(mac)
|
||||
return "kicad-cli"
|
||||
|
||||
|
||||
def resolve_freecad_cmd() -> str | None:
|
||||
candidates = (
|
||||
Path("/Applications/FreeCAD.app/Contents/MacOS/FreeCADCmd"),
|
||||
Path("/Applications/FreeCAD.app/Contents/MacOS/FreeCAD"),
|
||||
)
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
for name in ("FreeCADCmd", "freecadcmd", "FreeCAD"):
|
||||
found = shutil.which(name)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def guess_board_from_source(source_path: str | None) -> Path | None:
|
||||
if not source_path:
|
||||
return None
|
||||
source = Path(source_path).expanduser().resolve()
|
||||
if source.suffix == ".kicad_pcb" and source.exists():
|
||||
return source
|
||||
base = source.with_suffix(".kicad_pcb")
|
||||
if base.exists():
|
||||
return base
|
||||
for candidate in sorted(source.parent.glob("*.kicad_pcb")):
|
||||
return candidate.resolve()
|
||||
return None
|
||||
|
||||
|
||||
def guess_schematic_from_source(source_path: str | None) -> Path | None:
|
||||
if not source_path:
|
||||
return None
|
||||
source = Path(source_path).expanduser().resolve()
|
||||
if source.suffix == ".kicad_sch" and source.exists():
|
||||
return source
|
||||
base = source.with_suffix(".kicad_sch")
|
||||
if base.exists():
|
||||
return base
|
||||
for candidate in sorted(source.parent.glob("*.kicad_sch")):
|
||||
return candidate.resolve()
|
||||
return None
|
||||
|
||||
|
||||
def guess_freecad_document(source_path: str | None) -> Path | None:
|
||||
if not source_path:
|
||||
return None
|
||||
source = Path(source_path).expanduser().resolve()
|
||||
if source.suffix == ".FCStd" and source.exists():
|
||||
return source
|
||||
for candidate in sorted(source.parent.glob("*.FCStd")):
|
||||
return candidate.resolve()
|
||||
return None
|
||||
|
||||
|
||||
def latest_native_summary() -> str:
|
||||
if not ARTIFACTS_ROOT.exists():
|
||||
return "No YiACAD native artifact has been generated yet."
|
||||
runs = sorted(ARTIFACTS_ROOT.iterdir(), reverse=True)
|
||||
for run_dir in runs:
|
||||
summary = run_dir / "summary.md"
|
||||
if summary.exists():
|
||||
return summary.read_text(encoding="utf-8").strip()
|
||||
return "No YiACAD native summary is available yet."
|
||||
|
||||
|
||||
def print_contract_or_fallback(args: argparse.Namespace, payload: dict, fallback: str | Path) -> None:
|
||||
if getattr(args, "json_output", False):
|
||||
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(str(fallback))
|
||||
|
||||
|
||||
def command_status(args: argparse.Namespace) -> int:
|
||||
lines = ["# YiACAD Native Status", ""]
|
||||
if FUSION_STATUS.exists():
|
||||
lines += ["## Fusion lane", "", FUSION_STATUS.read_text(encoding="utf-8").strip(), ""]
|
||||
else:
|
||||
lines += ["## Fusion lane", "", "No fusion status snapshot found.", ""]
|
||||
lines += ["## Native surfaces", "", latest_native_summary(), ""]
|
||||
status = "done" if FUSION_STATUS.exists() or ARTIFACTS_ROOT.exists() else "degraded"
|
||||
severity = "info" if status == "done" else "warning"
|
||||
output_payload = build_uiux_output(
|
||||
surface="tui",
|
||||
action="status.surface",
|
||||
execution_mode="batch",
|
||||
status=status,
|
||||
severity=severity,
|
||||
summary="YiACAD native status snapshot generated.",
|
||||
details="Fusion lane snapshot and latest native summaries were collected.",
|
||||
context_ref=None,
|
||||
artifacts=[
|
||||
artifact_entry(FUSION_STATUS, "report", "YiACAD fusion status"),
|
||||
]
|
||||
if FUSION_STATUS.exists()
|
||||
else [],
|
||||
next_steps=[
|
||||
"open artifacts",
|
||||
"review latest native summary",
|
||||
"continue with backend or UX lot",
|
||||
],
|
||||
latency_ms=None,
|
||||
)
|
||||
if getattr(args, "json_output", False):
|
||||
print(json.dumps(output_payload, indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
print("\n".join(lines).strip())
|
||||
return 0
|
||||
|
||||
|
||||
def command_kicad_erc_drc(args: argparse.Namespace) -> int:
|
||||
started_at = time.time()
|
||||
board = Path(args.board).expanduser().resolve() if args.board else guess_board_from_source(args.source_path)
|
||||
schematic = (
|
||||
Path(args.schematic).expanduser().resolve() if args.schematic else guess_schematic_from_source(args.source_path)
|
||||
)
|
||||
run_dir = make_run_dir("kicad-erc-drc")
|
||||
surface = infer_surface(board=board, schematic=schematic)
|
||||
context = build_context_record(
|
||||
surface,
|
||||
source_path=args.source_path,
|
||||
board=board,
|
||||
schematic=schematic,
|
||||
artifacts_dir=run_dir,
|
||||
)
|
||||
context_path = write_context_record(run_dir / "context.json", context)
|
||||
payload: dict[str, object] = {
|
||||
"action": "kicad-erc-drc",
|
||||
"board": str(board) if board else "",
|
||||
"schematic": str(schematic) if schematic else "",
|
||||
"artifacts_dir": str(run_dir),
|
||||
"context": context,
|
||||
"steps": [],
|
||||
}
|
||||
|
||||
if not board and not schematic:
|
||||
payload["error"] = "No KiCad board or schematic could be resolved."
|
||||
output_payload = build_uiux_output(
|
||||
surface=surface,
|
||||
action="review.erc_drc",
|
||||
execution_mode="batch",
|
||||
status="blocked",
|
||||
severity="error",
|
||||
summary="YiACAD could not resolve a KiCad board or schematic.",
|
||||
details="Provide --board, --schematic or a valid --source-path with project files present.",
|
||||
context_ref=context["context_ref"],
|
||||
artifacts=[
|
||||
artifact_entry(context_path, "evidence", "YiACAD context record"),
|
||||
artifact_entry(run_dir / "result.json", "evidence", "YiACAD raw payload"),
|
||||
],
|
||||
next_steps=[
|
||||
"provide --board or --schematic",
|
||||
"rerun with a loaded KiCad project",
|
||||
],
|
||||
latency_ms=int((time.time() - started_at) * 1000),
|
||||
)
|
||||
output_path = write_uiux_output(run_dir / "uiux_output.json", output_payload)
|
||||
payload["uiux_output"] = str(output_path)
|
||||
write_json(run_dir / "result.json", payload)
|
||||
print_contract_or_fallback(args, output_payload, run_dir / "result.json")
|
||||
return 2
|
||||
|
||||
kicad_cli = resolve_kicad_cli()
|
||||
summary_lines = ["# YiACAD ERC/DRC Assist", ""]
|
||||
rc_final = 0
|
||||
|
||||
if schematic:
|
||||
erc_path = run_dir / "erc.json"
|
||||
step = run(
|
||||
[
|
||||
kicad_cli,
|
||||
"sch",
|
||||
"erc",
|
||||
"--format",
|
||||
"json",
|
||||
"--severity-all",
|
||||
"--exit-code-violations",
|
||||
"--output",
|
||||
str(erc_path),
|
||||
str(schematic),
|
||||
],
|
||||
ok_codes=(0, 5),
|
||||
)
|
||||
write_text(run_dir / "erc.stdout.txt", step["stdout"])
|
||||
write_text(run_dir / "erc.stderr.txt", step["stderr"])
|
||||
payload["steps"].append(step)
|
||||
summary_lines += [f"- ERC: `{erc_path}` (rc={step['returncode']})"]
|
||||
if not step["ok"]:
|
||||
rc_final = step["returncode"]
|
||||
|
||||
if board:
|
||||
drc_path = run_dir / "drc.json"
|
||||
step = run(
|
||||
[
|
||||
kicad_cli,
|
||||
"pcb",
|
||||
"drc",
|
||||
"--format",
|
||||
"json",
|
||||
"--severity-all",
|
||||
"--exit-code-violations",
|
||||
"--output",
|
||||
str(drc_path),
|
||||
str(board),
|
||||
],
|
||||
ok_codes=(0, 5),
|
||||
)
|
||||
write_text(run_dir / "drc.stdout.txt", step["stdout"])
|
||||
write_text(run_dir / "drc.stderr.txt", step["stderr"])
|
||||
payload["steps"].append(step)
|
||||
summary_lines += [f"- DRC: `{drc_path}` (rc={step['returncode']})"]
|
||||
if not step["ok"] and rc_final == 0:
|
||||
rc_final = step["returncode"]
|
||||
|
||||
summary_lines += ["", f"- Board: `{board}`" if board else "- Board: unresolved"]
|
||||
summary_lines += [f"- Schematic: `{schematic}`" if schematic else "- Schematic: unresolved"]
|
||||
write_text(run_dir / "summary.md", "\n".join(summary_lines) + "\n")
|
||||
payload["summary"] = str(run_dir / "summary.md")
|
||||
status, severity = output_status_from_returncodes([step["returncode"] for step in payload["steps"]])
|
||||
next_steps = {
|
||||
"done": ["open generated reports", "continue with BOM review or sync"],
|
||||
"degraded": ["open review center", "inspect generated reports", "rerun after fixes"],
|
||||
"blocked": ["verify KiCad CLI and project paths", "rerun with project loaded"],
|
||||
}[status]
|
||||
artifacts = [
|
||||
artifact_entry(run_dir / "summary.md", "report", "YiACAD ERC/DRC summary"),
|
||||
artifact_entry(context_path, "evidence", "YiACAD context record"),
|
||||
artifact_entry(run_dir / "result.json", "evidence", "YiACAD raw payload"),
|
||||
]
|
||||
if (run_dir / "erc.json").exists():
|
||||
artifacts.append(artifact_entry(run_dir / "erc.json", "report", "ERC JSON report"))
|
||||
if (run_dir / "drc.json").exists():
|
||||
artifacts.append(artifact_entry(run_dir / "drc.json", "report", "DRC JSON report"))
|
||||
for name, label in (
|
||||
("erc.stdout.txt", "ERC stdout"),
|
||||
("erc.stderr.txt", "ERC stderr"),
|
||||
("drc.stdout.txt", "DRC stdout"),
|
||||
("drc.stderr.txt", "DRC stderr"),
|
||||
):
|
||||
path = run_dir / name
|
||||
if path.exists():
|
||||
artifacts.append(artifact_entry(path, "log", label))
|
||||
output_payload = build_uiux_output(
|
||||
surface=surface,
|
||||
action="review.erc_drc",
|
||||
execution_mode="batch",
|
||||
status=status,
|
||||
severity=severity,
|
||||
summary="YiACAD ERC/DRC run completed.",
|
||||
details=f"Board={board or 'unresolved'} | Schematic={schematic or 'unresolved'} | Artifacts={run_dir}",
|
||||
context_ref=context["context_ref"],
|
||||
artifacts=artifacts,
|
||||
next_steps=next_steps,
|
||||
latency_ms=int((time.time() - started_at) * 1000),
|
||||
)
|
||||
output_path = write_uiux_output(run_dir / "uiux_output.json", output_payload)
|
||||
payload["uiux_output"] = str(output_path)
|
||||
write_json(run_dir / "result.json", payload)
|
||||
print_contract_or_fallback(args, output_payload, run_dir / "summary.md")
|
||||
return rc_final
|
||||
|
||||
|
||||
def command_bom_review(args: argparse.Namespace) -> int:
|
||||
started_at = time.time()
|
||||
schematic = (
|
||||
Path(args.schematic).expanduser().resolve() if args.schematic else guess_schematic_from_source(args.source_path)
|
||||
)
|
||||
run_dir = make_run_dir("bom-review")
|
||||
surface = infer_surface(schematic=schematic)
|
||||
context = build_context_record(
|
||||
surface,
|
||||
source_path=args.source_path,
|
||||
schematic=schematic,
|
||||
artifacts_dir=run_dir,
|
||||
)
|
||||
context_path = write_context_record(run_dir / "context.json", context)
|
||||
if not schematic:
|
||||
payload = {
|
||||
"action": "bom-review",
|
||||
"error": "No KiCad schematic could be resolved.",
|
||||
"artifacts_dir": str(run_dir),
|
||||
"context": context,
|
||||
}
|
||||
output_payload = build_uiux_output(
|
||||
surface=surface,
|
||||
action="review.bom",
|
||||
execution_mode="batch",
|
||||
status="blocked",
|
||||
severity="error",
|
||||
summary="YiACAD could not resolve a KiCad schematic for BOM review.",
|
||||
details="Provide --schematic or a valid --source-path with .kicad_sch present.",
|
||||
context_ref=context["context_ref"],
|
||||
artifacts=[
|
||||
artifact_entry(context_path, "evidence", "YiACAD context record"),
|
||||
artifact_entry(run_dir / "result.json", "evidence", "YiACAD raw payload"),
|
||||
],
|
||||
next_steps=[
|
||||
"provide --schematic",
|
||||
"rerun with a loaded KiCad project",
|
||||
],
|
||||
latency_ms=int((time.time() - started_at) * 1000),
|
||||
)
|
||||
output_path = write_uiux_output(run_dir / "uiux_output.json", output_payload)
|
||||
payload["uiux_output"] = str(output_path)
|
||||
write_json(run_dir / "result.json", payload)
|
||||
print_contract_or_fallback(args, output_payload, run_dir / "result.json")
|
||||
return 2
|
||||
|
||||
kicad_cli = resolve_kicad_cli()
|
||||
bom_path = run_dir / "bom.csv"
|
||||
step = run(
|
||||
[kicad_cli, "sch", "export", "bom", "--output", str(bom_path), str(schematic)],
|
||||
ok_codes=(0,),
|
||||
)
|
||||
write_text(run_dir / "bom.stdout.txt", step["stdout"])
|
||||
write_text(run_dir / "bom.stderr.txt", step["stderr"])
|
||||
|
||||
row_count = 0
|
||||
columns: list[str] = []
|
||||
blank_counts: dict[str, int] = {}
|
||||
if bom_path.exists():
|
||||
with bom_path.open("r", encoding="utf-8", newline="") as handle:
|
||||
reader = csv.DictReader(handle)
|
||||
columns = reader.fieldnames or []
|
||||
blank_counts = {column: 0 for column in columns}
|
||||
for row in reader:
|
||||
row_count += 1
|
||||
for column in columns:
|
||||
if not (row.get(column) or "").strip():
|
||||
blank_counts[column] += 1
|
||||
|
||||
summary_lines = [
|
||||
"# YiACAD BOM Review",
|
||||
"",
|
||||
f"- Schematic: `{schematic}`",
|
||||
f"- BOM: `{bom_path}`",
|
||||
f"- Rows: `{row_count}`",
|
||||
"",
|
||||
"## Empty field counts",
|
||||
"",
|
||||
]
|
||||
if columns:
|
||||
for column in columns:
|
||||
summary_lines.append(f"- `{column}`: {blank_counts.get(column, 0)}")
|
||||
else:
|
||||
summary_lines.append("- No BOM columns were parsed.")
|
||||
write_text(run_dir / "summary.md", "\n".join(summary_lines) + "\n")
|
||||
payload = {
|
||||
"action": "bom-review",
|
||||
"schematic": str(schematic),
|
||||
"bom": str(bom_path),
|
||||
"rows": row_count,
|
||||
"columns": columns,
|
||||
"blank_counts": blank_counts,
|
||||
"step": step,
|
||||
"summary": str(run_dir / "summary.md"),
|
||||
"context": context,
|
||||
}
|
||||
status, severity = output_status_from_returncodes([step["returncode"]])
|
||||
next_steps = {
|
||||
"done": ["open BOM summary", "inspect blank field counts"],
|
||||
"degraded": ["open BOM summary", "inspect blank field counts"],
|
||||
"blocked": ["verify KiCad CLI and schematic path", "rerun BOM export"],
|
||||
}[status]
|
||||
artifacts = [
|
||||
artifact_entry(run_dir / "summary.md", "report", "YiACAD BOM review summary"),
|
||||
artifact_entry(context_path, "evidence", "YiACAD context record"),
|
||||
artifact_entry(run_dir / "result.json", "evidence", "YiACAD raw payload"),
|
||||
]
|
||||
if bom_path.exists():
|
||||
artifacts.append(artifact_entry(bom_path, "export", "BOM CSV export"))
|
||||
for name, label in (("bom.stdout.txt", "BOM stdout"), ("bom.stderr.txt", "BOM stderr")):
|
||||
path = run_dir / name
|
||||
if path.exists():
|
||||
artifacts.append(artifact_entry(path, "log", label))
|
||||
output_payload = build_uiux_output(
|
||||
surface=surface,
|
||||
action="review.bom",
|
||||
execution_mode="batch",
|
||||
status=status,
|
||||
severity=severity,
|
||||
summary="YiACAD BOM review completed.",
|
||||
details=f"Schematic={schematic} | Rows={row_count} | Artifacts={run_dir}",
|
||||
context_ref=context["context_ref"],
|
||||
artifacts=artifacts,
|
||||
next_steps=next_steps,
|
||||
latency_ms=int((time.time() - started_at) * 1000),
|
||||
)
|
||||
output_path = write_uiux_output(run_dir / "uiux_output.json", output_payload)
|
||||
payload["uiux_output"] = str(output_path)
|
||||
write_json(run_dir / "result.json", payload)
|
||||
print_contract_or_fallback(args, output_payload, run_dir / "summary.md")
|
||||
return step["returncode"]
|
||||
|
||||
|
||||
def export_freecad_document(document_path: Path, output_path: Path, run_dir: Path) -> dict:
|
||||
freecad_cmd = resolve_freecad_cmd()
|
||||
if not freecad_cmd:
|
||||
return {
|
||||
"cmd": [],
|
||||
"returncode": 127,
|
||||
"stdout": "",
|
||||
"stderr": "FreeCAD command runtime not found.",
|
||||
"ok": False,
|
||||
}
|
||||
|
||||
script_path = run_dir / "freecad_export_step.py"
|
||||
write_text(
|
||||
script_path,
|
||||
"\n".join(
|
||||
[
|
||||
"import sys",
|
||||
"import FreeCAD",
|
||||
"import Import",
|
||||
"",
|
||||
"document_path = sys.argv[1]",
|
||||
"output_path = sys.argv[2]",
|
||||
"doc = FreeCAD.openDocument(document_path)",
|
||||
"doc.recompute()",
|
||||
"Import.export(doc.Objects, output_path)",
|
||||
'print(output_path)',
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
)
|
||||
return run([freecad_cmd, str(script_path), str(document_path), str(output_path)], ok_codes=(0,))
|
||||
|
||||
|
||||
def command_ecad_mcad_sync(args: argparse.Namespace) -> int:
|
||||
started_at = time.time()
|
||||
board = Path(args.board).expanduser().resolve() if args.board else guess_board_from_source(args.source_path)
|
||||
schematic = (
|
||||
Path(args.schematic).expanduser().resolve() if args.schematic else guess_schematic_from_source(args.source_path)
|
||||
)
|
||||
freecad_document = (
|
||||
Path(args.freecad_document).expanduser().resolve()
|
||||
if args.freecad_document
|
||||
else guess_freecad_document(args.source_path)
|
||||
)
|
||||
run_dir = make_run_dir("ecad-mcad-sync")
|
||||
surface = infer_surface(board=board, schematic=schematic, freecad_document=freecad_document)
|
||||
context = build_context_record(
|
||||
surface,
|
||||
source_path=args.source_path,
|
||||
board=board,
|
||||
schematic=schematic,
|
||||
freecad_document=freecad_document,
|
||||
artifacts_dir=run_dir,
|
||||
)
|
||||
context_path = write_context_record(run_dir / "context.json", context)
|
||||
kicad_cli = resolve_kicad_cli()
|
||||
sync_payload: dict[str, object] = {
|
||||
"action": "ecad-mcad-sync",
|
||||
"board": str(board) if board else "",
|
||||
"schematic": str(schematic) if schematic else "",
|
||||
"freecad_document": str(freecad_document) if freecad_document else "",
|
||||
"artifacts_dir": str(run_dir),
|
||||
"context": context,
|
||||
"steps": [],
|
||||
}
|
||||
summary_lines = ["# YiACAD ECAD/MCAD Sync", ""]
|
||||
rc_final = 0
|
||||
|
||||
if board:
|
||||
board_step = run_dir / f"{board.stem}.step"
|
||||
step = run(
|
||||
[
|
||||
kicad_cli,
|
||||
"pcb",
|
||||
"export",
|
||||
"step",
|
||||
"--output",
|
||||
str(board_step),
|
||||
str(board),
|
||||
],
|
||||
ok_codes=(0,),
|
||||
)
|
||||
write_text(run_dir / "kicad_step.stdout.txt", step["stdout"])
|
||||
write_text(run_dir / "kicad_step.stderr.txt", step["stderr"])
|
||||
sync_payload["steps"].append(step)
|
||||
summary_lines.append(f"- KiCad STEP export: `{board_step}` (rc={step['returncode']})")
|
||||
if not step["ok"]:
|
||||
rc_final = step["returncode"]
|
||||
|
||||
if freecad_document:
|
||||
freecad_step = run_dir / f"{freecad_document.stem}.step"
|
||||
step = export_freecad_document(freecad_document, freecad_step, run_dir)
|
||||
write_text(run_dir / "freecad_step.stdout.txt", step["stdout"])
|
||||
write_text(run_dir / "freecad_step.stderr.txt", step["stderr"])
|
||||
sync_payload["steps"].append(step)
|
||||
summary_lines.append(f"- FreeCAD STEP export: `{freecad_step}` (rc={step['returncode']})")
|
||||
if not step["ok"] and rc_final == 0:
|
||||
rc_final = step["returncode"]
|
||||
|
||||
if schematic:
|
||||
summary_lines.append(f"- Linked schematic: `{schematic}`")
|
||||
if board:
|
||||
summary_lines.append(f"- Linked board: `{board}`")
|
||||
if freecad_document:
|
||||
summary_lines.append(f"- Linked FreeCAD document: `{freecad_document}`")
|
||||
if not board and not freecad_document:
|
||||
summary_lines.append("- No board or FreeCAD document could be resolved.")
|
||||
rc_final = rc_final or 2
|
||||
|
||||
write_text(run_dir / "summary.md", "\n".join(summary_lines) + "\n")
|
||||
sync_payload["summary"] = str(run_dir / "summary.md")
|
||||
status, severity = output_status_from_returncodes([step["returncode"] for step in sync_payload["steps"]])
|
||||
if not board and not freecad_document:
|
||||
status, severity = ("blocked", "error")
|
||||
next_steps = {
|
||||
"done": ["open exported STEP artifacts", "continue with physical fit review"],
|
||||
"degraded": ["inspect generated exports", "rerun with both ECAD and MCAD files present"],
|
||||
"blocked": ["verify KiCad and FreeCAD project files", "rerun sync with complete context"],
|
||||
}[status]
|
||||
artifacts = [
|
||||
artifact_entry(run_dir / "summary.md", "report", "YiACAD ECAD/MCAD sync summary"),
|
||||
artifact_entry(context_path, "evidence", "YiACAD context record"),
|
||||
artifact_entry(run_dir / "result.json", "evidence", "YiACAD raw payload"),
|
||||
]
|
||||
for name, label, kind in (
|
||||
("kicad_step.stdout.txt", "KiCad STEP stdout", "log"),
|
||||
("kicad_step.stderr.txt", "KiCad STEP stderr", "log"),
|
||||
("freecad_step.stdout.txt", "FreeCAD STEP stdout", "log"),
|
||||
("freecad_step.stderr.txt", "FreeCAD STEP stderr", "log"),
|
||||
):
|
||||
path = run_dir / name
|
||||
if path.exists():
|
||||
artifacts.append(artifact_entry(path, kind, label))
|
||||
for step_path in run_dir.glob("*.step"):
|
||||
artifacts.append(artifact_entry(step_path, "export", f"STEP export: {step_path.name}"))
|
||||
output_payload = build_uiux_output(
|
||||
surface=surface,
|
||||
action="sync.ecad_mcad",
|
||||
execution_mode="batch",
|
||||
status=status,
|
||||
severity=severity,
|
||||
summary="YiACAD ECAD/MCAD sync completed.",
|
||||
details=f"Board={board or 'unresolved'} | FreeCAD={freecad_document or 'unresolved'} | Artifacts={run_dir}",
|
||||
context_ref=context["context_ref"],
|
||||
artifacts=artifacts,
|
||||
next_steps=next_steps,
|
||||
latency_ms=int((time.time() - started_at) * 1000),
|
||||
)
|
||||
output_path = write_uiux_output(run_dir / "uiux_output.json", output_payload)
|
||||
sync_payload["uiux_output"] = str(output_path)
|
||||
write_json(run_dir / "result.json", sync_payload)
|
||||
print_contract_or_fallback(args, output_payload, run_dir / "summary.md")
|
||||
return rc_final
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="YiACAD native concrete CAD utilities")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
common = argparse.ArgumentParser(add_help=False)
|
||||
common.add_argument(
|
||||
"--json-output",
|
||||
action="store_true",
|
||||
help="Emit the normalized YiACAD UI/UX output JSON instead of the default path output",
|
||||
)
|
||||
|
||||
status = subparsers.add_parser("status", parents=[common], help="Show YiACAD fusion and native surface status")
|
||||
status.set_defaults(func=command_status)
|
||||
|
||||
erc_drc = subparsers.add_parser("kicad-erc-drc", parents=[common], help="Run KiCad ERC and DRC reports")
|
||||
erc_drc.add_argument("--source-path", default="", help="Any project path to infer KiCad files from")
|
||||
erc_drc.add_argument("--board", default="", help="Path to .kicad_pcb")
|
||||
erc_drc.add_argument("--schematic", default="", help="Path to .kicad_sch")
|
||||
erc_drc.set_defaults(func=command_kicad_erc_drc)
|
||||
|
||||
bom = subparsers.add_parser("bom-review", parents=[common], help="Export and summarize a KiCad BOM")
|
||||
bom.add_argument("--source-path", default="", help="Any project path to infer schematic from")
|
||||
bom.add_argument("--schematic", default="", help="Path to .kicad_sch")
|
||||
bom.set_defaults(func=command_bom_review)
|
||||
|
||||
sync = subparsers.add_parser("ecad-mcad-sync", parents=[common], help="Export KiCad and FreeCAD STEP artifacts for sync")
|
||||
sync.add_argument("--source-path", default="", help="Any project path to infer paired files from")
|
||||
sync.add_argument("--board", default="", help="Path to .kicad_pcb")
|
||||
sync.add_argument("--schematic", default="", help="Path to .kicad_sch")
|
||||
sync.add_argument("--freecad-document", default="", help="Path to .FCStd")
|
||||
sync.set_defaults(func=command_ecad_mcad_sync)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
MATRIX_DOC="${ROOT_DIR}/docs/AGENT_SPEC_MODULE_MATRIX_2026-03-20.md"
|
||||
TASKS_DOC="${ROOT_DIR}/specs/04_tasks.md"
|
||||
PLAN_DOC="${ROOT_DIR}/docs/plans/12_plan_gestion_des_agents.md"
|
||||
INSERTION_DOC="${ROOT_DIR}/docs/YIACAD_NATIVE_UI_INSERTION_POINTS_2026-03-20.md"
|
||||
LOG_DIR="${ROOT_DIR}/artifacts/cockpit"
|
||||
mkdir -p "${LOG_DIR}"
|
||||
|
||||
ACTION="summary"
|
||||
JSON=0
|
||||
RETENTION_DAYS=7
|
||||
LOG_FILE="${LOG_DIR}/agent_matrix_tui_$(date '+%Y%m%d_%H%M%S').log"
|
||||
|
||||
log_line() {
|
||||
local level="$1"
|
||||
shift
|
||||
local msg="${level} $(date '+%Y-%m-%d %H:%M:%S %z') ${*}"
|
||||
printf '%s\n' "${msg}" | tee -a "${LOG_FILE}"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: bash tools/cockpit/agent_matrix_tui.sh [--action summary|owners|open-tasks|insertion-points|logs-list|logs-latest|clean-logs] [--json] [--days N]
|
||||
EOF
|
||||
}
|
||||
|
||||
count_lines() {
|
||||
local pattern="$1"
|
||||
local file="$2"
|
||||
rg -c "${pattern}" "${file}" 2>/dev/null || printf '0\n'
|
||||
}
|
||||
|
||||
emit_summary_json() {
|
||||
local specs_count="$1"
|
||||
local kl_modules_count="$2"
|
||||
local mascarade_count="$3"
|
||||
local crazy_count="$4"
|
||||
local open_tasks="$5"
|
||||
local native_forks_count="$6"
|
||||
printf '{\n'
|
||||
printf ' "generated_at": "%s",\n' "$(date '+%Y-%m-%d %H:%M:%S %z')"
|
||||
printf ' "matrix_doc": "%s",\n' "${MATRIX_DOC}"
|
||||
printf ' "spec_rows": %s,\n' "${specs_count}"
|
||||
printf ' "kill_life_module_rows": %s,\n' "${kl_modules_count}"
|
||||
printf ' "mascarade_rows": %s,\n' "${mascarade_count}"
|
||||
printf ' "crazy_life_rows": %s,\n' "${crazy_count}"
|
||||
printf ' "native_fork_rows": %s,\n' "${native_forks_count}"
|
||||
printf ' "open_tasks": %s,\n' "${open_tasks}"
|
||||
printf ' "log_file": "%s"\n' "${LOG_FILE}"
|
||||
printf '}\n'
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
JSON=1
|
||||
shift
|
||||
;;
|
||||
--days)
|
||||
RETENTION_DAYS="${2:-7}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "${ACTION}" in
|
||||
summary)
|
||||
specs_count="$(count_lines '^\| `specs/' "${MATRIX_DOC}")"
|
||||
kl_modules_count="$(count_lines '^\| `tools/|^\| `workflows/|^\| `\.github/workflows/|^\| `hardware/|^\| `firmware/|^\| `compliance/|^\| `openclaw/|^\| `agents/|^\| `docs/' "${MATRIX_DOC}")"
|
||||
mascarade_count="$(count_lines '^\| `WS[0-9]' "${MATRIX_DOC}")"
|
||||
crazy_count="$(count_lines '^\| `WS-[0-9]' "${MATRIX_DOC}")"
|
||||
native_forks_count="$(count_lines '^\| `\.runtime-home/cad-ai-native-forks/' "${MATRIX_DOC}")"
|
||||
open_tasks="$(count_lines '^- \[ \]' "${TASKS_DOC}")"
|
||||
log_line "INFO" "spec_rows=${specs_count} kill_life_module_rows=${kl_modules_count} mascarade_rows=${mascarade_count} crazy_life_rows=${crazy_count} native_fork_rows=${native_forks_count} open_tasks=${open_tasks}"
|
||||
if [[ "${JSON}" -eq 1 ]]; then
|
||||
emit_summary_json "${specs_count}" "${kl_modules_count}" "${mascarade_count}" "${crazy_count}" "${open_tasks}" "${native_forks_count}"
|
||||
else
|
||||
printf 'Matrix: %s\n' "${MATRIX_DOC}"
|
||||
printf 'Spec rows: %s\n' "${specs_count}"
|
||||
printf 'Kill_LIFE module rows: %s\n' "${kl_modules_count}"
|
||||
printf 'mascarade rows: %s\n' "${mascarade_count}"
|
||||
printf 'crazy_life rows: %s\n' "${crazy_count}"
|
||||
printf 'Native fork rows: %s\n' "${native_forks_count}"
|
||||
printf 'Open tasks: %s\n' "${open_tasks}"
|
||||
printf 'Log: %s\n' "${LOG_FILE}"
|
||||
fi
|
||||
;;
|
||||
owners)
|
||||
log_line "INFO" "Printing owner registry"
|
||||
sed -n '1,260p' "${MATRIX_DOC}"
|
||||
;;
|
||||
open-tasks)
|
||||
log_line "INFO" "Printing open tasks"
|
||||
rg '^- \[ \]' "${TASKS_DOC}" || true
|
||||
;;
|
||||
insertion-points)
|
||||
log_line "INFO" "Printing YiACAD native insertion points"
|
||||
sed -n '1,260p' "${INSERTION_DOC}"
|
||||
;;
|
||||
logs-list)
|
||||
log_line "INFO" "Listing agent_matrix_tui logs"
|
||||
find "${LOG_DIR}" -name 'agent_matrix_tui_*.log' -type f | sort
|
||||
;;
|
||||
logs-latest)
|
||||
log_line "INFO" "Printing latest agent_matrix_tui log"
|
||||
latest_log="$(find "${LOG_DIR}" -name 'agent_matrix_tui_*.log' -type f | sort | tail -n 1)"
|
||||
if [[ -n "${latest_log}" ]]; then
|
||||
cat "${latest_log}"
|
||||
fi
|
||||
;;
|
||||
clean-logs)
|
||||
log_line "INFO" "Cleaning agent_matrix_tui logs older than ${RETENTION_DAYS} days"
|
||||
find "${LOG_DIR}" -name 'agent_matrix_tui_*.log' -type f -mtime +"${RETENTION_DAYS}" -delete
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown action: %s\n' "${ACTION}" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Executable
+193
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Aperant ↔ Kill_LIFE Bridge
|
||||
# Provides cockpit entry points for the Aperant multi-agent
|
||||
# autonomous coding framework (git submodule at tools/aperant).
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
|
||||
APERANT_DIR="${ROOT_DIR}/tools/aperant"
|
||||
WEB_DIR="${ROOT_DIR}/web/aperant"
|
||||
TOWER_HOST="clems@192.168.0.120"
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────
|
||||
_ensure_submodule() {
|
||||
if [ ! -f "${APERANT_DIR}/package.json" ]; then
|
||||
echo "⚠ Aperant submodule not initialised – running git submodule update …"
|
||||
git -C "${ROOT_DIR}" submodule update --init --recursive -- tools/aperant
|
||||
fi
|
||||
}
|
||||
|
||||
_require_node() {
|
||||
if ! command -v node &>/dev/null; then
|
||||
echo "✗ node not found. Aperant requires Node >= 24." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
_aperant_version() {
|
||||
node -e "console.log(require('${APERANT_DIR}/package.json').version)" 2>/dev/null || echo "unknown"
|
||||
}
|
||||
|
||||
# ── commands ─────────────────────────────────────────────────────
|
||||
|
||||
cmd_status() {
|
||||
_ensure_submodule
|
||||
local ver
|
||||
ver=$(_aperant_version)
|
||||
local branch
|
||||
branch=$(git -C "${APERANT_DIR}" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "detached")
|
||||
local sha
|
||||
sha=$(git -C "${APERANT_DIR}" rev-parse --short HEAD 2>/dev/null || echo "?")
|
||||
|
||||
echo "┌──────────────────────────────────────┐"
|
||||
echo "│ Aperant v${ver} │"
|
||||
echo "│ branch : ${branch} │"
|
||||
echo "│ commit : ${sha} │"
|
||||
echo "│ path : tools/aperant │"
|
||||
echo "└──────────────────────────────────────┘"
|
||||
|
||||
# Check if desktop app deps are installed
|
||||
if [ -d "${APERANT_DIR}/apps/desktop/node_modules" ]; then
|
||||
echo " deps : installed ✓"
|
||||
else
|
||||
echo " deps : not installed (run: make aperant-install)"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_install() {
|
||||
_ensure_submodule
|
||||
_require_node
|
||||
echo "── Installing Aperant dependencies …"
|
||||
(cd "${APERANT_DIR}" && npm run install:all)
|
||||
echo "✓ Aperant dependencies installed."
|
||||
}
|
||||
|
||||
cmd_dev() {
|
||||
_ensure_submodule
|
||||
_require_node
|
||||
echo "── Launching Aperant in dev mode …"
|
||||
(cd "${APERANT_DIR}" && npm run dev)
|
||||
}
|
||||
|
||||
cmd_start() {
|
||||
_ensure_submodule
|
||||
_require_node
|
||||
echo "── Building & launching Aperant …"
|
||||
(cd "${APERANT_DIR}" && npm start)
|
||||
}
|
||||
|
||||
cmd_build() {
|
||||
_ensure_submodule
|
||||
_require_node
|
||||
echo "── Building Aperant …"
|
||||
(cd "${APERANT_DIR}" && npm run build)
|
||||
}
|
||||
|
||||
cmd_test() {
|
||||
_ensure_submodule
|
||||
_require_node
|
||||
echo "── Running Aperant tests …"
|
||||
(cd "${APERANT_DIR}" && npm test)
|
||||
}
|
||||
|
||||
cmd_update() {
|
||||
_ensure_submodule
|
||||
echo "── Pulling latest Aperant (develop) …"
|
||||
git -C "${APERANT_DIR}" fetch origin
|
||||
git -C "${APERANT_DIR}" checkout develop
|
||||
git -C "${APERANT_DIR}" pull --ff-only origin develop
|
||||
echo "✓ Aperant updated to $(git -C "${APERANT_DIR}" rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
# ── web commands ─────────────────────────────────────────────────
|
||||
|
||||
cmd_web_install() {
|
||||
_ensure_submodule
|
||||
_require_node
|
||||
echo "── Installing Aperant Web dependencies …"
|
||||
(cd "${WEB_DIR}" && npm install)
|
||||
echo "✓ Web dependencies installed."
|
||||
}
|
||||
|
||||
cmd_web_dev() {
|
||||
_ensure_submodule
|
||||
_require_node
|
||||
echo "── Launching Aperant Web (UI :5180 + API :5181) …"
|
||||
(cd "${WEB_DIR}" && npm run dev)
|
||||
}
|
||||
|
||||
cmd_web_build() {
|
||||
_ensure_submodule
|
||||
_require_node
|
||||
echo "── Building Aperant Web …"
|
||||
(cd "${WEB_DIR}" && npm run build)
|
||||
}
|
||||
|
||||
cmd_deploy() {
|
||||
_ensure_submodule
|
||||
_require_node
|
||||
echo "── Deploying Aperant Web → aperant.saillant.cc (tower) …"
|
||||
bash "${WEB_DIR}/deploy.sh"
|
||||
}
|
||||
|
||||
cmd_tower_status() {
|
||||
echo "── Aperant Web on tower …"
|
||||
ssh "${TOWER_HOST}" "pm2 describe aperant-web 2>/dev/null || echo 'not running'"
|
||||
}
|
||||
|
||||
cmd_tower_logs() {
|
||||
ssh "${TOWER_HOST}" "pm2 logs aperant-web --lines ${2:-50}"
|
||||
}
|
||||
|
||||
# ── main ─────────────────────────────────────────────────────────
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: bash tools/cockpit/aperant_bridge.sh <command>
|
||||
|
||||
Submodule (Electron desktop):
|
||||
status Show Aperant version, branch, and dep state
|
||||
install Install Aperant desktop app dependencies
|
||||
dev Launch Aperant in dev mode (HMR)
|
||||
start Build & launch Aperant desktop app
|
||||
build Build Aperant (no launch)
|
||||
test Run Aperant test suite
|
||||
update Pull latest from Aperant develop branch
|
||||
|
||||
Web (aperant.saillant.cc):
|
||||
web-install Install web app dependencies
|
||||
web-dev Launch web UI (:5180) + API (:5181) locally
|
||||
web-build Build web app for production
|
||||
deploy Build + deploy to tower (clems@192.168.0.120)
|
||||
tower-status Check PM2 status on tower
|
||||
tower-logs Tail PM2 logs on tower
|
||||
|
||||
Aperant is an autonomous multi-agent AI coding framework that
|
||||
orchestrates Claude Code agents for planning, building, and QA.
|
||||
EOF
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
status) cmd_status ;;
|
||||
install) cmd_install ;;
|
||||
dev) cmd_dev ;;
|
||||
start) cmd_start ;;
|
||||
build) cmd_build ;;
|
||||
test) cmd_test ;;
|
||||
update) cmd_update ;;
|
||||
web-install) cmd_web_install ;;
|
||||
web-dev) cmd_web_dev ;;
|
||||
web-build) cmd_web_build ;;
|
||||
deploy) cmd_deploy ;;
|
||||
tower-status) cmd_tower_status ;;
|
||||
tower-logs) cmd_tower_logs ;;
|
||||
-h|--help|"") usage ;;
|
||||
*)
|
||||
echo "Unknown command: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+158
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# artifact_wms_index_tui.sh
|
||||
# Index latest artifacts by lot, layer, and consumer.
|
||||
# Contract: cockpit-v1
|
||||
# Date: 2026-03-22
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
ARTIFACT_ROOT="${ARTIFACT_ROOT:-${ROOT_DIR}/artifacts}"
|
||||
RULES_FILE="${RULES_FILE:-${ROOT_DIR}/specs/contracts/artifact_wms_index_rules.json}"
|
||||
ACTION="summary"
|
||||
JSON_MODE=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: artifact_wms_index_tui.sh [--action summary|entries|unknown] [--json]
|
||||
|
||||
Actions:
|
||||
summary Show aggregate WMS artifact index summary
|
||||
entries Show all indexed latest artifacts
|
||||
unknown Show only artifacts that did not match a rule
|
||||
|
||||
Options:
|
||||
--json Emit cockpit-v1 JSON
|
||||
--help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
run_view() {
|
||||
python3 - "$ARTIFACT_ROOT" "$RULES_FILE" "$ACTION" "$JSON_MODE" <<'PY'
|
||||
import fnmatch
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
artifact_root = Path(sys.argv[1])
|
||||
rules_file = Path(sys.argv[2])
|
||||
action = sys.argv[3]
|
||||
json_mode = sys.argv[4] == "1"
|
||||
|
||||
rules = json.loads(rules_file.read_text(encoding="utf-8"))
|
||||
patterns = rules["scan"]["latest_patterns"]
|
||||
rule_entries = rules["rules"]
|
||||
defaults = rules["defaults"]
|
||||
|
||||
latest_files = []
|
||||
for pattern in patterns:
|
||||
latest_files.extend(artifact_root.rglob(pattern))
|
||||
|
||||
seen = set()
|
||||
entries = []
|
||||
|
||||
def match_rule(relpath: str):
|
||||
for rule in rule_entries:
|
||||
if rule["match"] in relpath:
|
||||
return rule
|
||||
return defaults
|
||||
|
||||
for path in sorted(latest_files):
|
||||
rel = path.relative_to(artifact_root).as_posix()
|
||||
if rel in seen:
|
||||
continue
|
||||
seen.add(rel)
|
||||
rule = match_rule(rel)
|
||||
entry = {
|
||||
"artifact": rel,
|
||||
"absolute_path": str(path),
|
||||
"consumer_layer": rule["consumer_layer"],
|
||||
"owner_agent": rule["owner_agent"],
|
||||
"lot_refs": rule["lot_refs"],
|
||||
"purpose": rule["purpose"],
|
||||
"size_bytes": path.stat().st_size if path.exists() else 0,
|
||||
"status": "known" if rule is not defaults else "unknown",
|
||||
}
|
||||
entries.append(entry)
|
||||
|
||||
unknown = [entry for entry in entries if entry["status"] == "unknown"]
|
||||
|
||||
payload = {
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "artifact-wms-index",
|
||||
"action": action,
|
||||
"status": "ok",
|
||||
"artifact_root": str(artifact_root),
|
||||
"rules_file": str(rules_file),
|
||||
"entries_total": len(entries),
|
||||
"unknown_total": len(unknown),
|
||||
}
|
||||
|
||||
if action == "summary":
|
||||
counts = {}
|
||||
for entry in entries:
|
||||
counts[entry["consumer_layer"]] = counts.get(entry["consumer_layer"], 0) + 1
|
||||
payload["counts_by_consumer_layer"] = counts
|
||||
payload["sample_latest"] = entries[:10]
|
||||
elif action == "entries":
|
||||
payload["entries"] = entries
|
||||
elif action == "unknown":
|
||||
payload["entries"] = unknown
|
||||
else:
|
||||
raise SystemExit(f"unknown action: {action}")
|
||||
|
||||
if json_mode:
|
||||
print(json.dumps(payload, indent=2))
|
||||
raise SystemExit(0)
|
||||
|
||||
if action == "summary":
|
||||
print("Artifact WMS Index")
|
||||
print(f"artifact root: {artifact_root}")
|
||||
print(f"rules file: {rules_file}")
|
||||
print(f"entries total: {len(entries)}")
|
||||
print(f"unknown total: {len(unknown)}")
|
||||
for layer, count in sorted(payload["counts_by_consumer_layer"].items()):
|
||||
print(f"- {layer}: {count}")
|
||||
elif action in {"entries", "unknown"}:
|
||||
print("Artifacts")
|
||||
for entry in payload["entries"]:
|
||||
print(
|
||||
f"- {entry['artifact']} | consumer={entry['consumer_layer']} | "
|
||||
f"owner={entry['owner_agent']} | lots={','.join(entry['lot_refs']) or '-'} | "
|
||||
f"status={entry['status']}"
|
||||
)
|
||||
PY
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
JSON_MODE=1
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$ACTION" in
|
||||
summary|entries|unknown)
|
||||
run_view
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown action: %s\n' "$ACTION" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Executable
+440
@@ -0,0 +1,440 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ============================================================================
|
||||
# dataset_audit_tui.sh — Audit and preflight for Mistral fine-tune datasets
|
||||
# Contract: cockpit-v1
|
||||
# Lots: 23 / 24 — T-MA-015, T-MS-002, T-MS-003
|
||||
# Date: 2026-03-22
|
||||
# ============================================================================
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
MASCARADE_ROOT="${MASCARADE_ROOT:-/Users/electron/Documents/Projets/mascarade}"
|
||||
FINETUNE_DIR="${FINETUNE_DIR:-${MASCARADE_ROOT}/finetune}"
|
||||
DATASETS_DIR="${DATASETS_DIR:-${FINETUNE_DIR}/datasets}"
|
||||
FORBIDDEN_MASCARADE_ROOT="${FORBIDDEN_MASCARADE_ROOT:-/Users/electron/Documents/Lelectron_rare/Github_Repos/Perso/mascarade-main}"
|
||||
WORKSPACE_GUARD="${ROOT_DIR}/tools/cockpit/mistral_workspace_guard.sh"
|
||||
DATASET_VM_HOST="${DATASET_VM_HOST:-photon-docker}"
|
||||
|
||||
ACTION="audit"
|
||||
JSON_MODE=0
|
||||
|
||||
DATASETS=(
|
||||
"kicad:build_kicad_dataset.py:KiCad PCB/Schematic"
|
||||
"spice:build_spice_dataset.py:SPICE Simulation"
|
||||
"freecad:build_freecad_dataset.py:FreeCAD 3D/Mecanique"
|
||||
"stm32:build_stm32_dataset.py:STM32 HAL/Firmware"
|
||||
"embedded:build_embedded_dataset.py:Embedded C/C++"
|
||||
"iot:build_iot_dataset.py:IoT MQTT/LoRa/Zigbee"
|
||||
"emc:build_emc_dataset.py:EMC/CEM Compatibilite"
|
||||
"dsp:build_dsp_dataset.py:DSP Traitement Signal"
|
||||
"power:build_power_dataset.py:Power Electronics"
|
||||
"platformio:build_platformio_dataset.py:PlatformIO CI/CD"
|
||||
)
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: dataset_audit_tui.sh [--action audit|preflight|paths] [--json]
|
||||
|
||||
Actions:
|
||||
audit Static analysis of dataset builders and generated files
|
||||
preflight Report readiness for T-MS-002/003 and downstream fine-tune lots
|
||||
paths Print canonical paths
|
||||
|
||||
Options:
|
||||
--json Emit cockpit-v1 JSON only
|
||||
--help Show this help
|
||||
|
||||
Env vars:
|
||||
MASCARADE_ROOT Active Mascarade workspace (default: /Users/electron/Documents/Projets/mascarade)
|
||||
FINETUNE_DIR Fine-tune root override
|
||||
DATASETS_DIR Dataset builders/output root override
|
||||
DATASET_VM_HOST VM host label for upload/fine-tune lots (default: photon-docker)
|
||||
EOF
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
python3 - "$1" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
print(json.dumps(sys.argv[1]))
|
||||
PY
|
||||
}
|
||||
|
||||
bool_json() {
|
||||
if [[ "$1" == "1" ]]; then
|
||||
printf 'true'
|
||||
else
|
||||
printf 'false'
|
||||
fi
|
||||
}
|
||||
|
||||
builder_path() {
|
||||
local script="$1"
|
||||
if [[ -f "${DATASETS_DIR}/${script}" ]]; then
|
||||
printf '%s\n' "${DATASETS_DIR}/${script}"
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "${FINETUNE_DIR}/${script}" ]]; then
|
||||
printf '%s\n' "${FINETUNE_DIR}/${script}"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
count_found_builders() {
|
||||
local found=0
|
||||
local entry script
|
||||
for entry in "${DATASETS[@]}"; do
|
||||
IFS=':' read -r _ script _ <<< "${entry}"
|
||||
if builder_path "${script}" >/dev/null; then
|
||||
((found+=1))
|
||||
fi
|
||||
done
|
||||
printf '%s\n' "${found}"
|
||||
}
|
||||
|
||||
count_generated_files() {
|
||||
local found=0
|
||||
local dir
|
||||
local dirs=(
|
||||
"${DATASETS_DIR}"
|
||||
"${FINETUNE_DIR}/datasets"
|
||||
"${FINETUNE_DIR}"
|
||||
)
|
||||
|
||||
for dir in "${dirs[@]}"; do
|
||||
if [[ -d "${dir}" ]]; then
|
||||
while IFS= read -r _; do
|
||||
((found+=1))
|
||||
done < <(find "${dir}" -maxdepth 1 -type f \( -name '*.jsonl' -o -name '*.json' -o -name '*.csv' -o -name '*.parquet' \) 2>/dev/null)
|
||||
fi
|
||||
done
|
||||
|
||||
printf '%s\n' "${found}"
|
||||
}
|
||||
|
||||
print_header() {
|
||||
echo -e "${BOLD}${CYAN}╔══════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BOLD}${CYAN}║ Dataset Fine-tune Audit / Preflight ║${NC}"
|
||||
echo -e "${BOLD}${CYAN}║ $(date '+%Y-%m-%d %H:%M:%S') ║${NC}"
|
||||
echo -e "${BOLD}${CYAN}╚══════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
emit_status_json() {
|
||||
local component="$1"
|
||||
local action="$2"
|
||||
local status="$3"
|
||||
local reason="$4"
|
||||
local builders_found="$5"
|
||||
local generated_files="$6"
|
||||
local upload_ready="$7"
|
||||
local finetune_ready="$8"
|
||||
local workspace_guard_available=0
|
||||
|
||||
[[ -x "${WORKSPACE_GUARD}" ]] && workspace_guard_available=1
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "${component}",
|
||||
"action": "${action}",
|
||||
"status": "${status}",
|
||||
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"reason": $(json_escape "${reason}"),
|
||||
"tracking_root": $(json_escape "${ROOT_DIR}"),
|
||||
"mascarade_root": $(json_escape "${MASCARADE_ROOT}"),
|
||||
"finetune_dir": $(json_escape "${FINETUNE_DIR}"),
|
||||
"datasets_dir": $(json_escape "${DATASETS_DIR}"),
|
||||
"forbidden_mascarade_root": $(json_escape "${FORBIDDEN_MASCARADE_ROOT}"),
|
||||
"workspace_guard": $(json_escape "${WORKSPACE_GUARD}"),
|
||||
"workspace_guard_available": $(bool_json "${workspace_guard_available}"),
|
||||
"mascarade_root_exists": $(bool_json "$([[ -d "${MASCARADE_ROOT}" ]] && echo 1 || echo 0)"),
|
||||
"finetune_dir_exists": $(bool_json "$([[ -d "${FINETUNE_DIR}" ]] && echo 1 || echo 0)"),
|
||||
"datasets_dir_exists": $(bool_json "$([[ -d "${DATASETS_DIR}" ]] && echo 1 || echo 0)"),
|
||||
"forbidden_copy_present": $(bool_json "$([[ -e "${FORBIDDEN_MASCARADE_ROOT}" ]] && echo 1 || echo 0)"),
|
||||
"builders_total": ${#DATASETS[@]},
|
||||
"builders_found": ${builders_found},
|
||||
"generated_files": ${generated_files},
|
||||
"dataset_vm_host": $(json_escape "${DATASET_VM_HOST}"),
|
||||
"dataset_vm_required": true,
|
||||
"dataset_vm_access_checked": false,
|
||||
"upload_ready": $(bool_json "${upload_ready}"),
|
||||
"finetune_ready": $(bool_json "${finetune_ready}"),
|
||||
"blocked_lots": ["T-MS-002","T-MS-003","T-MA-016","T-MA-017","T-MA-021"],
|
||||
"next_steps": [
|
||||
"Use the active Mascarade workspace only: /Users/electron/Documents/Projets/mascarade",
|
||||
"Prepare merged and validated datasets before T-MS-002/T-MS-003",
|
||||
"Run upload and fine-tune lots on the dataset VM host after local preflight is green"
|
||||
]
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
action_paths() {
|
||||
if [[ "${JSON_MODE}" -eq 1 ]]; then
|
||||
emit_status_json "dataset-audit" "paths" "ok" "canonical paths" 0 0 0 0
|
||||
else
|
||||
printf '%s\n' "${ROOT_DIR}"
|
||||
printf '%s\n' "${MASCARADE_ROOT}"
|
||||
printf '%s\n' "${FINETUNE_DIR}"
|
||||
printf '%s\n' "${DATASETS_DIR}"
|
||||
printf '%s\n' "${FORBIDDEN_MASCARADE_ROOT}"
|
||||
fi
|
||||
}
|
||||
|
||||
action_preflight() {
|
||||
local builders_found generated_files status reason upload_ready=0 finetune_ready=0
|
||||
builders_found="$(count_found_builders)"
|
||||
generated_files="$(count_generated_files)"
|
||||
status="ready"
|
||||
reason="dataset builders and outputs available for the next VM-bound lots"
|
||||
|
||||
if [[ ! -d "${MASCARADE_ROOT}" || ! -d "${FINETUNE_DIR}" || ! -d "${DATASETS_DIR}" ]]; then
|
||||
status="blocked"
|
||||
reason="active Mascarade fine-tune paths missing"
|
||||
elif [[ "${builders_found}" -lt "${#DATASETS[@]}" ]]; then
|
||||
status="degraded"
|
||||
reason="dataset builder coverage incomplete"
|
||||
elif [[ "${generated_files}" -eq 0 ]]; then
|
||||
status="degraded"
|
||||
reason="no generated dataset files found yet"
|
||||
fi
|
||||
|
||||
if [[ "${status}" == "ready" ]]; then
|
||||
upload_ready=1
|
||||
finetune_ready=1
|
||||
fi
|
||||
|
||||
if [[ "${JSON_MODE}" -eq 1 ]]; then
|
||||
emit_status_json "dataset-audit" "preflight" "${status}" "${reason}" "${builders_found}" "${generated_files}" "${upload_ready}" "${finetune_ready}"
|
||||
return
|
||||
fi
|
||||
|
||||
print_header
|
||||
echo -e "${BOLD}[Preflight]${NC}"
|
||||
echo -e " Status: ${status}"
|
||||
echo -e " Reason: ${reason}"
|
||||
echo -e " Active Mascarade root: ${MASCARADE_ROOT}"
|
||||
echo -e " Fine-tune dir: ${FINETUNE_DIR}"
|
||||
echo -e " Datasets dir: ${DATASETS_DIR}"
|
||||
echo -e " Builders found: ${builders_found}/${#DATASETS[@]}"
|
||||
echo -e " Generated files: ${generated_files}"
|
||||
echo -e " VM host for upload/fine-tune: ${DATASET_VM_HOST}"
|
||||
echo -e " Lots gated by VM: T-MS-002, T-MS-003, T-MA-016, T-MA-017, T-MA-021"
|
||||
}
|
||||
|
||||
audit_builder() {
|
||||
local name="$1"
|
||||
local script="$2"
|
||||
local desc="$3"
|
||||
local script_path=""
|
||||
|
||||
if script_path="$(builder_path "${script}" 2>/dev/null)"; then
|
||||
:
|
||||
else
|
||||
echo -e " ${RED}x${NC} ${BOLD}${name}${NC} (${desc}): ${RED}Script not found${NC}"
|
||||
echo "NOT_FOUND"
|
||||
return
|
||||
fi
|
||||
|
||||
local lines score has_output_format has_validation has_dedup has_balance_check issues
|
||||
lines="$(wc -l < "${script_path}" 2>/dev/null || echo 0)"
|
||||
score=0
|
||||
has_output_format=0
|
||||
has_validation=0
|
||||
has_dedup=0
|
||||
has_balance_check=0
|
||||
issues=""
|
||||
|
||||
grep -Eq "jsonl|json\.dumps|to_json|ChatML|messages" "${script_path}" 2>/dev/null && has_output_format=1
|
||||
grep -Eq "validate|assert|check|quality|filter" "${script_path}" 2>/dev/null && has_validation=1
|
||||
grep -Eq "deduplicate|dedup|drop_duplicates|set\(\)|unique" "${script_path}" 2>/dev/null && has_dedup=1
|
||||
grep -Eq "balance|distribution|count.*category|value_counts|Counter" "${script_path}" 2>/dev/null && has_balance_check=1
|
||||
|
||||
[[ "${lines}" -gt 50 ]] && ((score+=1))
|
||||
[[ "${lines}" -gt 200 ]] && ((score+=1))
|
||||
[[ "${has_output_format}" -eq 1 ]] && ((score+=2))
|
||||
[[ "${has_validation}" -eq 1 ]] && ((score+=2))
|
||||
[[ "${has_dedup}" -eq 1 ]] && ((score+=1))
|
||||
[[ "${has_balance_check}" -eq 1 ]] && ((score+=1))
|
||||
|
||||
[[ "${lines}" -lt 20 ]] && issues="${issues}skeleton-only "
|
||||
[[ "${has_output_format}" -eq 0 ]] && issues="${issues}no-output-format "
|
||||
[[ "${has_validation}" -eq 0 ]] && issues="${issues}no-validation "
|
||||
[[ "${has_dedup}" -eq 0 ]] && issues="${issues}no-dedup "
|
||||
|
||||
local color="${RED}"
|
||||
local status_text="POOR"
|
||||
if [[ "${score}" -ge 6 ]]; then
|
||||
color="${GREEN}"
|
||||
status_text="GOOD"
|
||||
elif [[ "${score}" -ge 3 ]]; then
|
||||
color="${YELLOW}"
|
||||
status_text="FAIR"
|
||||
fi
|
||||
|
||||
echo -e " ${color}o${NC} ${BOLD}${name}${NC} (${desc})"
|
||||
echo -e " Path: ${script_path}"
|
||||
echo -e " Lines: ${lines} | Score: ${score}/8 | Status: ${color}${status_text}${NC}"
|
||||
if [[ -n "${issues}" ]]; then
|
||||
echo -e " Issues: ${YELLOW}${issues}${NC}"
|
||||
fi
|
||||
echo "${score}"
|
||||
}
|
||||
|
||||
check_generated_datasets() {
|
||||
echo -e "\n${BOLD}[Generated Dataset Files]${NC}"
|
||||
|
||||
local dirs=(
|
||||
"${DATASETS_DIR}"
|
||||
"${FINETUNE_DIR}/datasets"
|
||||
"${FINETUNE_DIR}"
|
||||
)
|
||||
local dir
|
||||
local found=0
|
||||
|
||||
for dir in "${dirs[@]}"; do
|
||||
if [[ -d "${dir}" ]]; then
|
||||
while IFS= read -r f; do
|
||||
local size lines
|
||||
size="$(du -h "${f}" 2>/dev/null | cut -f1)"
|
||||
lines="$(wc -l < "${f}" 2>/dev/null || echo '?')"
|
||||
echo -e " ${GREEN}o${NC} $(basename "${f}"): ${size} (${lines} lines)"
|
||||
((found+=1))
|
||||
done < <(find "${dir}" -maxdepth 1 -type f \( -name '*.jsonl' -o -name '*.json' -o -name '*.csv' -o -name '*.parquet' \) 2>/dev/null)
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "${found}" -eq 0 ]]; then
|
||||
echo -e " ${YELLOW}o${NC} No generated dataset files found"
|
||||
fi
|
||||
|
||||
echo -e " Total: ${found} dataset files"
|
||||
}
|
||||
|
||||
action_audit() {
|
||||
local builders_found generated_files
|
||||
builders_found="$(count_found_builders)"
|
||||
generated_files="$(count_generated_files)"
|
||||
|
||||
if [[ "${JSON_MODE}" -eq 1 ]]; then
|
||||
local status="pass"
|
||||
local reason="dataset audit completed"
|
||||
local upload_ready=0
|
||||
local finetune_ready=0
|
||||
if [[ ! -d "${MASCARADE_ROOT}" || ! -d "${FINETUNE_DIR}" || ! -d "${DATASETS_DIR}" ]]; then
|
||||
status="blocked"
|
||||
reason="active Mascarade fine-tune paths missing"
|
||||
elif [[ "${builders_found}" -lt "${#DATASETS[@]}" || "${generated_files}" -eq 0 ]]; then
|
||||
status="needs-work"
|
||||
reason="dataset builders or outputs incomplete"
|
||||
else
|
||||
upload_ready=1
|
||||
finetune_ready=1
|
||||
fi
|
||||
emit_status_json "dataset-audit" "audit" "${status}" "${reason}" "${builders_found}" "${generated_files}" "${upload_ready}" "${finetune_ready}"
|
||||
return
|
||||
fi
|
||||
|
||||
print_header
|
||||
echo -e "${BOLD}[Workspace Policy]${NC}"
|
||||
echo -e " Tracking root: ${ROOT_DIR}"
|
||||
echo -e " Active Mascarade root: ${MASCARADE_ROOT}"
|
||||
echo -e " Forbidden copy: ${FORBIDDEN_MASCARADE_ROOT}"
|
||||
echo -e " VM host for upload/fine-tune: ${DATASET_VM_HOST}"
|
||||
echo ""
|
||||
|
||||
echo -e "${BOLD}[Builder Scripts Analysis]${NC}\n"
|
||||
|
||||
local total=0 good=0 fair=0 poor=0 missing=0 total_score=0
|
||||
local entry result score
|
||||
for entry in "${DATASETS[@]}"; do
|
||||
IFS=':' read -r name script desc <<< "${entry}"
|
||||
((total+=1))
|
||||
result="$(audit_builder "${name}" "${script}" "${desc}" 2>/dev/null)"
|
||||
score="$(echo "${result}" | tail -n 1)"
|
||||
|
||||
case "${score}" in
|
||||
NOT_FOUND) ((missing+=1)) ;;
|
||||
[6-8]) ((good+=1)); total_score=$((total_score + score)) ;;
|
||||
[3-5]) ((fair+=1)); total_score=$((total_score + score)) ;;
|
||||
*) ((poor+=1)); total_score=$((total_score + ${score:-0})) ;;
|
||||
esac
|
||||
|
||||
echo "${result}" | head -n -1
|
||||
echo ""
|
||||
done
|
||||
|
||||
check_generated_datasets
|
||||
echo ""
|
||||
|
||||
local avg_score=0
|
||||
[[ "${total}" -gt 0 ]] && avg_score=$((total_score / total))
|
||||
|
||||
echo -e "${BOLD}[Audit Summary]${NC}"
|
||||
echo -e " Builders: ${total} total"
|
||||
echo -e " ${GREEN}Good${NC}: ${good} | ${YELLOW}Fair${NC}: ${fair} | ${RED}Poor${NC}: ${poor} | Missing: ${missing}"
|
||||
echo -e " Average score: ${avg_score}/8"
|
||||
echo -e " Builders found on disk: ${builders_found}/${#DATASETS[@]}"
|
||||
echo -e " Generated dataset files: ${generated_files}"
|
||||
echo ""
|
||||
|
||||
echo -e "${BOLD}[Lot Readiness]${NC}"
|
||||
echo -e " Local audit lot: T-MA-015"
|
||||
echo -e " VM-gated upload lots: T-MS-002, T-MS-003"
|
||||
echo -e " VM-gated fine-tune lots: T-MA-016, T-MA-017"
|
||||
echo -e " Downstream benchmark lot: T-MA-021"
|
||||
if [[ "${missing}" -gt 0 || "${generated_files}" -eq 0 ]]; then
|
||||
echo -e " ${YELLOW}->${NC} Local preflight is not ready for VM upload/fine-tune"
|
||||
else
|
||||
echo -e " ${GREEN}o${NC} Local preflight is ready; next gate remains VM execution"
|
||||
fi
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
JSON_MODE=1
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "${ACTION}" in
|
||||
audit)
|
||||
action_audit
|
||||
;;
|
||||
preflight)
|
||||
action_preflight
|
||||
;;
|
||||
paths)
|
||||
action_paths
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown action: %s\n' "${ACTION}" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,471 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# e2e_agents_test.sh — Tests E2E Mistral Agents (Lot 23 T-MA-025)
|
||||
#
|
||||
# Scénarios:
|
||||
# 1. Sentinelle health-check → diagnostic
|
||||
# 2. Devstral code review → fix suggestion
|
||||
# 3. Handoff Sentinelle → Devstral (détection anomalie → correction auto)
|
||||
# 4. Tower email generation → validation template
|
||||
# 5. Forge dataset validation → score
|
||||
#
|
||||
# Usage:
|
||||
# bash e2e_agents_test.sh --action all
|
||||
# bash e2e_agents_test.sh --action sentinelle
|
||||
# bash e2e_agents_test.sh --action handoff
|
||||
# bash e2e_agents_test.sh --json
|
||||
#
|
||||
# Contract: cockpit-v1
|
||||
# Owner: QA + PM-Mesh
|
||||
# Date: 2026-03-21
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
ARTIFACTS_DIR="${ROOT_DIR}/artifacts/e2e_agents_test"
|
||||
mkdir -p "${ARTIFACTS_DIR}"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "${ROOT_DIR}/tools/cockpit/load_mistral_governance_env.sh"
|
||||
|
||||
STAMP="$(date +%Y%m%d_%H%M%S)"
|
||||
LOG_FILE="${ARTIFACTS_DIR}/e2e_${STAMP}.log"
|
||||
|
||||
ACTION=""
|
||||
JSON_MODE=0
|
||||
VERBOSE=0
|
||||
TIMEOUT=30
|
||||
|
||||
MISTRAL_BASE="${MISTRAL_BASE:-https://api.mistral.ai/v1}"
|
||||
API_KEY="${MISTRAL_GOVERNANCE_API_KEY:-${MISTRAL_API_KEY:-${MISTRAL_AGENTS_API_KEY:-}}}"
|
||||
|
||||
# Agent IDs (from lot 23)
|
||||
SENTINELLE_ID="${MISTRAL_AGENT_SENTINELLE_ID:-ag_019d124c302375a8bf06f9ff8a99fb5f}"
|
||||
TOWER_ID="${MISTRAL_AGENT_TOWER_ID:-ag_019d124e760877359ad3ff5031179ebc}"
|
||||
FORGE_ID="${MISTRAL_AGENT_FORGE_ID:-ag_019d1251023f73258b80ac73f90458f6}"
|
||||
DEVSTRAL_ID="${MISTRAL_AGENT_DEVSTRAL_ID:-ag_019d125348eb77e880df33acbd395efa}"
|
||||
|
||||
# Couleurs
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
SKIP=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: e2e_agents_test.sh --action <all|sentinelle|tower|forge|devstral|handoff> [options]
|
||||
|
||||
Options:
|
||||
--action <name> Test suite to run (default: all)
|
||||
--api-mode <mode> API mode: beta (conversations) or deprecated (agents/completions)
|
||||
--json Emit JSON report
|
||||
--timeout <sec> API timeout in seconds (default: 30)
|
||||
--verbose Show full API responses
|
||||
--help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
log_info() { printf "${CYAN}[INFO]${NC} %s\n" "$*"; }
|
||||
log_warn() { printf "${YELLOW}[WARN]${NC} %s\n" "$*"; }
|
||||
log_pass() { printf "${GREEN}[PASS]${NC} %s\n" "$*"; ((PASS++)); }
|
||||
log_fail() { printf "${RED}[FAIL]${NC} %s\n" "$*"; ((FAIL++)); }
|
||||
log_skip() { printf "${YELLOW}[SKIP]${NC} %s\n" "$*"; ((SKIP++)); }
|
||||
|
||||
check_api_key() {
|
||||
if [[ -z "${API_KEY}" ]]; then
|
||||
log_fail "MISTRAL_API_KEY ou MISTRAL_AGENTS_API_KEY non défini"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# API Mode: "beta" (conversations/completions) or "deprecated" (agents/completions)
|
||||
API_MODE="${API_MODE:-beta}"
|
||||
|
||||
# ── Agent Chat Helper ─────────────────────────────────────────────────────
|
||||
build_beta_payload() {
|
||||
local agent_id="$1"
|
||||
local message="$2"
|
||||
python3 - "$agent_id" "$message" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
agent_id = sys.argv[1]
|
||||
message = sys.argv[2]
|
||||
print(json.dumps({
|
||||
"agent_id": agent_id,
|
||||
"inputs": [{"role": "user", "content": message}],
|
||||
}))
|
||||
PY
|
||||
}
|
||||
|
||||
build_deprecated_payload() {
|
||||
local message="$1"
|
||||
python3 - "$message" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
message = sys.argv[1]
|
||||
print(json.dumps({
|
||||
"messages": [{"role": "user", "content": message}],
|
||||
}))
|
||||
PY
|
||||
}
|
||||
|
||||
_call_api() {
|
||||
local endpoint="$1"
|
||||
local payload="$2"
|
||||
local label="${3:-agent}"
|
||||
|
||||
local response
|
||||
response=$(curl -s -w "\n%{http_code}" \
|
||||
--max-time "${TIMEOUT}" \
|
||||
-H "Authorization: Bearer ${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "${payload}" \
|
||||
"${MISTRAL_BASE}/${endpoint}" 2>/dev/null) || true
|
||||
|
||||
local http_code
|
||||
http_code=$(echo "${response}" | tail -1)
|
||||
local body
|
||||
body=$(echo "${response}" | sed '$d')
|
||||
|
||||
if [[ "${VERBOSE}" -eq 1 ]]; then
|
||||
printf "[%s] HTTP %s -> %s\n%s\n" "${label}" "${http_code}" "${endpoint}" "${body}" >&2
|
||||
fi
|
||||
|
||||
if [[ "${http_code}" == "200" ]]; then
|
||||
echo "${body}"
|
||||
return 0
|
||||
else
|
||||
echo "HTTP_ERROR:${http_code}:${body}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
call_agent() {
|
||||
local agent_id="$1"
|
||||
local message="$2"
|
||||
local label="${3:-agent}"
|
||||
local beta_payload
|
||||
local deprecated_payload
|
||||
local result=""
|
||||
|
||||
beta_payload="$(build_beta_payload "${agent_id}" "${message}")"
|
||||
deprecated_payload="$(build_deprecated_payload "${message}")"
|
||||
|
||||
# Try Beta Conversations API first
|
||||
if [[ "${API_MODE}" == "beta" ]]; then
|
||||
if result=$(_call_api "conversations" "${beta_payload}" "${label}" 2>/dev/null); then
|
||||
printf "${CYAN}[INFO]${NC} %s\n" " -> Beta API (/conversations)" >&2
|
||||
echo "${result}"
|
||||
return 0
|
||||
fi
|
||||
printf "${YELLOW}[WARN]${NC} %s\n" " -> Beta API failed, fallback deprecated" >&2
|
||||
fi
|
||||
|
||||
# Deprecated API (fallback or explicit mode)
|
||||
if result=$(_call_api "agents/${agent_id}/completions" "${deprecated_payload}" "${label}" 2>/dev/null); then
|
||||
printf "${CYAN}[INFO]${NC} %s\n" " -> Deprecated API (/agents/${agent_id}/completions)" >&2
|
||||
echo "${result}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "${result}"
|
||||
return 1
|
||||
}
|
||||
|
||||
extract_content() {
|
||||
local response="$1"
|
||||
python3 - "$response" <<'PY' 2>/dev/null || echo ""
|
||||
import json
|
||||
import sys
|
||||
|
||||
raw = sys.argv[1]
|
||||
|
||||
def render_content(content):
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
chunks = []
|
||||
for item in content:
|
||||
if isinstance(item, str):
|
||||
if item:
|
||||
chunks.append(item)
|
||||
continue
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
text = item.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
chunks.append(text)
|
||||
return "\n".join(chunks)
|
||||
return ""
|
||||
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
outputs = data.get("outputs", [])
|
||||
for out in outputs:
|
||||
if out.get("role") == "assistant":
|
||||
print(render_content(out.get("content", "")))
|
||||
raise SystemExit(0)
|
||||
choices = data.get("choices", [])
|
||||
if choices:
|
||||
print(render_content(choices[0].get("message", {}).get("content", "")))
|
||||
else:
|
||||
print("")
|
||||
except Exception:
|
||||
print("")
|
||||
PY
|
||||
}
|
||||
|
||||
# ── Test Suites ───────────────────────────────────────────────────────────
|
||||
|
||||
test_sentinelle() {
|
||||
log_info "=== Test Suite: Sentinelle (Ops Monitoring) ==="
|
||||
|
||||
# T1: Health check prompt
|
||||
log_info "T1: Sentinelle health diagnostic prompt"
|
||||
local resp
|
||||
if resp=$(call_agent "${SENTINELLE_ID}" \
|
||||
"Effectue un diagnostic rapide de l'état du système. Vérifie: CPU, RAM, services critiques. Réponds en JSON structuré." \
|
||||
"sentinelle-health" 2>/dev/null); then
|
||||
local content
|
||||
content=$(extract_content "${resp}")
|
||||
if [[ -n "${content}" && "${#content}" -gt 20 ]]; then
|
||||
log_pass "T1: Sentinelle a répondu (${#content} chars)"
|
||||
else
|
||||
log_fail "T1: Réponse Sentinelle vide ou trop courte"
|
||||
fi
|
||||
else
|
||||
log_fail "T1: Appel Sentinelle échoué (${resp})"
|
||||
fi
|
||||
|
||||
# T2: Anomaly detection prompt
|
||||
log_info "T2: Sentinelle anomaly detection"
|
||||
if resp=$(call_agent "${SENTINELLE_ID}" \
|
||||
"Analyse ce log d'erreur et identifie la cause probable: 'ERROR 2026-03-21 05:32:12 mascarade.router: Provider timeout after 30s on mistral-large. Retry 3/3 failed. Circuit breaker OPEN.' Propose un fix." \
|
||||
"sentinelle-anomaly" 2>/dev/null); then
|
||||
local content
|
||||
content=$(extract_content "${resp}")
|
||||
if [[ -n "${content}" && "${#content}" -gt 50 ]]; then
|
||||
log_pass "T2: Sentinelle anomaly analysis OK (${#content} chars)"
|
||||
else
|
||||
log_fail "T2: Réponse anomaly insuffisante"
|
||||
fi
|
||||
else
|
||||
log_fail "T2: Appel Sentinelle anomaly échoué"
|
||||
fi
|
||||
}
|
||||
|
||||
test_tower() {
|
||||
log_info "=== Test Suite: Tower (Commercial/CRM) ==="
|
||||
|
||||
# T3: Email generation
|
||||
log_info "T3: Tower email generation"
|
||||
local resp
|
||||
if resp=$(call_agent "${TOWER_ID}" \
|
||||
"Génère un email de premier contact pour un prospect ingénieur en électronique qui a téléchargé notre guide KiCad. Nom: Jean Dupont, Entreprise: TechnoBoard SAS. Ton professionnel mais chaleureux." \
|
||||
"tower-email" 2>/dev/null); then
|
||||
local content
|
||||
content=$(extract_content "${resp}")
|
||||
if [[ -n "${content}" && "${content}" == *"@"* || "${content}" == *"Dupont"* || "${content}" == *"KiCad"* ]]; then
|
||||
log_pass "T3: Tower email généré avec contexte (${#content} chars)"
|
||||
elif [[ -n "${content}" && "${#content}" -gt 50 ]]; then
|
||||
log_pass "T3: Tower email généré (${#content} chars, contexte partiel)"
|
||||
else
|
||||
log_fail "T3: Tower email insuffisant"
|
||||
fi
|
||||
else
|
||||
log_fail "T3: Appel Tower échoué"
|
||||
fi
|
||||
}
|
||||
|
||||
test_forge() {
|
||||
log_info "=== Test Suite: Forge (Fine-tune Pipeline) ==="
|
||||
|
||||
# T4: Dataset quality evaluation
|
||||
log_info "T4: Forge dataset quality assessment"
|
||||
local resp
|
||||
if resp=$(call_agent "${FORGE_ID}" \
|
||||
"Évalue la qualité de ce dataset JSONL pour fine-tune Mistral. Stats: 5700 exemples, 12 duplicates, format ChatML, avg 450 tokens, domaine KiCad EDA. Score /10 et recommandations." \
|
||||
"forge-quality" 2>/dev/null); then
|
||||
local content
|
||||
content=$(extract_content "${resp}")
|
||||
if [[ -n "${content}" && "${#content}" -gt 30 ]]; then
|
||||
log_pass "T4: Forge dataset assessment OK (${#content} chars)"
|
||||
else
|
||||
log_fail "T4: Forge assessment insuffisant"
|
||||
fi
|
||||
else
|
||||
log_fail "T4: Appel Forge échoué"
|
||||
fi
|
||||
}
|
||||
|
||||
test_devstral() {
|
||||
log_info "=== Test Suite: Devstral (Code/Dev Workflow) ==="
|
||||
|
||||
# T5: Code review
|
||||
log_info "T5: Devstral code review"
|
||||
local resp
|
||||
if resp=$(call_agent "${DEVSTRAL_ID}" \
|
||||
"Review ce code Python et suggère des améliorations:
|
||||
\`\`\`python
|
||||
def process_data(data):
|
||||
result = []
|
||||
for item in data:
|
||||
if item['status'] == 'active':
|
||||
result.append({'id': item['id'], 'name': item['name'].upper()})
|
||||
return result
|
||||
\`\`\`
|
||||
Focus: performance, robustesse, type hints." \
|
||||
"devstral-review" 2>/dev/null); then
|
||||
local content
|
||||
content=$(extract_content "${resp}")
|
||||
if [[ -n "${content}" && "${#content}" -gt 50 ]]; then
|
||||
log_pass "T5: Devstral code review OK (${#content} chars)"
|
||||
else
|
||||
log_fail "T5: Devstral review insuffisant"
|
||||
fi
|
||||
else
|
||||
log_fail "T5: Appel Devstral échoué"
|
||||
fi
|
||||
}
|
||||
|
||||
test_handoff() {
|
||||
log_info "=== Test Suite: Handoff Sentinelle → Devstral ==="
|
||||
|
||||
# T6: Sentinelle détecte anomalie → Devstral génère fix
|
||||
log_info "T6: Phase 1 — Sentinelle détecte anomalie"
|
||||
local sentinelle_resp
|
||||
if sentinelle_resp=$(call_agent "${SENTINELLE_ID}" \
|
||||
"Analyse cette erreur et formule un diagnostic précis en une phrase pour transmission à l'agent de code: 'TypeError: NoneType has no attribute split in router/providers/mistral_agents_api.py line 142. Le champ agent_id peut être None quand l'env var est absente.'" \
|
||||
"handoff-sentinelle" 2>/dev/null); then
|
||||
local diagnosis
|
||||
diagnosis=$(extract_content "${sentinelle_resp}")
|
||||
if [[ -n "${diagnosis}" && "${#diagnosis}" -gt 20 ]]; then
|
||||
log_pass "T6.1: Sentinelle diagnostic OK"
|
||||
|
||||
# Phase 2: Devstral reçoit le diagnostic et génère un fix
|
||||
log_info "T6: Phase 2 — Devstral génère fix basé sur diagnostic"
|
||||
local devstral_resp
|
||||
if devstral_resp=$(call_agent "${DEVSTRAL_ID}" \
|
||||
"Un agent de monitoring a détecté ce problème: ${diagnosis}. Génère un patch Python minimal pour corriger le bug (ajout d'une validation None-check avant l'appel .split())." \
|
||||
"handoff-devstral" 2>/dev/null); then
|
||||
local fix
|
||||
fix=$(extract_content "${devstral_resp}")
|
||||
if [[ -n "${fix}" && "${#fix}" -gt 30 ]]; then
|
||||
log_pass "T6.2: Devstral fix généré — Handoff complet ✓"
|
||||
else
|
||||
log_fail "T6.2: Devstral fix insuffisant"
|
||||
fi
|
||||
else
|
||||
log_fail "T6.2: Appel Devstral handoff échoué"
|
||||
fi
|
||||
else
|
||||
log_fail "T6.1: Sentinelle diagnostic vide"
|
||||
fi
|
||||
else
|
||||
log_fail "T6.1: Appel Sentinelle handoff échoué"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Rapport ───────────────────────────────────────────────────────────────
|
||||
emit_report() {
|
||||
local total=$((PASS + FAIL + SKIP))
|
||||
if [[ "${JSON_MODE}" -eq 1 ]]; then
|
||||
python3 -c "
|
||||
import json
|
||||
print(json.dumps({
|
||||
'contract': 'cockpit-v1',
|
||||
'tool': 'e2e_agents_test',
|
||||
'timestamp': '${STAMP}',
|
||||
'action': '${ACTION}',
|
||||
'results': {
|
||||
'total': ${total},
|
||||
'pass': ${PASS},
|
||||
'fail': ${FAIL},
|
||||
'skip': ${SKIP},
|
||||
'success_rate': round(${PASS} / max(${total}, 1) * 100, 1)
|
||||
},
|
||||
'agents_tested': {
|
||||
'sentinelle': '${SENTINELLE_ID}',
|
||||
'tower': '${TOWER_ID}',
|
||||
'forge': '${FORGE_ID}',
|
||||
'devstral': '${DEVSTRAL_ID}'
|
||||
},
|
||||
'api_mode': '${API_MODE}',
|
||||
'log_file': '${LOG_FILE}'
|
||||
}, indent=2))
|
||||
"
|
||||
else
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════"
|
||||
printf " E2E Agents Test Report — %s\n" "${STAMP}"
|
||||
echo "══════════════════════════════════════════════"
|
||||
printf " API Mode: %s\n" "${API_MODE}"
|
||||
printf " Total: %d | ${GREEN}Pass: %d${NC} | ${RED}Fail: %d${NC} | ${YELLOW}Skip: %d${NC}\n" \
|
||||
"${total}" "${PASS}" "${FAIL}" "${SKIP}"
|
||||
printf " Success rate: %.1f%%\n" "$(python3 -c "print(${PASS}/max(${total},1)*100)")"
|
||||
echo " Log: ${LOG_FILE}"
|
||||
echo "══════════════════════════════════════════════"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action) ACTION="${2:-}"; shift 2 ;;
|
||||
--api-mode) API_MODE="${2:-beta}"; shift 2 ;;
|
||||
--json) JSON_MODE=1; shift ;;
|
||||
--timeout) TIMEOUT="${2:-30}"; shift 2 ;;
|
||||
--verbose) VERBOSE=1; shift ;;
|
||||
--help) usage; exit 0 ;;
|
||||
*) printf 'Unknown: %s\n' "$1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "${ACTION}" ]]; then
|
||||
ACTION="all"
|
||||
fi
|
||||
|
||||
exec > >(tee -a "${LOG_FILE}") 2>&1
|
||||
printf '[e2e-agents-test] action=%s timestamp=%s\n' "${ACTION}" "${STAMP}"
|
||||
|
||||
if ! check_api_key; then
|
||||
emit_report
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "${ACTION}" in
|
||||
all)
|
||||
test_sentinelle
|
||||
test_tower
|
||||
test_forge
|
||||
test_devstral
|
||||
test_handoff
|
||||
;;
|
||||
sentinelle) test_sentinelle ;;
|
||||
tower) test_tower ;;
|
||||
forge) test_forge ;;
|
||||
devstral) test_devstral ;;
|
||||
handoff) test_handoff ;;
|
||||
*)
|
||||
printf 'Unknown action: %s\n' "${ACTION}" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
emit_report
|
||||
|
||||
# Exit code basé sur le résultat
|
||||
if [[ "${FAIL}" -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
Executable
+618
@@ -0,0 +1,618 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
ARTIFACTS_ROOT="${ROOT_DIR}/artifacts/cockpit/fab_package"
|
||||
SCHEMA_PATH="${ROOT_DIR}/specs/contracts/fab_package.schema.json"
|
||||
SCHOPS="${ROOT_DIR}/tools/hw/schops/schops.py"
|
||||
YIACAD="${ROOT_DIR}/tools/cad/yiacad_native_ops.py"
|
||||
|
||||
ACTION="summary"
|
||||
JSON_MODE=0
|
||||
BOARD_ID=""
|
||||
SOURCE_SCHEMATIC=""
|
||||
SOURCE_BOARD=""
|
||||
ROUTE_ORIGIN="local"
|
||||
MODE="dry"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: fab_package_tui.sh [--action summary|build|validate|artifacts] [--json]
|
||||
[--board-id ID] [--schematic PATH] [--board PATH]
|
||||
[--route-origin local|quilter|pcbdesigner] [--mode dry|live]
|
||||
EOF
|
||||
}
|
||||
|
||||
now_stamp() {
|
||||
date '+%Y%m%d_%H%M%S'
|
||||
}
|
||||
|
||||
ensure_dir() {
|
||||
mkdir -p "$1"
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
python3 - "$1" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
print(json.dumps(sys.argv[1]))
|
||||
PY
|
||||
}
|
||||
|
||||
resolve_kicad_cli() {
|
||||
if [[ -n "${KICAD_CLI:-}" && -x "${KICAD_CLI}" ]]; then
|
||||
printf '%s\n' "${KICAD_CLI}"
|
||||
return 0
|
||||
fi
|
||||
if [[ -x /Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli ]]; then
|
||||
printf '%s\n' /Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli
|
||||
return 0
|
||||
fi
|
||||
command -v kicad-cli 2>/dev/null || true
|
||||
}
|
||||
|
||||
derive_board_id() {
|
||||
if [[ -n "${BOARD_ID}" ]]; then
|
||||
printf '%s\n' "${BOARD_ID}"
|
||||
return
|
||||
fi
|
||||
if [[ -n "${SOURCE_BOARD}" ]]; then
|
||||
basename "${SOURCE_BOARD}" .kicad_pcb
|
||||
return
|
||||
fi
|
||||
if [[ -n "${SOURCE_SCHEMATIC}" ]]; then
|
||||
basename "${SOURCE_SCHEMATIC}" .kicad_sch
|
||||
return
|
||||
fi
|
||||
printf '%s\n' unknown-board
|
||||
}
|
||||
|
||||
abs_or_empty() {
|
||||
local path="$1"
|
||||
if [[ -n "${path}" && -e "${path}" ]]; then
|
||||
python3 - "${path}" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
print(Path(sys.argv[1]).resolve())
|
||||
PY
|
||||
else
|
||||
printf '%s' ""
|
||||
fi
|
||||
}
|
||||
|
||||
latest_json_path() {
|
||||
printf '%s\n' "${ARTIFACTS_ROOT}/latest.json"
|
||||
}
|
||||
|
||||
latest_md_path() {
|
||||
printf '%s\n' "${ARTIFACTS_ROOT}/latest.md"
|
||||
}
|
||||
|
||||
write_latest() {
|
||||
local json_path="$1"
|
||||
local md_path="$2"
|
||||
ensure_dir "${ARTIFACTS_ROOT}"
|
||||
cp "${json_path}" "$(latest_json_path)"
|
||||
cp "${md_path}" "$(latest_md_path)"
|
||||
}
|
||||
|
||||
emit_no_latest() {
|
||||
local status="degraded"
|
||||
local reason="no fab package artifact has been generated yet"
|
||||
if [[ "${JSON_MODE}" -eq 1 ]]; then
|
||||
cat <<EOF
|
||||
{
|
||||
"contract_version": "fab-package-v1",
|
||||
"generated_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"status": "${status}",
|
||||
"board_id": "",
|
||||
"source_schematic": null,
|
||||
"source_board": null,
|
||||
"route_origin": "local",
|
||||
"bom_file": null,
|
||||
"cpl_file": null,
|
||||
"gerber_dir": null,
|
||||
"drill_file": null,
|
||||
"drc_report": null,
|
||||
"review_artifacts": [],
|
||||
"provenance": {
|
||||
"producer": "fab_package_tui.sh",
|
||||
"tool": "cockpit",
|
||||
"mode": "dry",
|
||||
"route_origin": "local"
|
||||
},
|
||||
"acceptance_gates": {
|
||||
"erc_ok": false,
|
||||
"drc_ok": false,
|
||||
"bom_review_ok": false,
|
||||
"artifacts_complete": false
|
||||
},
|
||||
"degraded_reasons": [$(json_escape "${reason}")],
|
||||
"next_steps": [
|
||||
"run fab_package_tui.sh --action build with a valid KiCad schematic or board",
|
||||
"materialize Hypnoled assets in the current checkout before running T-HP lots"
|
||||
],
|
||||
"artifacts": []
|
||||
}
|
||||
EOF
|
||||
else
|
||||
printf 'Fab package status: %s\n' "${status}"
|
||||
printf -- '- reason: %s\n' "${reason}"
|
||||
fi
|
||||
}
|
||||
|
||||
run_capture() {
|
||||
local log_prefix="$1"
|
||||
shift
|
||||
set +e
|
||||
"$@" >"${log_prefix}.stdout.log" 2>"${log_prefix}.stderr.log"
|
||||
local rc=$?
|
||||
set -e
|
||||
return "${rc}"
|
||||
}
|
||||
|
||||
build_package() {
|
||||
local board_id run_stamp run_dir hw_root hw_latest kicad_cli
|
||||
local bom_file="" cpl_file="" gerber_dir="" drill_file="" drc_report="" netlist_file=""
|
||||
local yiacad_erc_drc="" yiacad_bom_review=""
|
||||
local status="blocked"
|
||||
local erc_ok=0 drc_ok=0 bom_review_ok=0 artifacts_complete=0
|
||||
local reasons=()
|
||||
local next_steps=()
|
||||
local artifacts=()
|
||||
|
||||
board_id="$(derive_board_id)"
|
||||
run_stamp="$(now_stamp)"
|
||||
run_dir="${ARTIFACTS_ROOT}/${run_stamp}-${board_id}"
|
||||
hw_root="${run_dir}/hw"
|
||||
|
||||
ensure_dir "${run_dir}"
|
||||
ensure_dir "${hw_root}"
|
||||
|
||||
if [[ -z "${SOURCE_SCHEMATIC}" && -z "${SOURCE_BOARD}" ]]; then
|
||||
reasons+=("missing-source-files")
|
||||
next_steps+=("provide --schematic and/or --board")
|
||||
fi
|
||||
|
||||
if [[ -n "${SOURCE_SCHEMATIC}" && ! -f "${SOURCE_SCHEMATIC}" ]]; then
|
||||
reasons+=("schematic-not-found")
|
||||
next_steps+=("provide an existing .kicad_sch file")
|
||||
SOURCE_SCHEMATIC=""
|
||||
fi
|
||||
|
||||
if [[ -n "${SOURCE_BOARD}" && ! -f "${SOURCE_BOARD}" ]]; then
|
||||
reasons+=("board-not-found")
|
||||
next_steps+=("provide an existing .kicad_pcb file")
|
||||
SOURCE_BOARD=""
|
||||
fi
|
||||
|
||||
if [[ -n "${SOURCE_SCHEMATIC}" ]]; then
|
||||
if run_capture "${run_dir}/erc" python3 "${SCHOPS}" --artifacts "${hw_root}" erc --schematic "${SOURCE_SCHEMATIC}"; then
|
||||
erc_ok=1
|
||||
else
|
||||
reasons+=("schops-erc-failed")
|
||||
fi
|
||||
|
||||
if ! run_capture "${run_dir}/netlist" python3 "${SCHOPS}" --artifacts "${hw_root}" netlist --schematic "${SOURCE_SCHEMATIC}"; then
|
||||
reasons+=("schops-netlist-failed")
|
||||
fi
|
||||
|
||||
if run_capture "${run_dir}/bom" python3 "${SCHOPS}" --artifacts "${hw_root}" bom --schematic "${SOURCE_SCHEMATIC}"; then
|
||||
:
|
||||
else
|
||||
reasons+=("schops-bom-failed")
|
||||
fi
|
||||
|
||||
hw_latest="$(find "${hw_root}" -mindepth 1 -maxdepth 1 -type d | sort | tail -n 1 || true)"
|
||||
if [[ -n "${hw_latest}" ]]; then
|
||||
bom_file="$(find "${hw_latest}" -type f \( -name '*.csv' -o -name '*bom*' \) | sort | head -n 1 || true)"
|
||||
netlist_file="$(find "${hw_latest}" -type f \( -name '*.net' -o -name '*.xml' -o -name '*netlist*' \) | sort | head -n 1 || true)"
|
||||
artifacts+=("${hw_latest}")
|
||||
fi
|
||||
|
||||
if run_capture "${run_dir}/yiacad_erc_drc" python3 "${YIACAD}" kicad-erc-drc --source-path "${SOURCE_SCHEMATIC}" --json-output; then
|
||||
drc_ok=1
|
||||
yiacad_erc_drc="${run_dir}/yiacad_erc_drc.stdout.log"
|
||||
else
|
||||
reasons+=("yiacad-erc-drc-failed")
|
||||
yiacad_erc_drc="${run_dir}/yiacad_erc_drc.stderr.log"
|
||||
fi
|
||||
|
||||
if run_capture "${run_dir}/yiacad_bom_review" python3 "${YIACAD}" bom-review --source-path "${SOURCE_SCHEMATIC}" --json-output; then
|
||||
bom_review_ok=1
|
||||
yiacad_bom_review="${run_dir}/yiacad_bom_review.stdout.log"
|
||||
else
|
||||
reasons+=("yiacad-bom-review-failed")
|
||||
yiacad_bom_review="${run_dir}/yiacad_bom_review.stderr.log"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "${SOURCE_BOARD}" ]]; then
|
||||
kicad_cli="$(resolve_kicad_cli)"
|
||||
if [[ -n "${kicad_cli}" ]]; then
|
||||
ensure_dir "${run_dir}/gerbers"
|
||||
ensure_dir "${run_dir}/drill"
|
||||
if run_capture "${run_dir}/gerber_export" "${kicad_cli}" pcb export gerbers -o "${run_dir}/gerbers" "${SOURCE_BOARD}"; then
|
||||
gerber_dir="${run_dir}/gerbers"
|
||||
else
|
||||
reasons+=("gerber-export-failed")
|
||||
fi
|
||||
if run_capture "${run_dir}/drill_export" "${kicad_cli}" pcb export drill -o "${run_dir}/drill" "${SOURCE_BOARD}"; then
|
||||
drill_file="$(find "${run_dir}/drill" -type f | sort | head -n 1 || true)"
|
||||
else
|
||||
reasons+=("drill-export-failed")
|
||||
fi
|
||||
else
|
||||
reasons+=("kicad-cli-unavailable")
|
||||
next_steps+=("install KiCad CLI or use a machine with host KiCad access")
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "${cpl_file}" ]]; then
|
||||
reasons+=("cpl-export-missing")
|
||||
next_steps+=("add a canonical local CPL export before assembly-ready handoff")
|
||||
fi
|
||||
|
||||
if [[ -n "${bom_file}" && -n "${gerber_dir}" && -n "${drill_file}" && -n "${cpl_file}" ]]; then
|
||||
artifacts_complete=1
|
||||
fi
|
||||
|
||||
if [[ ${erc_ok} -eq 1 && ${drc_ok} -eq 1 && ${bom_review_ok} -eq 1 && ${artifacts_complete} -eq 1 ]]; then
|
||||
status="ready"
|
||||
elif [[ -n "${SOURCE_SCHEMATIC}" || -n "${SOURCE_BOARD}" ]]; then
|
||||
status="degraded"
|
||||
fi
|
||||
|
||||
local json_path="${run_dir}/fab_package_${run_stamp}.json"
|
||||
local md_path="${run_dir}/fab_package_${run_stamp}.md"
|
||||
|
||||
python3 - "${json_path}" "${md_path}" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
json_path = pathlib.Path(sys.argv[1])
|
||||
md_path = pathlib.Path(sys.argv[2])
|
||||
|
||||
def env(name, default=""):
|
||||
return os.environ.get(name, default)
|
||||
|
||||
reasons = [item for item in env("FAB_REASONS", "").split("\n") if item]
|
||||
next_steps = [item for item in env("FAB_NEXT_STEPS", "").split("\n") if item]
|
||||
review_artifacts = [item for item in env("FAB_REVIEW_ARTIFACTS", "").split("\n") if item]
|
||||
artifacts = []
|
||||
for item in [x for x in env("FAB_ARTIFACTS", "").split("\n") if x]:
|
||||
artifacts.append({"path": item})
|
||||
|
||||
payload = {
|
||||
"contract_version": "fab-package-v1",
|
||||
"generated_at": env("FAB_GENERATED_AT"),
|
||||
"status": env("FAB_STATUS"),
|
||||
"board_id": env("FAB_BOARD_ID"),
|
||||
"source_schematic": env("FAB_SOURCE_SCHEMATIC") or None,
|
||||
"source_board": env("FAB_SOURCE_BOARD") or None,
|
||||
"route_origin": env("FAB_ROUTE_ORIGIN"),
|
||||
"bom_file": env("FAB_BOM_FILE") or None,
|
||||
"cpl_file": env("FAB_CPL_FILE") or None,
|
||||
"gerber_dir": env("FAB_GERBER_DIR") or None,
|
||||
"drill_file": env("FAB_DRILL_FILE") or None,
|
||||
"drc_report": env("FAB_DRC_REPORT") or None,
|
||||
"netlist_file": env("FAB_NETLIST_FILE") or None,
|
||||
"review_artifacts": review_artifacts,
|
||||
"provenance": {
|
||||
"producer": "tools/cockpit/fab_package_tui.sh",
|
||||
"tool": "fab-package-local-chain",
|
||||
"mode": env("FAB_MODE"),
|
||||
"route_origin": env("FAB_ROUTE_ORIGIN"),
|
||||
},
|
||||
"acceptance_gates": {
|
||||
"erc_ok": env("FAB_ERC_OK") == "1",
|
||||
"drc_ok": env("FAB_DRC_OK") == "1",
|
||||
"bom_review_ok": env("FAB_BOM_REVIEW_OK") == "1",
|
||||
"artifacts_complete": env("FAB_ARTIFACTS_COMPLETE") == "1",
|
||||
},
|
||||
"degraded_reasons": reasons,
|
||||
"next_steps": next_steps,
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
|
||||
json_path.write_text(json.dumps(payload, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Fab package",
|
||||
"",
|
||||
f"- status: {payload['status']}",
|
||||
f"- board_id: {payload['board_id']}",
|
||||
f"- route_origin: {payload['route_origin']}",
|
||||
f"- source_schematic: {payload['source_schematic'] or ''}",
|
||||
f"- source_board: {payload['source_board'] or ''}",
|
||||
f"- bom_file: {payload['bom_file'] or ''}",
|
||||
f"- cpl_file: {payload['cpl_file'] or ''}",
|
||||
f"- gerber_dir: {payload['gerber_dir'] or ''}",
|
||||
f"- drill_file: {payload['drill_file'] or ''}",
|
||||
f"- drc_report: {payload['drc_report'] or ''}",
|
||||
"",
|
||||
"## Acceptance gates",
|
||||
"",
|
||||
]
|
||||
for key, value in payload["acceptance_gates"].items():
|
||||
lines.append(f"- {key}: {str(value).lower()}")
|
||||
lines += ["", "## Reasons", ""]
|
||||
if reasons:
|
||||
lines.extend(f"- {item}" for item in reasons)
|
||||
else:
|
||||
lines.append("- none")
|
||||
lines += ["", "## Next steps", ""]
|
||||
if next_steps:
|
||||
lines.extend(f"- {item}" for item in next_steps)
|
||||
else:
|
||||
lines.append("- none")
|
||||
|
||||
md_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
PY
|
||||
|
||||
FAB_GENERATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
FAB_STATUS="${status}" \
|
||||
FAB_BOARD_ID="${board_id}" \
|
||||
FAB_SOURCE_SCHEMATIC="$(abs_or_empty "${SOURCE_SCHEMATIC}")" \
|
||||
FAB_SOURCE_BOARD="$(abs_or_empty "${SOURCE_BOARD}")" \
|
||||
FAB_ROUTE_ORIGIN="${ROUTE_ORIGIN}" \
|
||||
FAB_BOM_FILE="$(abs_or_empty "${bom_file}")" \
|
||||
FAB_CPL_FILE="$(abs_or_empty "${cpl_file}")" \
|
||||
FAB_GERBER_DIR="$(abs_or_empty "${gerber_dir}")" \
|
||||
FAB_DRILL_FILE="$(abs_or_empty "${drill_file}")" \
|
||||
FAB_DRC_REPORT="$(abs_or_empty "${yiacad_erc_drc}")" \
|
||||
FAB_NETLIST_FILE="$(abs_or_empty "${netlist_file}")" \
|
||||
FAB_MODE="${MODE}" \
|
||||
FAB_ERC_OK="${erc_ok}" \
|
||||
FAB_DRC_OK="${drc_ok}" \
|
||||
FAB_BOM_REVIEW_OK="${bom_review_ok}" \
|
||||
FAB_ARTIFACTS_COMPLETE="${artifacts_complete}" \
|
||||
FAB_REASONS="$(printf '%s\n' "${reasons[@]-}")" \
|
||||
FAB_NEXT_STEPS="$(printf '%s\n' "${next_steps[@]-}")" \
|
||||
FAB_REVIEW_ARTIFACTS="$(printf '%s\n' "${yiacad_erc_drc}" "${yiacad_bom_review}")" \
|
||||
FAB_ARTIFACTS="$(printf '%s\n' "${artifacts[@]-}")" \
|
||||
python3 - "${json_path}" "${md_path}" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
json_path = pathlib.Path(sys.argv[1])
|
||||
md_path = pathlib.Path(sys.argv[2])
|
||||
|
||||
def env(name, default=""):
|
||||
return os.environ.get(name, default)
|
||||
|
||||
reasons = [item for item in env("FAB_REASONS", "").split("\n") if item]
|
||||
next_steps = [item for item in env("FAB_NEXT_STEPS", "").split("\n") if item]
|
||||
review_artifacts = [item for item in env("FAB_REVIEW_ARTIFACTS", "").split("\n") if item]
|
||||
artifacts = []
|
||||
for item in [x for x in env("FAB_ARTIFACTS", "").split("\n") if x]:
|
||||
artifacts.append({"path": item})
|
||||
|
||||
payload = {
|
||||
"contract_version": "fab-package-v1",
|
||||
"generated_at": env("FAB_GENERATED_AT"),
|
||||
"status": env("FAB_STATUS"),
|
||||
"board_id": env("FAB_BOARD_ID"),
|
||||
"source_schematic": env("FAB_SOURCE_SCHEMATIC") or None,
|
||||
"source_board": env("FAB_SOURCE_BOARD") or None,
|
||||
"route_origin": env("FAB_ROUTE_ORIGIN"),
|
||||
"bom_file": env("FAB_BOM_FILE") or None,
|
||||
"cpl_file": env("FAB_CPL_FILE") or None,
|
||||
"gerber_dir": env("FAB_GERBER_DIR") or None,
|
||||
"drill_file": env("FAB_DRILL_FILE") or None,
|
||||
"drc_report": env("FAB_DRC_REPORT") or None,
|
||||
"netlist_file": env("FAB_NETLIST_FILE") or None,
|
||||
"review_artifacts": review_artifacts,
|
||||
"provenance": {
|
||||
"producer": "tools/cockpit/fab_package_tui.sh",
|
||||
"tool": "fab-package-local-chain",
|
||||
"mode": env("FAB_MODE"),
|
||||
"route_origin": env("FAB_ROUTE_ORIGIN"),
|
||||
},
|
||||
"acceptance_gates": {
|
||||
"erc_ok": env("FAB_ERC_OK") == "1",
|
||||
"drc_ok": env("FAB_DRC_OK") == "1",
|
||||
"bom_review_ok": env("FAB_BOM_REVIEW_OK") == "1",
|
||||
"artifacts_complete": env("FAB_ARTIFACTS_COMPLETE") == "1",
|
||||
},
|
||||
"degraded_reasons": reasons,
|
||||
"next_steps": next_steps,
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
|
||||
json_path.write_text(json.dumps(payload, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Fab package",
|
||||
"",
|
||||
f"- status: {payload['status']}",
|
||||
f"- board_id: {payload['board_id']}",
|
||||
f"- route_origin: {payload['route_origin']}",
|
||||
f"- source_schematic: {payload['source_schematic'] or ''}",
|
||||
f"- source_board: {payload['source_board'] or ''}",
|
||||
f"- bom_file: {payload['bom_file'] or ''}",
|
||||
f"- cpl_file: {payload['cpl_file'] or ''}",
|
||||
f"- gerber_dir: {payload['gerber_dir'] or ''}",
|
||||
f"- drill_file: {payload['drill_file'] or ''}",
|
||||
f"- drc_report: {payload['drc_report'] or ''}",
|
||||
"",
|
||||
"## Acceptance gates",
|
||||
"",
|
||||
]
|
||||
for key, value in payload["acceptance_gates"].items():
|
||||
lines.append(f"- {key}: {str(value).lower()}")
|
||||
lines += ["", "## Reasons", ""]
|
||||
if reasons:
|
||||
lines.extend(f"- {item}" for item in reasons)
|
||||
else:
|
||||
lines.append("- none")
|
||||
lines += ["", "## Next steps", ""]
|
||||
if next_steps:
|
||||
lines.extend(f"- {item}" for item in next_steps)
|
||||
else:
|
||||
lines.append("- none")
|
||||
|
||||
md_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
PY
|
||||
|
||||
write_latest "${json_path}" "${md_path}"
|
||||
|
||||
if [[ "${JSON_MODE}" -eq 1 ]]; then
|
||||
cat "${json_path}"
|
||||
else
|
||||
cat "${md_path}"
|
||||
fi
|
||||
}
|
||||
|
||||
validate_package() {
|
||||
local latest
|
||||
latest="$(latest_json_path)"
|
||||
if [[ ! -f "${latest}" ]]; then
|
||||
emit_no_latest
|
||||
return 1
|
||||
fi
|
||||
|
||||
python3 - "${latest}" "${SCHEMA_PATH}" "${JSON_MODE}" <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
latest = pathlib.Path(sys.argv[1])
|
||||
schema = pathlib.Path(sys.argv[2])
|
||||
as_json = sys.argv[3] == "1"
|
||||
|
||||
payload = json.loads(latest.read_text(encoding="utf-8"))
|
||||
schema_payload = json.loads(schema.read_text(encoding="utf-8"))
|
||||
required = schema_payload.get("required", [])
|
||||
missing = [field for field in required if field not in payload]
|
||||
|
||||
paths_to_check = {
|
||||
"bom_file": payload.get("bom_file"),
|
||||
"cpl_file": payload.get("cpl_file"),
|
||||
"gerber_dir": payload.get("gerber_dir"),
|
||||
"drill_file": payload.get("drill_file"),
|
||||
"drc_report": payload.get("drc_report"),
|
||||
}
|
||||
missing_paths = []
|
||||
for key, value in paths_to_check.items():
|
||||
if value and not pathlib.Path(value).exists():
|
||||
missing_paths.append(key)
|
||||
|
||||
result = {
|
||||
"status": "ok" if not missing and not missing_paths else "degraded",
|
||||
"latest_json": str(latest),
|
||||
"missing_fields": missing,
|
||||
"missing_paths": missing_paths,
|
||||
"acceptance_gates": payload.get("acceptance_gates", {}),
|
||||
}
|
||||
|
||||
if as_json:
|
||||
print(json.dumps(result, indent=2, ensure_ascii=True))
|
||||
else:
|
||||
print("Fab package validation")
|
||||
print(f"- status: {result['status']}")
|
||||
print("- missing_fields: " + (", ".join(result["missing_fields"]) or "none"))
|
||||
print("- missing_paths: " + (", ".join(result["missing_paths"]) or "none"))
|
||||
PY
|
||||
}
|
||||
|
||||
summary_package() {
|
||||
local latest
|
||||
latest="$(latest_json_path)"
|
||||
if [[ ! -f "${latest}" ]]; then
|
||||
emit_no_latest
|
||||
return 0
|
||||
fi
|
||||
if [[ "${JSON_MODE}" -eq 1 ]]; then
|
||||
cat "${latest}"
|
||||
else
|
||||
cat "$(latest_md_path)"
|
||||
fi
|
||||
}
|
||||
|
||||
artifacts_package() {
|
||||
local latest latest_md
|
||||
latest="$(latest_json_path)"
|
||||
latest_md="$(latest_md_path)"
|
||||
if [[ "${JSON_MODE}" -eq 1 ]]; then
|
||||
cat <<EOF
|
||||
{
|
||||
"status": "ok",
|
||||
"schema": $(json_escape "${SCHEMA_PATH}"),
|
||||
"latest_json": $(json_escape "${latest}"),
|
||||
"latest_markdown": $(json_escape "${latest_md}")
|
||||
}
|
||||
EOF
|
||||
else
|
||||
printf 'Fab package artifacts\n'
|
||||
printf -- '- schema: %s\n' "${SCHEMA_PATH}"
|
||||
printf -- '- latest_json: %s\n' "${latest}"
|
||||
printf -- '- latest_markdown: %s\n' "${latest_md}"
|
||||
fi
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
JSON_MODE=1
|
||||
shift
|
||||
;;
|
||||
--board-id)
|
||||
BOARD_ID="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--schematic)
|
||||
SOURCE_SCHEMATIC="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--board)
|
||||
SOURCE_BOARD="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--route-origin)
|
||||
ROUTE_ORIGIN="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--mode)
|
||||
MODE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "${ACTION}" in
|
||||
summary)
|
||||
summary_package
|
||||
;;
|
||||
build)
|
||||
build_package
|
||||
;;
|
||||
validate)
|
||||
validate_package
|
||||
;;
|
||||
artifacts)
|
||||
artifacts_package
|
||||
;;
|
||||
*)
|
||||
echo "Unknown action: ${ACTION}" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+853
@@ -0,0 +1,853 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
|
||||
source "${PROJECT_DIR}/tools/cockpit/json_contract.sh"
|
||||
LOG_DIR="${PROJECT_DIR}/artifacts/cockpit"
|
||||
STATE_DIR="${PROJECT_DIR}/artifacts/operator_lane"
|
||||
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
|
||||
LOG_FILE="${LOG_DIR}/full_operator_lane_${TIMESTAMP}.log"
|
||||
SUMMARY_FILE="${STATE_DIR}/full_operator_lane_${TIMESTAMP}.json"
|
||||
MASCARADE_HEALTH_FILE="${STATE_DIR}/full_operator_lane_mascarade_health_${TIMESTAMP}.json"
|
||||
MASCARADE_LOGS_FILE="${STATE_DIR}/full_operator_lane_mascarade_logs_${TIMESTAMP}.json"
|
||||
MASCARADE_QUEUE_FILE="${STATE_DIR}/full_operator_lane_mascarade_queue_${TIMESTAMP}.json"
|
||||
MASCARADE_WATCH_FILE="${STATE_DIR}/full_operator_lane_mascarade_watch_${TIMESTAMP}.json"
|
||||
DAILY_OPERATOR_SUMMARY_FILE="${STATE_DIR}/full_operator_lane_daily_operator_summary_${TIMESTAMP}.json"
|
||||
ROUTING_FILE="${STATE_DIR}/full_operator_lane_routing_${TIMESTAMP}.json"
|
||||
KILL_LIFE_MEMORY_FILE="${STATE_DIR}/full_operator_lane_kill_life_memory_${TIMESTAMP}.json"
|
||||
API_BASE="${CRAZY_LIFE_API_BASE:-http://localhost:3100/api/killlife}"
|
||||
WORKFLOW_ID="${FULL_OPERATOR_LANE_WORKFLOW_ID:-embedded-operator-live}"
|
||||
WAIT_SECONDS="${FULL_OPERATOR_LANE_POLL_SECONDS:-2}"
|
||||
WAIT_ATTEMPTS="${FULL_OPERATOR_LANE_POLL_ATTEMPTS:-20}"
|
||||
JSON_OUTPUT=0
|
||||
COMMAND="status"
|
||||
LOGS_ACTION="summary"
|
||||
MASCARADE_HEALTH_STATUS="unknown"
|
||||
MASCARADE_PROVIDER="unknown"
|
||||
MASCARADE_MODEL="unknown"
|
||||
MASCARADE_LOGS_STATUS="unknown"
|
||||
MASCARADE_LOGS_STALE="0"
|
||||
MASCARADE_LOGS_PURGED="0"
|
||||
MASCARADE_QUEUE_STATUS="unknown"
|
||||
MASCARADE_QUEUE_MARKDOWN=""
|
||||
MASCARADE_WATCH_STATUS="unknown"
|
||||
MASCARADE_WATCH_MARKDOWN=""
|
||||
DAILY_OPERATOR_SUMMARY_STATUS="unknown"
|
||||
DAILY_OPERATOR_SUMMARY_MARKDOWN=""
|
||||
ROUTING_STATUS="unknown"
|
||||
KILL_LIFE_MEMORY_STATUS="unknown"
|
||||
COMMAND_EXIT_CODE=0
|
||||
|
||||
mkdir -p "${LOG_DIR}" "${STATE_DIR}"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: bash tools/cockpit/full_operator_lane.sh <status|dry-run|live|all|logs|purge> [--json] [--logs-action <summary|latest|list|purge>]
|
||||
|
||||
Commands:
|
||||
status Fetch workflow detail and latest runs.
|
||||
dry-run Validate then trigger a dry-run on the operator lane.
|
||||
live Validate then trigger the live-provider execution path.
|
||||
all Run dry-run then live in sequence.
|
||||
logs Read or purge Mascarade/Ollama logs through the operator lane.
|
||||
purge Remove generated operator-lane logs and summaries.
|
||||
|
||||
Options:
|
||||
--json Print the generated summary JSON to stdout.
|
||||
--logs-action <name> summary|latest|list|purge for the `logs` command (default: summary).
|
||||
USAGE
|
||||
}
|
||||
|
||||
log_line() {
|
||||
local level="$1"
|
||||
shift
|
||||
mkdir -p "${LOG_DIR}" "${STATE_DIR}"
|
||||
local msg="${level} $(date '+%Y-%m-%d %H:%M:%S %z') $*"
|
||||
printf '%s\n' "${msg}" | tee -a "${LOG_FILE}" >/dev/null
|
||||
}
|
||||
|
||||
curl_json() {
|
||||
local method="$1"
|
||||
local url="$2"
|
||||
local body="$3"
|
||||
local output_file="$4"
|
||||
local http_file
|
||||
local http_code
|
||||
http_file="$(mktemp)"
|
||||
local -a args=(curl -sS -X "${method}" -H 'Content-Type: application/json' -o "${output_file}" -w '%{http_code}')
|
||||
local gateway_api_key="${CRAZY_LIFE_API_KEY:-${MASCARADE_API_KEY:-${KILL_LIFE_API_KEY:-}}}"
|
||||
if [[ -n "${gateway_api_key}" ]]; then
|
||||
args+=(-H "Authorization: Bearer ${gateway_api_key}")
|
||||
fi
|
||||
if [[ -n "${body}" ]]; then
|
||||
args+=(--data "${body}")
|
||||
fi
|
||||
args+=("${url}")
|
||||
if ! "${args[@]}" > "${http_file}" 2>>"${LOG_FILE}"; then
|
||||
rm -f "${http_file}"
|
||||
return 1
|
||||
fi
|
||||
http_code="$(tr -d '\n' < "${http_file}")"
|
||||
rm -f "${http_file}"
|
||||
if [[ ! "${http_code}" =~ ^2[0-9][0-9]$ ]]; then
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
write_failure_summary() {
|
||||
local output_file="$1"
|
||||
local error_code="$2"
|
||||
local url="$3"
|
||||
local hint=""
|
||||
local suggested_command=""
|
||||
case "${error_code}" in
|
||||
status-api-unreachable)
|
||||
hint="API locale indisponible pour lire le workflow operateur."
|
||||
suggested_command="export CRAZY_LIFE_API_BASE=http://<host>:3100/api/killlife"
|
||||
;;
|
||||
validate-api-unreachable)
|
||||
hint="Impossible de valider le workflow tant que l'API locale n'est pas joignable."
|
||||
suggested_command="bash tools/cockpit/full_operator_lane.sh status --json"
|
||||
;;
|
||||
run-api-unreachable)
|
||||
hint="Le declenchement dry-run/live ne peut pas partir sans API locale joignable."
|
||||
suggested_command="bash tools/cockpit/full_operator_lane.sh status --json"
|
||||
;;
|
||||
poll-api-unreachable)
|
||||
hint="Le workflow a peut-etre demarre mais le polling ne peut plus joindre l'API."
|
||||
suggested_command="bash tools/cockpit/full_operator_lane.sh status --json"
|
||||
;;
|
||||
esac
|
||||
printf '{"status":"failed","error":"%s","url":"%s","command":"%s","workflow_id":"%s","hint":"%s","suggested_command":"%s"}\n' \
|
||||
"${error_code}" "${url}" "${COMMAND}" "${WORKFLOW_ID}" "${hint}" "${suggested_command}" > "${output_file}"
|
||||
}
|
||||
|
||||
json_get() {
|
||||
local file="$1"
|
||||
local path_expr="$2"
|
||||
python3 - "$file" "$path_expr" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
file_path = Path(sys.argv[1])
|
||||
path = sys.argv[2].split('.') if sys.argv[2] else []
|
||||
text = file_path.read_text(encoding='utf-8')
|
||||
try:
|
||||
value = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
decoder = json.JSONDecoder()
|
||||
idx = 0
|
||||
last_value = None
|
||||
while idx < len(text):
|
||||
while idx < len(text) and text[idx].isspace():
|
||||
idx += 1
|
||||
if idx >= len(text):
|
||||
break
|
||||
try:
|
||||
candidate, end = decoder.raw_decode(text, idx)
|
||||
except json.JSONDecodeError:
|
||||
break
|
||||
last_value = candidate
|
||||
idx = end
|
||||
if last_value is None:
|
||||
print("")
|
||||
raise SystemExit(0)
|
||||
value = last_value
|
||||
for key in path:
|
||||
if isinstance(value, dict):
|
||||
value = value.get(key)
|
||||
else:
|
||||
value = None
|
||||
break
|
||||
if value is None:
|
||||
print("")
|
||||
elif isinstance(value, (dict, list)):
|
||||
print(json.dumps(value, ensure_ascii=True))
|
||||
else:
|
||||
print(value)
|
||||
PY
|
||||
}
|
||||
|
||||
emit_contract_json() {
|
||||
python3 - "${SUMMARY_FILE}" "${COMMAND}" "${LOGS_ACTION}" "${LOG_FILE}" "${WORKFLOW_ID}" "${API_BASE}" "${MASCARADE_HEALTH_FILE}" "${MASCARADE_HEALTH_STATUS}" "${MASCARADE_PROVIDER}" "${MASCARADE_MODEL}" "${MASCARADE_LOGS_FILE}" "${MASCARADE_LOGS_STATUS}" "${MASCARADE_LOGS_STALE}" "${MASCARADE_LOGS_PURGED}" "${MASCARADE_QUEUE_FILE}" "${MASCARADE_QUEUE_STATUS}" "${MASCARADE_QUEUE_MARKDOWN}" "${MASCARADE_WATCH_FILE}" "${MASCARADE_WATCH_STATUS}" "${MASCARADE_WATCH_MARKDOWN}" "${DAILY_OPERATOR_SUMMARY_FILE}" "${DAILY_OPERATOR_SUMMARY_STATUS}" "${DAILY_OPERATOR_SUMMARY_MARKDOWN}" "${ROUTING_FILE}" "${ROUTING_STATUS}" "${KILL_LIFE_MEMORY_FILE}" "${KILL_LIFE_MEMORY_STATUS}" "${PRODUCT_CONTRACT_HANDOFF_FILE:-}" "${PRODUCT_CONTRACT_HANDOFF_STATUS:-unknown}" "${PRODUCT_CONTRACT_HANDOFF_MARKDOWN:-}" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
summary_path = Path(sys.argv[1])
|
||||
command = sys.argv[2]
|
||||
logs_action = sys.argv[3]
|
||||
log_file = sys.argv[4]
|
||||
workflow_id = sys.argv[5]
|
||||
api_base = sys.argv[6]
|
||||
health_path = Path(sys.argv[7])
|
||||
health_status = sys.argv[8]
|
||||
health_provider = sys.argv[9]
|
||||
health_model = sys.argv[10]
|
||||
logs_path = Path(sys.argv[11])
|
||||
logs_status = sys.argv[12]
|
||||
logs_stale = sys.argv[13]
|
||||
logs_purged = sys.argv[14]
|
||||
queue_path = Path(sys.argv[15])
|
||||
queue_status = sys.argv[16]
|
||||
queue_markdown = sys.argv[17]
|
||||
watch_path = Path(sys.argv[18])
|
||||
watch_status = sys.argv[19]
|
||||
watch_markdown = sys.argv[20]
|
||||
daily_summary_path = Path(sys.argv[21])
|
||||
daily_summary_status = sys.argv[22]
|
||||
daily_summary_markdown = sys.argv[23]
|
||||
routing_path = Path(sys.argv[24])
|
||||
routing_status = sys.argv[25]
|
||||
memory_path = Path(sys.argv[26])
|
||||
memory_status = sys.argv[27]
|
||||
handoff_path = Path(sys.argv[28]) if sys.argv[28] else Path("")
|
||||
handoff_status = sys.argv[29]
|
||||
handoff_markdown = sys.argv[30]
|
||||
|
||||
summary = {}
|
||||
if summary_path.exists():
|
||||
raw = summary_path.read_text(encoding="utf-8")
|
||||
try:
|
||||
summary = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
summary = {"raw_summary": raw}
|
||||
|
||||
raw_status = ""
|
||||
error_code = ""
|
||||
error_url = ""
|
||||
hint = ""
|
||||
suggested_command = ""
|
||||
if isinstance(summary, dict):
|
||||
raw_status = str(summary.get("status", ""))
|
||||
error_code = str(summary.get("error", ""))
|
||||
error_url = str(summary.get("url", ""))
|
||||
hint = str(summary.get("hint", ""))
|
||||
suggested_command = str(summary.get("suggested_command", ""))
|
||||
|
||||
def map_status(value: str) -> str:
|
||||
value = (value or "").strip().lower()
|
||||
if value in {"done", "ready", "ok", "success"}:
|
||||
return "ok"
|
||||
if value in {"failed", "error", "cancelled", "cancel_unresolved"}:
|
||||
return "error"
|
||||
if value in {"", "unknown"}:
|
||||
return "degraded"
|
||||
return "degraded"
|
||||
|
||||
contract_status = map_status(raw_status)
|
||||
reasons = []
|
||||
next_steps = []
|
||||
if contract_status != "ok":
|
||||
reasons.append(error_code or f"operator-lane-{raw_status or command}")
|
||||
if error_url:
|
||||
next_steps.append(f"Restore or reroute the local API endpoint {error_url}")
|
||||
if hint:
|
||||
next_steps.append(hint)
|
||||
if suggested_command:
|
||||
next_steps.append(suggested_command)
|
||||
next_steps.append(f"Inspect {log_file} and {summary_path}")
|
||||
|
||||
health_summary = {}
|
||||
if health_path.exists():
|
||||
raw = health_path.read_text(encoding="utf-8")
|
||||
try:
|
||||
health_summary = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
health_summary = {"raw_health_summary": raw}
|
||||
|
||||
if (health_status or "").strip().lower() not in {"ok", "ready", "done", "success", "skipped", "n/a"}:
|
||||
if contract_status == "ok":
|
||||
contract_status = "degraded"
|
||||
reasons.append(f"mascarade-runtime-{health_status or 'unknown'}")
|
||||
next_steps.append(f"Inspect {health_path}")
|
||||
|
||||
logs_summary = {}
|
||||
if logs_path.exists():
|
||||
raw = logs_path.read_text(encoding="utf-8")
|
||||
try:
|
||||
logs_summary = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logs_summary = {"raw_logs_summary": raw}
|
||||
|
||||
if (logs_status or "").strip().lower() not in {"ok", "ready", "done", "success", "skipped", "n/a"}:
|
||||
if contract_status == "ok":
|
||||
contract_status = "degraded"
|
||||
reasons.append(f"mascarade-logs-{logs_status or 'unknown'}")
|
||||
next_steps.append(f"Inspect {logs_path}")
|
||||
|
||||
queue_summary = {}
|
||||
if queue_path.exists():
|
||||
raw = queue_path.read_text(encoding="utf-8")
|
||||
try:
|
||||
queue_summary = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
queue_summary = {"raw_queue_summary": raw}
|
||||
|
||||
if (queue_status or "").strip().lower() not in {"ok", "ready", "done", "success", "skipped", "n/a"}:
|
||||
if contract_status == "ok":
|
||||
contract_status = "degraded"
|
||||
reasons.append(f"mascarade-queue-{queue_status or 'unknown'}")
|
||||
next_steps.append(f"Inspect {queue_path}")
|
||||
|
||||
watch_summary = {}
|
||||
if watch_path.exists():
|
||||
raw = watch_path.read_text(encoding="utf-8")
|
||||
try:
|
||||
watch_summary = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
watch_summary = {"raw_watch_summary": raw}
|
||||
|
||||
if (watch_status or "").strip().lower() not in {"ok", "ready", "done", "success", "skipped", "n/a"}:
|
||||
if contract_status == "ok":
|
||||
contract_status = "degraded"
|
||||
reasons.append(f"mascarade-watch-{watch_status or 'unknown'}")
|
||||
next_steps.append(f"Inspect {watch_path}")
|
||||
|
||||
daily_summary = {}
|
||||
if daily_summary_path.exists():
|
||||
raw = daily_summary_path.read_text(encoding="utf-8")
|
||||
try:
|
||||
daily_summary = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
daily_summary = {"raw_daily_summary": raw}
|
||||
|
||||
if (daily_summary_status or "").strip().lower() not in {"ok", "ready", "done", "success", "skipped", "n/a"}:
|
||||
if contract_status == "ok":
|
||||
contract_status = "degraded"
|
||||
reasons.append(f"daily-operator-summary-{daily_summary_status or 'unknown'}")
|
||||
next_steps.append(f"Inspect {daily_summary_path}")
|
||||
|
||||
routing_summary = {}
|
||||
if routing_path.exists():
|
||||
raw = routing_path.read_text(encoding="utf-8")
|
||||
try:
|
||||
routing_summary = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
routing_summary = {"raw_routing_summary": raw}
|
||||
|
||||
if (routing_status or "").strip().lower() not in {"ok", "ready", "done", "success", "skipped", "n/a"}:
|
||||
if contract_status == "ok":
|
||||
contract_status = "degraded"
|
||||
reasons.append(f"mascarade-routing-{routing_status or 'unknown'}")
|
||||
next_steps.append(f"Inspect {routing_path}")
|
||||
|
||||
memory_summary = {}
|
||||
if memory_path.exists():
|
||||
raw = memory_path.read_text(encoding="utf-8")
|
||||
try:
|
||||
memory_summary = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
memory_summary = {"raw_memory_summary": raw}
|
||||
|
||||
if (memory_status or "").strip().lower() not in {"ok", "ready", "done", "success", "skipped", "n/a"}:
|
||||
if contract_status == "ok":
|
||||
contract_status = "degraded"
|
||||
reasons.append(f"kill-life-memory-{memory_status or 'unknown'}")
|
||||
next_steps.append(f"Inspect {memory_path}")
|
||||
|
||||
handoff_summary = {}
|
||||
if handoff_path and handoff_path.exists():
|
||||
raw = handoff_path.read_text(encoding="utf-8")
|
||||
try:
|
||||
handoff_summary = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
handoff_summary = {"raw_handoff_summary": raw}
|
||||
|
||||
if (handoff_status or "").strip().lower() not in {"ok", "ready", "done", "success", "skipped", "n/a"}:
|
||||
if contract_status == "ok":
|
||||
contract_status = "degraded"
|
||||
reasons.append(f"product-contract-handoff-{handoff_status or 'unknown'}")
|
||||
next_steps.append(f"Inspect {handoff_path}")
|
||||
|
||||
priority_counts = daily_summary.get("priority_counts", {}) if isinstance(daily_summary.get("priority_counts"), dict) else {}
|
||||
severity_counts = daily_summary.get("severity_counts", {}) if isinstance(daily_summary.get("severity_counts"), dict) else {}
|
||||
memory_entry = memory_summary.get("entry", {}) if isinstance(memory_summary, dict) else {}
|
||||
if not isinstance(memory_entry, dict):
|
||||
memory_entry = {}
|
||||
decision = memory_entry.get("decision") if isinstance(memory_entry.get("decision"), dict) else {
|
||||
"action": f"operator-lane-{command}",
|
||||
"reason": "Operator lane projected into kill_life continuity memory.",
|
||||
}
|
||||
owner = memory_entry.get("owner", "SyncOps")
|
||||
resume_ref = memory_entry.get("resume_ref", f"kill-life:full-operator-lane:{workflow_id}:{command}")
|
||||
trust_level = memory_entry.get("trust_level", "inferred")
|
||||
|
||||
payload = {
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "full_operator_lane",
|
||||
"action": command,
|
||||
"logs_action": logs_action,
|
||||
"status": contract_status,
|
||||
"contract_status": contract_status,
|
||||
"workflow_id": workflow_id,
|
||||
"api_base": api_base,
|
||||
"log_file": log_file,
|
||||
"summary_file": str(summary_path),
|
||||
"owner": owner,
|
||||
"decision": decision,
|
||||
"resume_ref": resume_ref,
|
||||
"trust_level": trust_level,
|
||||
"mascarade_health_status": health_status or "unknown",
|
||||
"mascarade_provider": health_provider or "unknown",
|
||||
"mascarade_model": health_model or "unknown",
|
||||
"mascarade_health_file": str(health_path),
|
||||
"mascarade_logs_status": logs_status or "unknown",
|
||||
"mascarade_logs_stale": logs_stale or "0",
|
||||
"mascarade_logs_purged": logs_purged or "0",
|
||||
"mascarade_logs_file": str(logs_path),
|
||||
"mascarade_queue_status": queue_status or "unknown",
|
||||
"mascarade_queue_file": str(queue_path),
|
||||
"mascarade_queue_markdown": queue_markdown or "",
|
||||
"mascarade_watch_status": watch_status or "unknown",
|
||||
"mascarade_watch_file": str(watch_path),
|
||||
"mascarade_watch_markdown": watch_markdown or "",
|
||||
"routing_status": routing_status or "unknown",
|
||||
"routing_file": str(routing_path),
|
||||
"routing": routing_summary,
|
||||
"memory_entry_status": memory_status or "unknown",
|
||||
"memory_entry_file": str(memory_path),
|
||||
"memory_entry": memory_entry,
|
||||
"priority_counts": priority_counts,
|
||||
"severity_counts": severity_counts,
|
||||
"daily_operator_summary_status": daily_summary_status or "unknown",
|
||||
"daily_operator_summary_file": str(daily_summary_path),
|
||||
"daily_operator_summary_markdown": daily_summary_markdown or "",
|
||||
"product_contract_handoff_status": handoff_status or "unknown",
|
||||
"product_contract_handoff_artifact": str(handoff_path) if handoff_path else "",
|
||||
"product_contract_handoff_markdown": handoff_markdown or "",
|
||||
"artifacts": [log_file, str(summary_path), str(health_path), str(logs_path), str(queue_path), str(watch_path), str(daily_summary_path), str(routing_path), str(memory_path)] + ([str(handoff_path)] if handoff_path else []),
|
||||
"degraded_reasons": reasons,
|
||||
"next_steps": next_steps,
|
||||
"summary": summary,
|
||||
"mascarade_health_summary": health_summary,
|
||||
"mascarade_logs_summary": logs_summary,
|
||||
"mascarade_queue_summary": queue_summary,
|
||||
"mascarade_watch_summary": watch_summary,
|
||||
"daily_operator_summary": daily_summary,
|
||||
"product_contract_handoff": handoff_summary,
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=False))
|
||||
PY
|
||||
}
|
||||
|
||||
refresh_mascarade_health() {
|
||||
if bash "${PROJECT_DIR}/tools/cockpit/mascarade_runtime_health.sh" --json > "${MASCARADE_HEALTH_FILE}" 2>>"${LOG_FILE}"; then
|
||||
MASCARADE_HEALTH_STATUS="$(json_get "${MASCARADE_HEALTH_FILE}" 'status')"
|
||||
MASCARADE_PROVIDER="$(json_get "${MASCARADE_HEALTH_FILE}" 'provider')"
|
||||
MASCARADE_MODEL="$(json_get "${MASCARADE_HEALTH_FILE}" 'model')"
|
||||
log_line INFO "mascarade health status=${MASCARADE_HEALTH_STATUS:-unknown} provider=${MASCARADE_PROVIDER:-unknown} model=${MASCARADE_MODEL:-unknown}"
|
||||
else
|
||||
MASCARADE_HEALTH_STATUS="failed"
|
||||
MASCARADE_PROVIDER="unknown"
|
||||
MASCARADE_MODEL="unknown"
|
||||
log_line ERROR "mascarade health-check failed"
|
||||
fi
|
||||
|
||||
[[ -n "${MASCARADE_HEALTH_STATUS}" ]] || MASCARADE_HEALTH_STATUS="unknown"
|
||||
[[ -n "${MASCARADE_PROVIDER}" ]] || MASCARADE_PROVIDER="unknown"
|
||||
[[ -n "${MASCARADE_MODEL}" ]] || MASCARADE_MODEL="unknown"
|
||||
}
|
||||
|
||||
refresh_mascarade_logs() {
|
||||
local mode="${1:-summary}"
|
||||
local -a args=(bash "${PROJECT_DIR}/tools/cockpit/mascarade_logs_tui.sh" --action "${mode}" --json)
|
||||
if [[ "${mode}" == "purge" ]]; then
|
||||
args+=(--apply)
|
||||
fi
|
||||
|
||||
if "${args[@]}" > "${MASCARADE_LOGS_FILE}" 2>>"${LOG_FILE}"; then
|
||||
MASCARADE_LOGS_STATUS="$(json_get "${MASCARADE_LOGS_FILE}" 'status')"
|
||||
MASCARADE_LOGS_STALE="$(json_get "${MASCARADE_LOGS_FILE}" 'stale_candidate_count')"
|
||||
MASCARADE_LOGS_PURGED="$(json_get "${MASCARADE_LOGS_FILE}" 'purged_count')"
|
||||
[[ -n "${MASCARADE_LOGS_STALE}" ]] || MASCARADE_LOGS_STALE="0"
|
||||
[[ -n "${MASCARADE_LOGS_PURGED}" ]] || MASCARADE_LOGS_PURGED="0"
|
||||
log_line INFO "mascarade logs mode=${mode} status=${MASCARADE_LOGS_STATUS:-unknown} stale=${MASCARADE_LOGS_STALE:-0} purged=${MASCARADE_LOGS_PURGED:-0}"
|
||||
else
|
||||
MASCARADE_LOGS_STATUS="failed"
|
||||
MASCARADE_LOGS_STALE="0"
|
||||
MASCARADE_LOGS_PURGED="0"
|
||||
log_line ERROR "mascarade logs action failed mode=${mode}"
|
||||
fi
|
||||
|
||||
[[ -n "${MASCARADE_LOGS_STATUS}" ]] || MASCARADE_LOGS_STATUS="unknown"
|
||||
}
|
||||
|
||||
refresh_mascarade_queue() {
|
||||
if bash "${PROJECT_DIR}/tools/cockpit/render_mascarade_incident_queue.sh" --json > "${MASCARADE_QUEUE_FILE}" 2>>"${LOG_FILE}"; then
|
||||
MASCARADE_QUEUE_STATUS="$(json_get "${MASCARADE_QUEUE_FILE}" 'status')"
|
||||
MASCARADE_QUEUE_MARKDOWN="$(json_get "${MASCARADE_QUEUE_FILE}" 'markdown_file')"
|
||||
log_line INFO "mascarade queue status=${MASCARADE_QUEUE_STATUS:-unknown} markdown=${MASCARADE_QUEUE_MARKDOWN:-none}"
|
||||
else
|
||||
MASCARADE_QUEUE_STATUS="failed"
|
||||
MASCARADE_QUEUE_MARKDOWN=""
|
||||
log_line ERROR "mascarade queue render failed"
|
||||
fi
|
||||
|
||||
[[ -n "${MASCARADE_QUEUE_STATUS}" ]] || MASCARADE_QUEUE_STATUS="unknown"
|
||||
}
|
||||
|
||||
refresh_mascarade_watch() {
|
||||
if bash "${PROJECT_DIR}/tools/cockpit/render_mascarade_incident_watch.sh" --json > "${MASCARADE_WATCH_FILE}" 2>>"${LOG_FILE}"; then
|
||||
MASCARADE_WATCH_STATUS="$(json_get "${MASCARADE_WATCH_FILE}" 'status')"
|
||||
MASCARADE_WATCH_MARKDOWN="$(json_get "${MASCARADE_WATCH_FILE}" 'markdown_file')"
|
||||
log_line INFO "mascarade watch status=${MASCARADE_WATCH_STATUS:-unknown} markdown=${MASCARADE_WATCH_MARKDOWN:-none}"
|
||||
else
|
||||
MASCARADE_WATCH_STATUS="failed"
|
||||
MASCARADE_WATCH_MARKDOWN=""
|
||||
log_line ERROR "mascarade watch render failed"
|
||||
fi
|
||||
|
||||
[[ -n "${MASCARADE_WATCH_STATUS}" ]] || MASCARADE_WATCH_STATUS="unknown"
|
||||
}
|
||||
|
||||
refresh_daily_operator_summary() {
|
||||
if bash "${PROJECT_DIR}/tools/cockpit/render_daily_operator_summary.sh" --json > "${DAILY_OPERATOR_SUMMARY_FILE}" 2>>"${LOG_FILE}"; then
|
||||
DAILY_OPERATOR_SUMMARY_STATUS="$(json_get "${DAILY_OPERATOR_SUMMARY_FILE}" 'status')"
|
||||
DAILY_OPERATOR_SUMMARY_MARKDOWN="$(json_get "${DAILY_OPERATOR_SUMMARY_FILE}" 'markdown_file')"
|
||||
log_line INFO "daily operator summary status=${DAILY_OPERATOR_SUMMARY_STATUS:-unknown} markdown=${DAILY_OPERATOR_SUMMARY_MARKDOWN:-none}"
|
||||
else
|
||||
DAILY_OPERATOR_SUMMARY_STATUS="failed"
|
||||
DAILY_OPERATOR_SUMMARY_MARKDOWN=""
|
||||
log_line ERROR "daily operator summary render failed"
|
||||
fi
|
||||
|
||||
[[ -n "${DAILY_OPERATOR_SUMMARY_STATUS}" ]] || DAILY_OPERATOR_SUMMARY_STATUS="unknown"
|
||||
}
|
||||
|
||||
current_contract_status() {
|
||||
local derived="ok"
|
||||
local value=""
|
||||
if [[ "${COMMAND_EXIT_CODE}" -ne 0 ]]; then
|
||||
printf 'error'
|
||||
return 0
|
||||
fi
|
||||
for value in "${MASCARADE_HEALTH_STATUS}" "${MASCARADE_LOGS_STATUS}" "${MASCARADE_QUEUE_STATUS}" "${MASCARADE_WATCH_STATUS}" "${DAILY_OPERATOR_SUMMARY_STATUS}"; do
|
||||
case "${value:-unknown}" in
|
||||
ok|ready|done|success|skipped|n/a)
|
||||
;;
|
||||
blocked|failed|error)
|
||||
printf 'error'
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
derived="degraded"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
printf '%s' "${derived}"
|
||||
}
|
||||
|
||||
current_trust_level() {
|
||||
local lane_status=""
|
||||
lane_status="$(current_contract_status)"
|
||||
if [[ "${lane_status}" == "ok" && "${ROUTING_STATUS}" == "ok" ]]; then
|
||||
printf 'verified'
|
||||
elif [[ "${ROUTING_STATUS}" == "ok" ]]; then
|
||||
printf 'bounded'
|
||||
else
|
||||
printf 'inferred'
|
||||
fi
|
||||
}
|
||||
|
||||
refresh_mascarade_routing() {
|
||||
if bash "${PROJECT_DIR}/tools/cockpit/mascarade_dispatch_mesh.sh" --action route --profile "kxkm-ops" --json > "${ROUTING_FILE}" 2>>"${LOG_FILE}"; then
|
||||
ROUTING_STATUS="$(json_get "${ROUTING_FILE}" 'status')"
|
||||
log_line INFO "mascarade routing status=${ROUTING_STATUS:-unknown} file=${ROUTING_FILE}"
|
||||
else
|
||||
ROUTING_STATUS="failed"
|
||||
log_line ERROR "mascarade routing failed"
|
||||
fi
|
||||
[[ -n "${ROUTING_STATUS}" ]] || ROUTING_STATUS="unknown"
|
||||
}
|
||||
|
||||
refresh_kill_life_memory() {
|
||||
local lane_status=""
|
||||
local trust_level=""
|
||||
local next_step=""
|
||||
local resume_ref=""
|
||||
lane_status="$(current_contract_status)"
|
||||
trust_level="$(current_trust_level)"
|
||||
resume_ref="kill-life:full-operator-lane:${WORKFLOW_ID}:${COMMAND}:${TIMESTAMP}"
|
||||
next_step="Review ${SUMMARY_FILE} then continue from ${COMMAND}."
|
||||
if [[ "${COMMAND_EXIT_CODE}" -ne 0 ]]; then
|
||||
next_step="Inspect ${LOG_FILE} and rerun bash tools/cockpit/full_operator_lane.sh ${COMMAND} --json."
|
||||
fi
|
||||
if bash "${PROJECT_DIR}/tools/cockpit/write_kill_life_memory_entry.sh" \
|
||||
--component "full_operator_lane" \
|
||||
--status "${lane_status}" \
|
||||
--owner "SyncOps" \
|
||||
--decision-action "full-operator-lane-${COMMAND}" \
|
||||
--decision-reason "Operator lane state is projected into kill_life with Tower/KXKM mesh routing for trustworthy continuity." \
|
||||
--next-step "${next_step}" \
|
||||
--resume-ref "${resume_ref}" \
|
||||
--trust-level "${trust_level}" \
|
||||
--routing-file "${ROUTING_FILE}" \
|
||||
--artifact "${SUMMARY_FILE}" \
|
||||
--artifact "${LOG_FILE}" \
|
||||
--artifact "${MASCARADE_WATCH_FILE}" \
|
||||
--artifact "${DAILY_OPERATOR_SUMMARY_FILE}" \
|
||||
--json > "${KILL_LIFE_MEMORY_FILE}" 2>>"${LOG_FILE}"; then
|
||||
KILL_LIFE_MEMORY_STATUS="$(json_get "${KILL_LIFE_MEMORY_FILE}" 'status')"
|
||||
log_line INFO "kill_life memory status=${KILL_LIFE_MEMORY_STATUS:-unknown} file=${KILL_LIFE_MEMORY_FILE}"
|
||||
else
|
||||
KILL_LIFE_MEMORY_STATUS="failed"
|
||||
log_line ERROR "kill_life memory write failed"
|
||||
fi
|
||||
[[ -n "${KILL_LIFE_MEMORY_STATUS}" ]] || KILL_LIFE_MEMORY_STATUS="unknown"
|
||||
}
|
||||
|
||||
refresh_product_contract_handoff() {
|
||||
PRODUCT_CONTRACT_HANDOFF_FILE="${STATE_DIR}/full_operator_lane_product_contract_handoff_${TIMESTAMP}.json"
|
||||
if bash "${PROJECT_DIR}/tools/cockpit/render_product_contract_handoff.sh" --no-refresh --json > "${PRODUCT_CONTRACT_HANDOFF_FILE}" 2>>"${LOG_FILE}"; then
|
||||
PRODUCT_CONTRACT_HANDOFF_STATUS="$(json_get "${PRODUCT_CONTRACT_HANDOFF_FILE}" 'status')"
|
||||
PRODUCT_CONTRACT_HANDOFF_MARKDOWN="$(json_get "${PRODUCT_CONTRACT_HANDOFF_FILE}" 'markdown_file')"
|
||||
log_line INFO "product contract handoff status=${PRODUCT_CONTRACT_HANDOFF_STATUS:-unknown} markdown=${PRODUCT_CONTRACT_HANDOFF_MARKDOWN:-none}"
|
||||
else
|
||||
PRODUCT_CONTRACT_HANDOFF_STATUS="failed"
|
||||
PRODUCT_CONTRACT_HANDOFF_MARKDOWN=""
|
||||
log_line ERROR "product contract handoff render failed"
|
||||
fi
|
||||
|
||||
[[ -n "${PRODUCT_CONTRACT_HANDOFF_STATUS}" ]] || PRODUCT_CONTRACT_HANDOFF_STATUS="unknown"
|
||||
}
|
||||
|
||||
capture_post_run_logs() {
|
||||
LOGS_ACTION="latest"
|
||||
refresh_mascarade_logs latest
|
||||
}
|
||||
|
||||
write_logs_summary() {
|
||||
python3 - "${SUMMARY_FILE}" "${MASCARADE_LOGS_FILE}" "${LOGS_ACTION}" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
summary_path = Path(sys.argv[1])
|
||||
logs_path = Path(sys.argv[2])
|
||||
logs_action = sys.argv[3]
|
||||
|
||||
payload = {
|
||||
"status": "failed",
|
||||
"logs_action": logs_action,
|
||||
"source": str(logs_path),
|
||||
}
|
||||
|
||||
if logs_path.exists():
|
||||
try:
|
||||
data = json.loads(logs_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
data = {"status": "failed", "error": "invalid-json", "source": str(logs_path)}
|
||||
payload["status"] = data.get("status", "unknown")
|
||||
payload["details"] = data
|
||||
else:
|
||||
payload["error"] = "missing-logs-artifact"
|
||||
|
||||
summary_path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
PY
|
||||
}
|
||||
|
||||
validate_workflow() {
|
||||
local output_file="${STATE_DIR}/full_operator_lane_validate_${TIMESTAMP}.json"
|
||||
if ! curl_json POST "${API_BASE}/workflows/${WORKFLOW_ID}/validate" "" "${output_file}"; then
|
||||
write_failure_summary "${output_file}" "validate-api-unreachable" "${API_BASE}/workflows/${WORKFLOW_ID}/validate"
|
||||
cp "${output_file}" "${SUMMARY_FILE}"
|
||||
log_line ERROR "validate workflow=${WORKFLOW_ID} unreachable api_base=${API_BASE}; try 'bash tools/cockpit/full_operator_lane.sh status --json'"
|
||||
return 1
|
||||
fi
|
||||
local valid
|
||||
valid="$(json_get "${output_file}" 'valid')"
|
||||
log_line INFO "validate workflow=${WORKFLOW_ID} valid=${valid:-unknown}"
|
||||
}
|
||||
|
||||
start_run() {
|
||||
local dry_run_flag="$1"
|
||||
local output_file="${STATE_DIR}/full_operator_lane_start_${TIMESTAMP}.json"
|
||||
if ! curl_json POST "${API_BASE}/workflows/${WORKFLOW_ID}/run" "{\"mode\":\"local\",\"dry_run\":${dry_run_flag}}" "${output_file}"; then
|
||||
write_failure_summary "${output_file}" "run-api-unreachable" "${API_BASE}/workflows/${WORKFLOW_ID}/run"
|
||||
cp "${output_file}" "${SUMMARY_FILE}"
|
||||
log_line ERROR "run start unreachable workflow=${WORKFLOW_ID} dry_run=${dry_run_flag} api_base=${API_BASE}; verify local API or export CRAZY_LIFE_API_BASE"
|
||||
return 1
|
||||
fi
|
||||
local run_id
|
||||
run_id="$(json_get "${output_file}" 'run_id')"
|
||||
if [[ -z "${run_id}" ]]; then
|
||||
log_line ERROR "run start failed for workflow=${WORKFLOW_ID} dry_run=${dry_run_flag}"
|
||||
cp "${output_file}" "${SUMMARY_FILE}"
|
||||
return 1
|
||||
fi
|
||||
local status
|
||||
status="$(json_get "${output_file}" 'status')"
|
||||
cp "${output_file}" "${STATE_DIR}/full_operator_lane_run_${run_id}.json"
|
||||
cp "${output_file}" "${SUMMARY_FILE}"
|
||||
log_line INFO "run completed workflow=${WORKFLOW_ID} dry_run=${dry_run_flag} run_id=${run_id} status=${status:-unknown}"
|
||||
}
|
||||
|
||||
poll_run() {
|
||||
local run_id="$1"
|
||||
local output_file="${STATE_DIR}/full_operator_lane_run_${run_id}.json"
|
||||
local attempt="1"
|
||||
while [[ "${attempt}" -le "${WAIT_ATTEMPTS}" ]]; do
|
||||
if ! curl_json GET "${API_BASE}/workflows/${WORKFLOW_ID}/runs/${run_id}" "" "${output_file}"; then
|
||||
write_failure_summary "${output_file}" "poll-api-unreachable" "${API_BASE}/workflows/${WORKFLOW_ID}/runs/${run_id}"
|
||||
cp "${output_file}" "${SUMMARY_FILE}"
|
||||
log_line ERROR "poll unreachable run_id=${run_id} attempt=${attempt} api_base=${API_BASE}; retry status once API is back"
|
||||
return 1
|
||||
fi
|
||||
local status
|
||||
status="$(json_get "${output_file}" 'status')"
|
||||
log_line INFO "poll run_id=${run_id} attempt=${attempt} status=${status:-unknown}"
|
||||
case "${status}" in
|
||||
success|failed|no-op|cancelled|cancel_unresolved)
|
||||
cp "${output_file}" "${SUMMARY_FILE}"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
sleep "${WAIT_SECONDS}"
|
||||
attempt="$((attempt + 1))"
|
||||
done
|
||||
cp "${output_file}" "${SUMMARY_FILE}"
|
||||
}
|
||||
|
||||
status_command() {
|
||||
if curl_json GET "${API_BASE}/workflows/${WORKFLOW_ID}" "" "${SUMMARY_FILE}"; then
|
||||
log_line INFO "status workflow=${WORKFLOW_ID} summary=${SUMMARY_FILE}"
|
||||
return 0
|
||||
fi
|
||||
write_failure_summary "${SUMMARY_FILE}" "status-api-unreachable" "${API_BASE}/workflows/${WORKFLOW_ID}"
|
||||
log_line ERROR "status workflow=${WORKFLOW_ID} unreachable api_base=${API_BASE}; export CRAZY_LIFE_API_BASE or restart the local API"
|
||||
return 1
|
||||
}
|
||||
|
||||
purge_command() {
|
||||
mkdir -p "${LOG_DIR}" "${STATE_DIR}"
|
||||
rm -f "${LOG_DIR}"/full_operator_lane_*.log "${STATE_DIR}"/full_operator_lane_*.json "${STATE_DIR}"/full_operator_lane_mascarade_health_*.json "${STATE_DIR}"/full_operator_lane_mascarade_logs_*.json "${STATE_DIR}"/full_operator_lane_mascarade_queue_*.json "${STATE_DIR}"/full_operator_lane_mascarade_watch_*.json "${STATE_DIR}"/full_operator_lane_routing_*.json "${STATE_DIR}"/full_operator_lane_kill_life_memory_*.json "${STATE_DIR}"/live_provider_result.json
|
||||
printf '{"status":"done","purged":["artifacts/cockpit/full_operator_lane_*.log","artifacts/operator_lane/full_operator_lane_*.json","artifacts/operator_lane/full_operator_lane_mascarade_health_*.json","artifacts/operator_lane/full_operator_lane_mascarade_logs_*.json","artifacts/operator_lane/full_operator_lane_mascarade_queue_*.json","artifacts/operator_lane/full_operator_lane_mascarade_watch_*.json","artifacts/operator_lane/full_operator_lane_routing_*.json","artifacts/operator_lane/full_operator_lane_kill_life_memory_*.json","artifacts/operator_lane/live_provider_result.json"]}\n' > "${SUMMARY_FILE}"
|
||||
MASCARADE_HEALTH_STATUS="skipped"
|
||||
MASCARADE_QUEUE_STATUS="skipped"
|
||||
MASCARADE_QUEUE_MARKDOWN=""
|
||||
MASCARADE_WATCH_STATUS="skipped"
|
||||
MASCARADE_WATCH_MARKDOWN=""
|
||||
refresh_mascarade_logs purge
|
||||
log_line INFO "purged full operator lane artifacts"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
status|dry-run|live|all|logs|purge)
|
||||
COMMAND="$1"
|
||||
;;
|
||||
--json)
|
||||
JSON_OUTPUT=1
|
||||
;;
|
||||
--logs-action)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
LOGS_ACTION="$2"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [[ ! "${LOGS_ACTION}" =~ ^(summary|latest|list|purge)$ ]]; then
|
||||
echo "[error] --logs-action requires summary|latest|list|purge" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
case "${COMMAND}" in
|
||||
status)
|
||||
refresh_mascarade_health
|
||||
refresh_mascarade_logs summary
|
||||
refresh_mascarade_queue
|
||||
refresh_daily_operator_summary
|
||||
refresh_mascarade_watch
|
||||
status_command || COMMAND_EXIT_CODE=1
|
||||
;;
|
||||
dry-run)
|
||||
refresh_mascarade_health
|
||||
refresh_mascarade_logs summary
|
||||
refresh_mascarade_queue
|
||||
refresh_daily_operator_summary
|
||||
refresh_mascarade_watch
|
||||
validate_workflow || COMMAND_EXIT_CODE=1
|
||||
if [[ "${COMMAND_EXIT_CODE}" -eq 0 ]]; then
|
||||
start_run true || COMMAND_EXIT_CODE=1
|
||||
fi
|
||||
capture_post_run_logs
|
||||
refresh_mascarade_queue
|
||||
refresh_daily_operator_summary
|
||||
refresh_mascarade_watch
|
||||
;;
|
||||
live)
|
||||
refresh_mascarade_health
|
||||
refresh_mascarade_logs summary
|
||||
refresh_mascarade_queue
|
||||
refresh_daily_operator_summary
|
||||
refresh_mascarade_watch
|
||||
validate_workflow || COMMAND_EXIT_CODE=1
|
||||
if [[ "${COMMAND_EXIT_CODE}" -eq 0 ]]; then
|
||||
start_run false || COMMAND_EXIT_CODE=1
|
||||
fi
|
||||
capture_post_run_logs
|
||||
refresh_mascarade_queue
|
||||
refresh_daily_operator_summary
|
||||
refresh_mascarade_watch
|
||||
;;
|
||||
all)
|
||||
refresh_mascarade_health
|
||||
refresh_mascarade_logs summary
|
||||
refresh_mascarade_queue
|
||||
refresh_daily_operator_summary
|
||||
refresh_mascarade_watch
|
||||
validate_workflow || COMMAND_EXIT_CODE=1
|
||||
if [[ "${COMMAND_EXIT_CODE}" -eq 0 ]]; then
|
||||
start_run true || COMMAND_EXIT_CODE=1
|
||||
fi
|
||||
if [[ "${COMMAND_EXIT_CODE}" -eq 0 ]]; then
|
||||
start_run false || COMMAND_EXIT_CODE=1
|
||||
fi
|
||||
capture_post_run_logs
|
||||
refresh_mascarade_queue
|
||||
refresh_daily_operator_summary
|
||||
refresh_mascarade_watch
|
||||
;;
|
||||
logs)
|
||||
refresh_mascarade_health
|
||||
refresh_mascarade_logs "${LOGS_ACTION}"
|
||||
refresh_mascarade_queue
|
||||
write_logs_summary
|
||||
refresh_daily_operator_summary
|
||||
refresh_mascarade_watch
|
||||
if [[ "${MASCARADE_LOGS_STATUS}" == "failed" || "${MASCARADE_LOGS_STATUS}" == "error" ]]; then
|
||||
COMMAND_EXIT_CODE=1
|
||||
fi
|
||||
;;
|
||||
purge)
|
||||
purge_command
|
||||
;;
|
||||
esac
|
||||
|
||||
refresh_mascarade_routing
|
||||
refresh_kill_life_memory
|
||||
refresh_product_contract_handoff
|
||||
|
||||
if [[ "${JSON_OUTPUT}" -eq 1 && -f "${SUMMARY_FILE}" ]]; then
|
||||
emit_contract_json
|
||||
fi
|
||||
|
||||
if [[ "${COMMAND_EXIT_CODE}" -ne 0 ]]; then
|
||||
exit "${COMMAND_EXIT_CODE}"
|
||||
fi
|
||||
Executable
+164
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
TIMESTAMP="$(date +"%Y%m%d_%H%M%S")"
|
||||
LOG_DIR="${ROOT_DIR}/artifacts/cockpit"
|
||||
STATE_DIR="${ROOT_DIR}/artifacts/operator_lane"
|
||||
LOG_FILE="${LOG_DIR}/full_operator_lane_sync_${TIMESTAMP}.log"
|
||||
SUMMARY_FILE="${STATE_DIR}/full_operator_lane_sync_${TIMESTAMP}.json"
|
||||
|
||||
JSON_MODE="no"
|
||||
MODE="staged"
|
||||
|
||||
LOCAL_KILL_LIFE_MIRROR="${LOCAL_KILL_LIFE_MIRROR:-/Users/electron/Documents/Lelectron_rare/Github_Repos/Perso/Kill_LIFE-main}"
|
||||
LOCAL_MASCARADE_MIRROR="${LOCAL_MASCARADE_MIRROR:-/Users/electron/Documents/Lelectron_rare/Github_Repos/Perso/mascarade-main}"
|
||||
LOCAL_CRAZY_LIFE_MIRROR="${LOCAL_CRAZY_LIFE_MIRROR:-/Users/electron/Documents/Lelectron_rare/Github_Repos/Perso/crazy_life-main}"
|
||||
|
||||
KILL_LIFE_FILES=(
|
||||
"tools/cockpit/full_operator_lane.sh"
|
||||
"tools/cockpit/full_operator_lane_sync.sh"
|
||||
"tools/cockpit/README.md"
|
||||
"tools/ops/operator_live_provider_smoke.py"
|
||||
"tools/ops/operator_live_provider_smoke.js"
|
||||
"workflows/embedded-operator-live.json"
|
||||
"docs/FULL_OPERATOR_LANE_2026-03-20.md"
|
||||
"docs/PROVIDER_RUNTIME_COMPAT_2026-03-20.md"
|
||||
"docs/MACHINE_SYNC_STATUS_2026-03-20.md"
|
||||
"docs/plans/12_plan_gestion_des_agents.md"
|
||||
"docs/plans/19_todo_mesh_tri_repo.md"
|
||||
"docs/index.md"
|
||||
"README.md"
|
||||
"specs/03_plan.md"
|
||||
"specs/04_tasks.md"
|
||||
)
|
||||
|
||||
MASCARADE_FILES=(
|
||||
"api/src/lib/killlife.ts"
|
||||
"docs/FULL_OPERATOR_LANE_2026-03-20.md"
|
||||
"docs/TODO_MESH_TRI_REPO_2026-03-20.md"
|
||||
"README.md"
|
||||
)
|
||||
|
||||
CRAZY_LIFE_FILES=(
|
||||
"api/src/lib/killlife.ts"
|
||||
"docs/FULL_OPERATOR_LANE_2026-03-20.md"
|
||||
"docs/TODO_MESH_TRI_REPO_2026-03-20.md"
|
||||
"README.md"
|
||||
)
|
||||
|
||||
REMOTE_TARGETS=(
|
||||
"clems@192.168.0.120|/home/clems/Kill_LIFE-main|/home/clems/mascarade-main|/home/clems/crazy_life-main"
|
||||
"root@192.168.0.119|/root/Kill_LIFE-main|/root/mascarade-main|/root/crazy_life-main"
|
||||
"kxkm@kxkm-ai|/home/kxkm/Kill_LIFE-main|/home/kxkm/mascarade-main|/home/kxkm/crazy_life-main"
|
||||
"cils@100.126.225.111|/Users/cils/Kill_LIFE-main|/Users/cils/mascarade-main|/Users/cils/crazy_life-main"
|
||||
)
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: bash tools/cockpit/full_operator_lane_sync.sh [--json] [--mode staged|clems-live]
|
||||
EOF
|
||||
}
|
||||
|
||||
log_line() {
|
||||
local level="$1"
|
||||
local message="$2"
|
||||
mkdir -p "${LOG_DIR}" "${STATE_DIR}"
|
||||
local line
|
||||
line="${level} $(date +"%Y-%m-%d %H:%M:%S %z") ${message}"
|
||||
printf '%s\n' "${line}" >> "${LOG_FILE}"
|
||||
if [[ "${JSON_MODE}" != "yes" ]]; then
|
||||
printf '%s\n' "${line}"
|
||||
fi
|
||||
}
|
||||
|
||||
copy_local_file() {
|
||||
local src="$1"
|
||||
local dest="$2"
|
||||
mkdir -p "$(dirname "${dest}")"
|
||||
cp "${src}" "${dest}"
|
||||
}
|
||||
|
||||
sync_local_repo() {
|
||||
local src_root="$1"
|
||||
local dest_root="$2"
|
||||
shift 2
|
||||
local rel
|
||||
for rel in "$@"; do
|
||||
copy_local_file "${src_root}/${rel}" "${dest_root}/${rel}"
|
||||
done
|
||||
}
|
||||
|
||||
sync_remote_repo() {
|
||||
local src_root="$1"
|
||||
local target="$2"
|
||||
local dest_root="$3"
|
||||
shift 3
|
||||
tar -cf - -C "${src_root}" "$@" | ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new "${target}" \
|
||||
"mkdir -p \"${dest_root}\" && cd \"${dest_root}\" && tar -xf -"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--json)
|
||||
JSON_MODE="yes"
|
||||
shift
|
||||
;;
|
||||
--mode)
|
||||
MODE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "${MODE}" != "staged" && "${MODE}" != "clems-live" ]]; then
|
||||
usage
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p "${LOG_DIR}" "${STATE_DIR}"
|
||||
log_line INFO "Starting full operator lane sync mode=${MODE}"
|
||||
|
||||
sync_local_repo "${ROOT_DIR}" "${LOCAL_KILL_LIFE_MIRROR}" "${KILL_LIFE_FILES[@]}"
|
||||
log_line INFO "Updated local Kill_LIFE-main mirror"
|
||||
|
||||
for entry in "${REMOTE_TARGETS[@]}"; do
|
||||
IFS='|' read -r target kill_life_root mascarade_root crazy_life_root <<< "${entry}"
|
||||
log_line INFO "Syncing staged lanes on ${target}"
|
||||
sync_remote_repo "${ROOT_DIR}" "${target}" "${kill_life_root}" "${KILL_LIFE_FILES[@]}"
|
||||
sync_remote_repo "${LOCAL_MASCARADE_MIRROR}" "${target}" "${mascarade_root}" "${MASCARADE_FILES[@]}"
|
||||
sync_remote_repo "${LOCAL_CRAZY_LIFE_MIRROR}" "${target}" "${crazy_life_root}" "${CRAZY_LIFE_FILES[@]}"
|
||||
log_line INFO "Synced staged lanes on ${target}"
|
||||
done
|
||||
|
||||
if [[ "${MODE}" == "clems-live" ]]; then
|
||||
sync_remote_repo "${ROOT_DIR}" "clems@192.168.0.120" "/home/clems/Kill_LIFE" \
|
||||
"tools/ops/operator_live_provider_smoke.py" \
|
||||
"tools/ops/operator_live_provider_smoke.js" \
|
||||
"workflows/embedded-operator-live.json"
|
||||
sync_remote_repo "${LOCAL_MASCARADE_MIRROR}" "clems@192.168.0.120" "/home/clems/mascarade" \
|
||||
"api/src/lib/killlife.ts"
|
||||
log_line INFO "Synced clems live roots"
|
||||
fi
|
||||
|
||||
cat > "${SUMMARY_FILE}" <<EOF
|
||||
{
|
||||
"generated_at": "$(date +"%Y-%m-%d %H:%M:%S %z")",
|
||||
"status": "done",
|
||||
"mode": "${MODE}",
|
||||
"targets": ${#REMOTE_TARGETS[@]},
|
||||
"log_file": "${LOG_FILE}"
|
||||
}
|
||||
EOF
|
||||
|
||||
if [[ "${JSON_MODE}" == "yes" ]]; then
|
||||
cat "${SUMMARY_FILE}"
|
||||
fi
|
||||
@@ -0,0 +1,309 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# infra_container_health.sh — Infrastructure Container Health Check & Fix
|
||||
#
|
||||
# Vérifie l'état de tous les containers Docker sur la VM photon-docker
|
||||
# et propose des actions correctives pour les containers down.
|
||||
#
|
||||
# Usage:
|
||||
# bash infra_container_health.sh --action status
|
||||
# bash infra_container_health.sh --action fix --target metabase
|
||||
# bash infra_container_health.sh --action fix-all --yes
|
||||
# bash infra_container_health.sh --action web-check
|
||||
# bash infra_container_health.sh --json
|
||||
#
|
||||
# Contract: cockpit-v1
|
||||
# Owner: Sentinelle + PM-Mesh
|
||||
# Date: 2026-03-21
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
ARTIFACTS_DIR="${ROOT_DIR}/artifacts/infra_container_health"
|
||||
mkdir -p "${ARTIFACTS_DIR}"
|
||||
|
||||
STAMP="$(date +%Y%m%d_%H%M%S)"
|
||||
LOG_FILE="${ARTIFACTS_DIR}/health_${STAMP}.log"
|
||||
|
||||
ACTION=""
|
||||
TARGET=""
|
||||
JSON_MODE=0
|
||||
VERBOSE=0
|
||||
YES=0
|
||||
|
||||
# VM Configuration
|
||||
VM_HOST="${VM_HOST:-192.168.0.119}"
|
||||
VM_USER="${VM_USER:-clement}"
|
||||
SSH_OPTS="-o ConnectTimeout=10 -o StrictHostKeyChecking=no -o BatchMode=yes"
|
||||
|
||||
# Known services and their expected container names/web endpoints
|
||||
declare -A SERVICE_URLS=(
|
||||
["mascarade"]="https://mascarade.saillant.cc"
|
||||
["langfuse"]="https://langfuse.saillant.cc"
|
||||
["grafana"]="https://grafana.saillant.cc"
|
||||
["authentik"]="https://auth.saillant.cc"
|
||||
["outline"]="https://outline.saillant.cc"
|
||||
["n8n"]="https://n8n.saillant.cc"
|
||||
["gitea"]="https://gitea.saillant.cc"
|
||||
["uptime-kuma"]="https://uptime.saillant.cc"
|
||||
["portainer"]="https://portainer.saillant.cc"
|
||||
["metabase"]="https://metabase.saillant.cc"
|
||||
["listmonk"]="https://listmonk.saillant.cc"
|
||||
["changedetection"]="https://changedetection.saillant.cc"
|
||||
["bookmarks"]="https://bookmarks.saillant.cc"
|
||||
["dify"]="https://dify.saillant.cc"
|
||||
)
|
||||
|
||||
# Couleurs
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: infra_container_health.sh --action <status|fix|fix-all|web-check|docker-status|restart> [options]
|
||||
|
||||
Actions:
|
||||
status Full health report (web + docker)
|
||||
web-check Check web endpoints only (no SSH required)
|
||||
docker-status Check Docker containers via SSH
|
||||
fix Restart a specific container (requires --target)
|
||||
fix-all Restart all down containers (requires --yes)
|
||||
restart Restart a specific container (alias for fix)
|
||||
|
||||
Options:
|
||||
--action <name> Action to run
|
||||
--target <name> Container name for fix/restart
|
||||
--json Emit JSON report
|
||||
--yes Confirm destructive actions
|
||||
--verbose Show detailed output
|
||||
--help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
log_info() { printf "${CYAN}[INFO]${NC} %s\n" "$*"; }
|
||||
log_ok() { printf "${GREEN}[ OK ]${NC} %s\n" "$*"; }
|
||||
log_warn() { printf "${YELLOW}[WARN]${NC} %s\n" "$*"; }
|
||||
log_err() { printf "${RED}[FAIL]${NC} %s\n" "$*"; }
|
||||
|
||||
# ── Web Health Check ──────────────────────────────────────────────────────
|
||||
check_web_endpoint() {
|
||||
local name="$1"
|
||||
local url="$2"
|
||||
local http_code
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -L "${url}" 2>/dev/null) || http_code="000"
|
||||
|
||||
local status="down"
|
||||
if [[ "${http_code}" =~ ^(200|301|302|303|307|401|403)$ ]]; then
|
||||
status="up"
|
||||
fi
|
||||
|
||||
echo "${name}|${url}|${http_code}|${status}"
|
||||
}
|
||||
|
||||
action_web_check() {
|
||||
log_info "Checking web endpoints..."
|
||||
local up=0 down=0 results=()
|
||||
|
||||
for name in $(echo "${!SERVICE_URLS[@]}" | tr ' ' '\n' | sort); do
|
||||
local url="${SERVICE_URLS[$name]}"
|
||||
local result
|
||||
result=$(check_web_endpoint "${name}" "${url}")
|
||||
results+=("${result}")
|
||||
|
||||
local http_code status
|
||||
http_code=$(echo "${result}" | cut -d'|' -f3)
|
||||
status=$(echo "${result}" | cut -d'|' -f4)
|
||||
|
||||
if [[ "${status}" == "up" ]]; then
|
||||
log_ok "${name} — HTTP ${http_code} (${url})"
|
||||
((up++))
|
||||
else
|
||||
log_err "${name} — HTTP ${http_code} (${url})"
|
||||
((down++))
|
||||
fi
|
||||
done
|
||||
|
||||
local total=$((up + down))
|
||||
echo ""
|
||||
printf "═══ Web Health: %d/%d up " "${up}" "${total}"
|
||||
if [[ "${down}" -gt 0 ]]; then
|
||||
printf "(${RED}%d down${NC})" "${down}"
|
||||
else
|
||||
printf "(${GREEN}all healthy${NC})"
|
||||
fi
|
||||
echo " ═══"
|
||||
|
||||
if [[ "${JSON_MODE}" -eq 1 ]]; then
|
||||
python3 -c "
|
||||
import json
|
||||
results = []
|
||||
for r in '''$(printf '%s\n' "${results[@]}")'''.strip().split('\n'):
|
||||
parts = r.split('|')
|
||||
if len(parts) == 4:
|
||||
results.append({'name': parts[0], 'url': parts[1], 'http_code': int(parts[2]) if parts[2].isdigit() else 0, 'status': parts[3]})
|
||||
print(json.dumps({
|
||||
'contract': 'cockpit-v1',
|
||||
'tool': 'infra_container_health',
|
||||
'action': 'web-check',
|
||||
'timestamp': '${STAMP}',
|
||||
'total': ${total},
|
||||
'up': ${up},
|
||||
'down': ${down},
|
||||
'services': results
|
||||
}, indent=2))
|
||||
"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Docker Status via SSH ─────────────────────────────────────────────────
|
||||
action_docker_status() {
|
||||
log_info "Checking Docker containers on ${VM_HOST}..."
|
||||
|
||||
local ssh_result
|
||||
if ! ssh_result=$(ssh ${SSH_OPTS} "${VM_USER}@${VM_HOST}" \
|
||||
"docker ps -a --format '{{.Names}}|{{.Status}}|{{.Image}}|{{.Ports}}'" 2>/dev/null); then
|
||||
log_err "SSH connection to ${VM_HOST} failed"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local running=0 stopped=0 total=0
|
||||
|
||||
echo ""
|
||||
printf "%-30s %-15s %s\n" "CONTAINER" "STATE" "IMAGE"
|
||||
printf "%s\n" "$(printf '─%.0s' {1..80})"
|
||||
|
||||
while IFS='|' read -r name status image ports; do
|
||||
[[ -z "${name}" ]] && continue
|
||||
((total++))
|
||||
|
||||
local state="stopped"
|
||||
if [[ "${status}" == *"Up"* ]]; then
|
||||
state="running"
|
||||
((running++))
|
||||
printf "${GREEN}%-30s${NC} %-15s %s\n" "${name}" "${state}" "${image}"
|
||||
else
|
||||
((stopped++))
|
||||
printf "${RED}%-30s${NC} %-15s %s\n" "${name}" "${state}" "${image}"
|
||||
fi
|
||||
done <<< "${ssh_result}"
|
||||
|
||||
echo ""
|
||||
printf "═══ Docker: %d/%d running " "${running}" "${total}"
|
||||
if [[ "${stopped}" -gt 0 ]]; then
|
||||
printf "(${RED}%d stopped${NC})" "${stopped}"
|
||||
else
|
||||
printf "(${GREEN}all running${NC})"
|
||||
fi
|
||||
echo " ═══"
|
||||
}
|
||||
|
||||
# ── Fix / Restart ─────────────────────────────────────────────────────────
|
||||
action_fix() {
|
||||
if [[ -z "${TARGET}" ]]; then
|
||||
log_err "Missing --target <container_name>"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Attempting to restart container: ${TARGET}"
|
||||
|
||||
# Try docker compose first, then docker restart
|
||||
local compose_result
|
||||
if ssh ${SSH_OPTS} "${VM_USER}@${VM_HOST}" \
|
||||
"cd /opt/docker && docker compose up -d ${TARGET}" 2>/dev/null; then
|
||||
log_ok "Container ${TARGET} restarted via docker compose"
|
||||
elif ssh ${SSH_OPTS} "${VM_USER}@${VM_HOST}" \
|
||||
"docker restart ${TARGET}" 2>/dev/null; then
|
||||
log_ok "Container ${TARGET} restarted via docker restart"
|
||||
else
|
||||
log_err "Failed to restart ${TARGET}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Verify
|
||||
sleep 3
|
||||
local status
|
||||
status=$(ssh ${SSH_OPTS} "${VM_USER}@${VM_HOST}" \
|
||||
"docker inspect -f '{{.State.Status}}' ${TARGET}" 2>/dev/null) || status="unknown"
|
||||
log_info "Post-restart status: ${status}"
|
||||
}
|
||||
|
||||
action_fix_all() {
|
||||
if [[ "${YES}" -ne 1 ]]; then
|
||||
log_err "Requires --yes flag to restart all down containers"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Finding stopped containers..."
|
||||
local stopped
|
||||
stopped=$(ssh ${SSH_OPTS} "${VM_USER}@${VM_HOST}" \
|
||||
"docker ps -a --filter 'status=exited' --filter 'status=created' --format '{{.Names}}'" 2>/dev/null) || {
|
||||
log_err "SSH connection failed"
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ -z "${stopped}" ]]; then
|
||||
log_ok "No stopped containers found"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local count=0
|
||||
while IFS= read -r name; do
|
||||
[[ -z "${name}" ]] && continue
|
||||
log_info "Restarting: ${name}"
|
||||
if ssh ${SSH_OPTS} "${VM_USER}@${VM_HOST}" "docker start ${name}" 2>/dev/null; then
|
||||
log_ok "Started: ${name}"
|
||||
((count++))
|
||||
else
|
||||
log_err "Failed to start: ${name}"
|
||||
fi
|
||||
done <<< "${stopped}"
|
||||
|
||||
log_info "Restarted ${count} containers"
|
||||
}
|
||||
|
||||
# ── Full Status ───────────────────────────────────────────────────────────
|
||||
action_status() {
|
||||
echo "╔══════════════════════════════════════════════════╗"
|
||||
echo "║ Infrastructure Health Report — ${STAMP} ║"
|
||||
echo "╚══════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
action_web_check
|
||||
echo ""
|
||||
action_docker_status
|
||||
}
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action) ACTION="${2:-}"; shift 2 ;;
|
||||
--target) TARGET="${2:-}"; shift 2 ;;
|
||||
--json) JSON_MODE=1; shift ;;
|
||||
--yes) YES=1; shift ;;
|
||||
--verbose) VERBOSE=1; shift ;;
|
||||
--help) usage; exit 0 ;;
|
||||
*) printf 'Unknown: %s\n' "$1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "${ACTION}" ]] && { usage >&2; exit 2; }
|
||||
|
||||
exec > >(tee -a "${LOG_FILE}") 2>&1
|
||||
printf '[infra-container-health] action=%s timestamp=%s\n' "${ACTION}" "${STAMP}"
|
||||
|
||||
case "${ACTION}" in
|
||||
status) action_status ;;
|
||||
web-check) action_web_check ;;
|
||||
docker-status) action_docker_status ;;
|
||||
fix|restart) action_fix ;;
|
||||
fix-all) action_fix_all ;;
|
||||
*)
|
||||
printf 'Unknown action: %s\n' "${ACTION}" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# integration_health_tui.sh — Health check complet de l'écosystème saillant.cc
|
||||
# Contrat: cockpit-v1
|
||||
# Lot: 23 — Intégration Mistral Agents
|
||||
# Date: 2026-03-21
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'
|
||||
CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
|
||||
|
||||
VM_HOST="${VM_HOST:-192.168.0.119}"
|
||||
VM_USER="${VM_USER:-clement}"
|
||||
TIMEOUT=5
|
||||
|
||||
# --- Services catalogue ---
|
||||
declare -A SERVICES=(
|
||||
["Authentik SSO"]="https://auth.saillant.cc"
|
||||
["Mascarade API"]="https://mascarade.saillant.cc/health"
|
||||
["Langfuse"]="https://langfuse.saillant.cc"
|
||||
["Grafana"]="https://grafana.saillant.cc"
|
||||
["Prometheus"]="https://prometheus.saillant.cc"
|
||||
["Outline Wiki"]="https://wiki.saillant.cc"
|
||||
["n8n Workflows"]="https://n8n.saillant.cc"
|
||||
["Excalidraw"]="https://draw.saillant.cc"
|
||||
["Dify AI"]="https://dify.saillant.cc"
|
||||
["Gitea"]="https://git.saillant.cc"
|
||||
["Portainer"]="https://portainer.saillant.cc"
|
||||
["Uptime Kuma"]="https://uptime.saillant.cc"
|
||||
["Homepage"]="https://home.saillant.cc"
|
||||
["Audiobookshelf"]="https://audio.saillant.cc"
|
||||
["Immich"]="https://photos.saillant.cc"
|
||||
["Vaultwarden"]="https://vault.saillant.cc"
|
||||
["Paperless"]="https://docs.saillant.cc"
|
||||
["Metabase"]="https://metabase.saillant.cc"
|
||||
["Listmonk"]="https://listmonk.saillant.cc"
|
||||
["Changedetection"]="https://changes.saillant.cc"
|
||||
["Bookmarks"]="https://bookmarks.saillant.cc"
|
||||
)
|
||||
|
||||
# --- Fonctions ---
|
||||
check_service() {
|
||||
local name="$1" url="$2"
|
||||
local http_code
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time "$TIMEOUT" \
|
||||
-L "$url" 2>/dev/null || echo "000")
|
||||
|
||||
case "$http_code" in
|
||||
200|301|302|303|307|308)
|
||||
echo -e " ${GREEN}●${NC} ${name}: ${GREEN}OK${NC} (${http_code})"
|
||||
return 0
|
||||
;;
|
||||
401|403)
|
||||
echo -e " ${GREEN}●${NC} ${name}: ${GREEN}AUTH-OK${NC} (${http_code} — SSO protégé)"
|
||||
return 0
|
||||
;;
|
||||
502|503|504)
|
||||
echo -e " ${RED}✗${NC} ${name}: ${RED}DOWN${NC} (${http_code})"
|
||||
return 1
|
||||
;;
|
||||
000)
|
||||
echo -e " ${RED}✗${NC} ${name}: ${RED}TIMEOUT${NC}"
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
echo -e " ${YELLOW}?${NC} ${name}: ${YELLOW}HTTP ${http_code}${NC}"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
check_docker_containers() {
|
||||
echo -e "${BOLD}[Docker Containers — ${VM_HOST}]${NC}"
|
||||
|
||||
local output
|
||||
output=$(ssh -o ConnectTimeout="$TIMEOUT" -o BatchMode=yes "${VM_USER}@${VM_HOST}" \
|
||||
"docker ps -a --format '{{.Names}}\t{{.Status}}\t{{.Ports}}' 2>/dev/null" 2>/dev/null) || {
|
||||
echo -e " ${RED}✗ SSH connection failed to ${VM_HOST}${NC}"
|
||||
return 1
|
||||
}
|
||||
|
||||
local running=0 stopped=0 total=0
|
||||
|
||||
while IFS=$'\t' read -r name status ports; do
|
||||
((total++))
|
||||
if echo "$status" | grep -q "^Up"; then
|
||||
((running++))
|
||||
else
|
||||
((stopped++))
|
||||
echo -e " ${RED}✗${NC} ${name}: ${RED}${status}${NC}"
|
||||
fi
|
||||
done <<< "$output"
|
||||
|
||||
echo -e " ${GREEN}●${NC} Running: ${GREEN}${running}${NC}/${total} | Stopped: ${RED}${stopped}${NC}"
|
||||
return 0
|
||||
}
|
||||
|
||||
check_mistral_agents() {
|
||||
local api_key="${MISTRAL_API_KEY:-}"
|
||||
echo -e "${BOLD}[Mistral Agents]${NC}"
|
||||
|
||||
if [ -z "$api_key" ]; then
|
||||
echo -e " ${YELLOW}○${NC} MISTRAL_API_KEY not set — skip"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local agents=("sentinelle-ops-v1" "tower-commercial-v1" "forge-finetune-v1" "devstral-code-v1")
|
||||
local names=("Sentinelle" "Tower" "Forge" "Devstral")
|
||||
|
||||
for i in "${!agents[@]}"; do
|
||||
local http_code
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time "$TIMEOUT" \
|
||||
-H "Authorization: Bearer $api_key" \
|
||||
"https://api.mistral.ai/v1/agents/${agents[$i]}" 2>/dev/null || echo "000")
|
||||
|
||||
case "$http_code" in
|
||||
200) echo -e " ${GREEN}●${NC} ${names[$i]}: ${GREEN}Active${NC}" ;;
|
||||
404) echo -e " ${YELLOW}○${NC} ${names[$i]}: ${YELLOW}Not deployed${NC}" ;;
|
||||
*) echo -e " ${RED}✗${NC} ${names[$i]}: ${RED}Error (${http_code})${NC}" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# --- Action principale ---
|
||||
action_full_health() {
|
||||
local ok=0 fail=0
|
||||
|
||||
echo -e "${BOLD}${CYAN}╔══════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BOLD}${CYAN}║ Écosystème saillant.cc — Health Check ║${NC}"
|
||||
echo -e "${BOLD}${CYAN}║ $(date '+%Y-%m-%d %H:%M:%S') ║${NC}"
|
||||
echo -e "${BOLD}${CYAN}╚══════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
echo -e "${BOLD}[Web Services]${NC}"
|
||||
for name in $(echo "${!SERVICES[@]}" | tr ' ' '\n' | sort); do
|
||||
if check_service "$name" "${SERVICES[$name]}"; then
|
||||
((ok++))
|
||||
else
|
||||
((fail++))
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
check_docker_containers
|
||||
echo ""
|
||||
|
||||
check_mistral_agents
|
||||
echo ""
|
||||
|
||||
echo -e "${BOLD}[Résumé]${NC}"
|
||||
local total=$((ok + fail))
|
||||
local pct=0
|
||||
[ "$total" -gt 0 ] && pct=$((ok * 100 / total))
|
||||
|
||||
if [ "$fail" -eq 0 ]; then
|
||||
echo -e " ${GREEN}✓ ALL HEALTHY${NC} — ${ok}/${total} services OK (${pct}%)"
|
||||
elif [ "$fail" -le 3 ]; then
|
||||
echo -e " ${YELLOW}⚠ DEGRADED${NC} — ${ok}/${total} services OK (${pct}%) | ${fail} down"
|
||||
else
|
||||
echo -e " ${RED}✗ CRITICAL${NC} — ${ok}/${total} services OK (${pct}%) | ${fail} down"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# JSON cockpit-v1 output
|
||||
cat <<EOF
|
||||
{
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "ecosystem-health",
|
||||
"action": "full-check",
|
||||
"status": "$([ "$fail" -eq 0 ] && echo "healthy" || ([ "$fail" -le 3 ] && echo "degraded" || echo "critical"))",
|
||||
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"services_ok": $ok,
|
||||
"services_fail": $fail,
|
||||
"services_total": $total,
|
||||
"uptime_pct": $pct
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
# --- Main ---
|
||||
case "${1:-}" in
|
||||
--json)
|
||||
action_full_health 2>/dev/null | grep -A 999 '{'
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--json] [--help]"
|
||||
echo ""
|
||||
echo " (default) Full health check with colored output"
|
||||
echo " --json Output cockpit-v1 JSON only"
|
||||
;;
|
||||
*)
|
||||
action_full_health
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
|
||||
source "${ROOT_DIR}/tools/cockpit/json_contract.sh"
|
||||
|
||||
AUDIT_DOC="${ROOT_DIR}/docs/KILL_LIFE_CONSOLIDATION_AUDIT_2026-03-21.md"
|
||||
SPEC_DOC="${ROOT_DIR}/specs/agentic_intelligence_integration_spec.md"
|
||||
FEATURE_DOC="${ROOT_DIR}/docs/AGENTIC_INTELLIGENCE_FEATURE_MAP_2026-03-21.md"
|
||||
PLAN_DOC="${ROOT_DIR}/docs/plans/22_plan_integration_intelligence_agentique.md"
|
||||
TODO_DOC="${ROOT_DIR}/docs/plans/22_todo_integration_intelligence_agentique.md"
|
||||
RESEARCH_DOC="${ROOT_DIR}/docs/WEB_RESEARCH_OPEN_SOURCE_2026-03-20.md"
|
||||
OWNERS_DOC="${ROOT_DIR}/docs/plans/12_plan_gestion_des_agents.md"
|
||||
GLOBAL_TASKS_DOC="${ROOT_DIR}/specs/04_tasks.md"
|
||||
|
||||
LOG_DIR="${ROOT_DIR}/artifacts/cockpit/intelligence_program_tui"
|
||||
mkdir -p "${LOG_DIR}"
|
||||
|
||||
ACTION=""
|
||||
JSON=0
|
||||
RETENTION_DAYS=7
|
||||
LINES=120
|
||||
LOG_FILE="${LOG_DIR}/intelligence_program_tui_$(date '+%Y%m%d_%H%M%S').log"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: bash tools/cockpit/intelligence_program_tui.sh [options]
|
||||
|
||||
Options:
|
||||
--action <status|audit|feature-map|spec|plan|todo|research|owners|logs-summary|logs-list|logs-latest|purge-logs>
|
||||
--json
|
||||
--days <N>
|
||||
--lines <N>
|
||||
-h, --help
|
||||
EOF
|
||||
}
|
||||
|
||||
have_tty() {
|
||||
[[ -t 0 && -t 1 ]]
|
||||
}
|
||||
|
||||
choose_action_interactive() {
|
||||
if command -v gum >/dev/null 2>&1 && have_tty; then
|
||||
gum choose \
|
||||
status \
|
||||
audit \
|
||||
feature-map \
|
||||
spec \
|
||||
plan \
|
||||
todo \
|
||||
research \
|
||||
owners \
|
||||
logs-summary \
|
||||
logs-list \
|
||||
logs-latest \
|
||||
purge-logs
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
log_line() {
|
||||
local level="$1"
|
||||
shift
|
||||
local msg="$*"
|
||||
printf '[%s] [%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S %z')" "${level}" "${msg}" | tee -a "${LOG_FILE}" >&2
|
||||
}
|
||||
|
||||
count_matching_lines() {
|
||||
local pattern="$1"
|
||||
local file="$2"
|
||||
rg -c "${pattern}" "${file}" 2>/dev/null || printf '0\n'
|
||||
}
|
||||
|
||||
show_file() {
|
||||
local file="$1"
|
||||
if [[ ! -f "${file}" ]]; then
|
||||
printf 'Missing file: %s\n' "${file}" >&2
|
||||
return 1
|
||||
fi
|
||||
sed -n "1,${LINES}p" "${file}"
|
||||
}
|
||||
|
||||
emit_status() {
|
||||
local status="ok"
|
||||
local contract_status="ok"
|
||||
local degraded_reasons=()
|
||||
local next_steps=(
|
||||
"bash tools/cockpit/intelligence_program_tui.sh --action plan"
|
||||
"bash tools/cockpit/intelligence_program_tui.sh --action todo"
|
||||
"bash tools/cockpit/intelligence_program_tui.sh --action research"
|
||||
)
|
||||
local open_todo_count completed_todo_count global_open_tasks
|
||||
|
||||
open_todo_count="$(count_matching_lines '^- \[ \]' "${TODO_DOC}")"
|
||||
completed_todo_count="$(count_matching_lines '^- \[x\]' "${TODO_DOC}")"
|
||||
global_open_tasks="$(count_matching_lines '^- \[ \]' "${GLOBAL_TASKS_DOC}")"
|
||||
|
||||
for required_file in "${AUDIT_DOC}" "${SPEC_DOC}" "${FEATURE_DOC}" "${PLAN_DOC}" "${TODO_DOC}" "${RESEARCH_DOC}" "${OWNERS_DOC}"; do
|
||||
if [[ ! -f "${required_file}" ]]; then
|
||||
status="degraded"
|
||||
degraded_reasons+=("missing:$(basename "${required_file}")")
|
||||
fi
|
||||
done
|
||||
|
||||
contract_status="$(json_contract_map_status "${status}")"
|
||||
|
||||
if [[ "${JSON}" -eq 1 ]]; then
|
||||
printf '{\n'
|
||||
printf ' "contract_version": "cockpit-v1",\n'
|
||||
printf ' "component": "intelligence_program_tui",\n'
|
||||
printf ' "contract_status": "%s",\n' "${contract_status}"
|
||||
printf ' "status": "%s",\n' "${status}"
|
||||
printf ' "action": "status",\n'
|
||||
printf ' "artifacts": %s,\n' "$(json_contract_array_from_args "${LOG_FILE}" "${AUDIT_DOC}" "${SPEC_DOC}" "${FEATURE_DOC}" "${PLAN_DOC}" "${TODO_DOC}")"
|
||||
printf ' "degraded_reasons": %s,\n' "$(json_contract_array_from_args "${degraded_reasons[@]}")"
|
||||
printf ' "next_steps": %s,\n' "$(json_contract_array_from_args "${next_steps[@]}")"
|
||||
printf ' "audit_doc": "%s",\n' "${AUDIT_DOC}"
|
||||
printf ' "spec_doc": "%s",\n' "${SPEC_DOC}"
|
||||
printf ' "feature_map_doc": "%s",\n' "${FEATURE_DOC}"
|
||||
printf ' "plan_doc": "%s",\n' "${PLAN_DOC}"
|
||||
printf ' "todo_doc": "%s",\n' "${TODO_DOC}"
|
||||
printf ' "research_doc": "%s",\n' "${RESEARCH_DOC}"
|
||||
printf ' "owners_doc": "%s",\n' "${OWNERS_DOC}"
|
||||
printf ' "open_todo_count": %s,\n' "${open_todo_count}"
|
||||
printf ' "completed_todo_count": %s,\n' "${completed_todo_count}"
|
||||
printf ' "global_open_tasks": %s,\n' "${global_open_tasks}"
|
||||
printf ' "log_file": "%s"\n' "${LOG_FILE}"
|
||||
printf '}\n'
|
||||
else
|
||||
printf 'Intelligence Program Status\n\n'
|
||||
printf 'status: %s\n' "${status}"
|
||||
printf 'audit: %s\n' "${AUDIT_DOC}"
|
||||
printf 'spec: %s\n' "${SPEC_DOC}"
|
||||
printf 'feature map: %s\n' "${FEATURE_DOC}"
|
||||
printf 'plan: %s\n' "${PLAN_DOC}"
|
||||
printf 'todo: %s\n' "${TODO_DOC}"
|
||||
printf 'research: %s\n' "${RESEARCH_DOC}"
|
||||
printf 'owners: %s\n' "${OWNERS_DOC}"
|
||||
printf 'todo open/completed: %s/%s\n' "${open_todo_count}" "${completed_todo_count}"
|
||||
printf 'global open tasks: %s\n' "${global_open_tasks}"
|
||||
printf 'log: %s\n' "${LOG_FILE}"
|
||||
fi
|
||||
}
|
||||
|
||||
list_logs() {
|
||||
find "${LOG_DIR}" -type f -name 'intelligence_program_tui_*.log' | sort
|
||||
}
|
||||
|
||||
latest_log() {
|
||||
find "${LOG_DIR}" -type f -name 'intelligence_program_tui_*.log' | sort | tail -n 1
|
||||
}
|
||||
|
||||
purge_logs() {
|
||||
find "${LOG_DIR}" -type f -name 'intelligence_program_tui_*.log' -mtime +"${RETENTION_DAYS}" -delete
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
JSON=1
|
||||
shift
|
||||
;;
|
||||
--days)
|
||||
RETENTION_DAYS="${2:-7}"
|
||||
shift 2
|
||||
;;
|
||||
--lines)
|
||||
LINES="${2:-120}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "${ACTION}" ]]; then
|
||||
if ACTION="$(choose_action_interactive)"; then
|
||||
:
|
||||
else
|
||||
ACTION="status"
|
||||
fi
|
||||
fi
|
||||
|
||||
case "${ACTION}" in
|
||||
status)
|
||||
log_line "INFO" "status"
|
||||
emit_status
|
||||
;;
|
||||
audit)
|
||||
log_line "INFO" "audit"
|
||||
show_file "${AUDIT_DOC}"
|
||||
;;
|
||||
feature-map)
|
||||
log_line "INFO" "feature-map"
|
||||
show_file "${FEATURE_DOC}"
|
||||
;;
|
||||
spec)
|
||||
log_line "INFO" "spec"
|
||||
show_file "${SPEC_DOC}"
|
||||
;;
|
||||
plan)
|
||||
log_line "INFO" "plan"
|
||||
show_file "${PLAN_DOC}"
|
||||
;;
|
||||
todo)
|
||||
log_line "INFO" "todo"
|
||||
show_file "${TODO_DOC}"
|
||||
;;
|
||||
research)
|
||||
log_line "INFO" "research"
|
||||
show_file "${RESEARCH_DOC}"
|
||||
;;
|
||||
owners)
|
||||
log_line "INFO" "owners"
|
||||
show_file "${OWNERS_DOC}"
|
||||
;;
|
||||
logs-summary)
|
||||
log_line "INFO" "logs-summary"
|
||||
printf 'log_dir=%s\n' "${LOG_DIR}"
|
||||
printf 'latest=%s\n' "$(latest_log)"
|
||||
printf 'count=%s\n' "$(find "${LOG_DIR}" -type f -name 'intelligence_program_tui_*.log' | wc -l | tr -d ' ')"
|
||||
;;
|
||||
logs-list)
|
||||
log_line "INFO" "logs-list"
|
||||
list_logs
|
||||
;;
|
||||
logs-latest)
|
||||
log_line "INFO" "logs-latest"
|
||||
latest="$(latest_log)"
|
||||
if [[ -n "${latest}" ]]; then
|
||||
cat "${latest}"
|
||||
fi
|
||||
;;
|
||||
purge-logs)
|
||||
log_line "INFO" "purge-logs days=${RETENTION_DAYS}"
|
||||
purge_logs
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown action: %s\n' "${ACTION}" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Compatibility wrapper kept for historical runbooks and aliases.
|
||||
exec bash "${SCRIPT_DIR}/intelligence_tui.sh" "$@"
|
||||
Executable
+1669
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
json_contract_escape() {
|
||||
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
}
|
||||
|
||||
json_contract_append_string() {
|
||||
local current="$1"
|
||||
local value="$2"
|
||||
local escaped=""
|
||||
escaped="$(json_contract_escape "${value}")"
|
||||
if [[ -z "${current}" ]]; then
|
||||
printf '"%s"' "${escaped}"
|
||||
else
|
||||
printf '%s,"%s"' "${current}" "${escaped}"
|
||||
fi
|
||||
}
|
||||
|
||||
json_contract_array_from_args() {
|
||||
local payload=""
|
||||
local value=""
|
||||
for value in "$@"; do
|
||||
[[ -n "${value}" ]] || continue
|
||||
payload="$(json_contract_append_string "${payload}" "${value}")"
|
||||
done
|
||||
printf '[%s]' "${payload}"
|
||||
}
|
||||
|
||||
json_contract_map_status() {
|
||||
case "${1:-unknown}" in
|
||||
ok|done|ready|success)
|
||||
printf 'ok'
|
||||
;;
|
||||
degraded|warn|warning|no-op|pending|running|skipped|unknown)
|
||||
printf 'degraded'
|
||||
;;
|
||||
blocked|ko|error|fail|failed|cancelled|cancel_unresolved)
|
||||
printf 'error'
|
||||
;;
|
||||
*)
|
||||
printf 'degraded'
|
||||
;;
|
||||
esac
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# kill_life_mistral_governance_sync.sh
|
||||
# Sync Kill_LIFE governance-only Mistral key to per-user secret files on mesh machines.
|
||||
# Contract: cockpit-v1
|
||||
# Date: 2026-03-22
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
ARTIFACTS_DIR="${ROOT_DIR}/artifacts/cockpit/kill_life_mistral_governance_sync"
|
||||
mkdir -p "${ARTIFACTS_DIR}"
|
||||
|
||||
ACTION="sync"
|
||||
SECRET_FILE="${SECRET_FILE:-${HOME}/.kill-life/mistral.env}"
|
||||
|
||||
HOST_LABELS=(
|
||||
"local"
|
||||
"clems"
|
||||
"root"
|
||||
"kxkm"
|
||||
"cils"
|
||||
)
|
||||
|
||||
HOST_TARGETS=(
|
||||
"local"
|
||||
"clems@192.168.0.120"
|
||||
"root@192.168.0.119"
|
||||
"kxkm@kxkm-ai"
|
||||
"cils@100.126.225.111"
|
||||
)
|
||||
|
||||
HOST_SECRET_FILES=(
|
||||
"${HOME}/.kill-life/mistral.env"
|
||||
"/home/clems/.kill-life/mistral.env"
|
||||
"/root/.kill-life/mistral.env"
|
||||
"/home/kxkm/.kill-life/mistral.env"
|
||||
"/Users/cils/.kill-life/mistral.env"
|
||||
)
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: kill_life_mistral_governance_sync.sh [--action sync|status] [--help]
|
||||
|
||||
Actions:
|
||||
sync Write/update MISTRAL_GOVERNANCE_API_KEY on all configured machines
|
||||
status Report presence of the secret file on all configured machines
|
||||
|
||||
Env vars:
|
||||
SECRET_FILE Local governance secret source (default: ~/.kill-life/mistral.env)
|
||||
EOF
|
||||
}
|
||||
|
||||
load_governance_key() {
|
||||
python3 - "$SECRET_FILE" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
secret_path = Path(sys.argv[1]).expanduser()
|
||||
if not secret_path.exists():
|
||||
raise SystemExit(f"missing secret file: {secret_path}")
|
||||
|
||||
value = None
|
||||
for line in secret_path.read_text(encoding="utf-8").splitlines():
|
||||
if line.startswith("MISTRAL_GOVERNANCE_API_KEY="):
|
||||
value = line.split("=", 1)[1]
|
||||
break
|
||||
|
||||
if not value:
|
||||
raise SystemExit("MISTRAL_GOVERNANCE_API_KEY missing in secret file")
|
||||
|
||||
print(value)
|
||||
PY
|
||||
}
|
||||
|
||||
remote_status() {
|
||||
local host="$1"
|
||||
local secret_path="$2"
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=8 "$host" python3 - "$secret_path" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
secret_path = Path(sys.argv[1])
|
||||
print(json.dumps({
|
||||
"status": "ok",
|
||||
"secret_path": str(secret_path),
|
||||
"exists": secret_path.exists(),
|
||||
}))
|
||||
PY
|
||||
}
|
||||
|
||||
remote_sync() {
|
||||
local host="$1"
|
||||
local secret_path="$2"
|
||||
local key_b64="$3"
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=8 "$host" python3 - "$secret_path" "$key_b64" <<'PY'
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
secret_path = Path(sys.argv[1])
|
||||
key = base64.b64decode(sys.argv[2]).decode("utf-8")
|
||||
|
||||
secret_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
secret_path.write_text(f"MISTRAL_GOVERNANCE_API_KEY={key}\n", encoding="utf-8")
|
||||
secret_path.chmod(0o600)
|
||||
|
||||
print(json.dumps({
|
||||
"status": "updated",
|
||||
"secret_path": str(secret_path),
|
||||
}))
|
||||
PY
|
||||
}
|
||||
|
||||
run_action() {
|
||||
local mode="$1"
|
||||
local stamp artifact_file latest_file now
|
||||
local key="" key_b64=""
|
||||
|
||||
if [[ "$mode" == "sync" ]]; then
|
||||
key="$(load_governance_key)"
|
||||
key_b64="$(printf '%s' "$key" | base64)"
|
||||
fi
|
||||
|
||||
stamp="$(date +%Y%m%d_%H%M%S)"
|
||||
artifact_file="${ARTIFACTS_DIR}/kill_life_mistral_governance_sync_${stamp}.json"
|
||||
latest_file="${ARTIFACTS_DIR}/latest.json"
|
||||
now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
python3 - "$mode" "$now" "$artifact_file" "$latest_file" "$key_b64" "${HOST_LABELS[@]}" -- "${HOST_TARGETS[@]}" -- "${HOST_SECRET_FILES[@]}" <<'PY'
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
mode = sys.argv[1]
|
||||
timestamp = sys.argv[2]
|
||||
artifact_file = sys.argv[3]
|
||||
latest_file = sys.argv[4]
|
||||
key_b64 = sys.argv[5]
|
||||
args = sys.argv[6:]
|
||||
sep1 = args.index("--")
|
||||
labels = args[:sep1]
|
||||
args = args[sep1 + 1 :]
|
||||
sep2 = args.index("--")
|
||||
targets = args[:sep2]
|
||||
secret_files = args[sep2 + 1 :]
|
||||
|
||||
results = []
|
||||
|
||||
for label, target, secret_path in zip(labels, targets, secret_files):
|
||||
if target == "local":
|
||||
if mode == "status":
|
||||
proc = subprocess.run(
|
||||
["python3", "-", secret_path],
|
||||
input=(
|
||||
"import json,sys\n"
|
||||
"from pathlib import Path\n"
|
||||
"p=Path(sys.argv[1]).expanduser()\n"
|
||||
"print(json.dumps({'status':'ok','secret_path':str(p),'exists':p.exists()}))\n"
|
||||
),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
else:
|
||||
proc = subprocess.run(
|
||||
["python3", "-", secret_path, key_b64],
|
||||
input=(
|
||||
"import base64,json,sys\n"
|
||||
"from pathlib import Path\n"
|
||||
"p=Path(sys.argv[1]).expanduser(); key=base64.b64decode(sys.argv[2]).decode('utf-8')\n"
|
||||
"p.parent.mkdir(parents=True, exist_ok=True)\n"
|
||||
"p.write_text(f'MISTRAL_GOVERNANCE_API_KEY={key}\\n', encoding='utf-8')\n"
|
||||
"p.chmod(0o600)\n"
|
||||
"print(json.dumps({'status':'updated','secret_path':str(p)}))\n"
|
||||
),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
else:
|
||||
subcmd = "__remote_status__" if mode == "status" else "__remote_sync__"
|
||||
argv = [
|
||||
"/Users/electron/Documents/Lelectron_rare/Kill_LIFE/tools/cockpit/kill_life_mistral_governance_sync.sh",
|
||||
subcmd,
|
||||
target,
|
||||
secret_path,
|
||||
]
|
||||
if mode == "sync":
|
||||
argv.append(key_b64)
|
||||
proc = subprocess.run(argv, capture_output=True, text=True)
|
||||
|
||||
record = {"label": label, "host": target, "returncode": proc.returncode}
|
||||
stdout = (proc.stdout or "").strip()
|
||||
stderr = (proc.stderr or "").strip()
|
||||
if stdout:
|
||||
try:
|
||||
record.update(json.loads(stdout.splitlines()[-1]))
|
||||
except Exception:
|
||||
record["status"] = "parse-error"
|
||||
record["stdout"] = stdout
|
||||
if stderr:
|
||||
record["stderr"] = stderr
|
||||
if "status" not in record:
|
||||
record["status"] = "ssh-error" if proc.returncode else "empty"
|
||||
results.append(record)
|
||||
|
||||
summary = {
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "kill-life-mistral-governance-sync",
|
||||
"action": mode,
|
||||
"timestamp": timestamp,
|
||||
"results": results,
|
||||
}
|
||||
summary["ok"] = sum(1 for item in results if item.get("status") in {"ok", "updated"})
|
||||
summary["errors"] = sum(1 for item in results if item.get("status") not in {"ok", "updated"})
|
||||
|
||||
payload = json.dumps(summary, indent=2) + "\n"
|
||||
with open(artifact_file, "w", encoding="utf-8") as handle:
|
||||
handle.write(payload)
|
||||
with open(latest_file, "w", encoding="utf-8") as handle:
|
||||
handle.write(payload)
|
||||
print(payload, end="")
|
||||
PY
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "__remote_status__" ]]; then
|
||||
shift
|
||||
remote_status "$@"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "__remote_sync__" ]]; then
|
||||
shift
|
||||
remote_sync "$@"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$ACTION" in
|
||||
sync|status)
|
||||
run_action "$ACTION"
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown action: %s\n' "$ACTION" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load optional Kill_LIFE local Mistral governance secrets without touching repo .env files.
|
||||
|
||||
MISTRAL_GOVERNANCE_ENV_FILE="${KILL_LIFE_MISTRAL_ENV_FILE:-${HOME}/.kill-life/mistral.env}"
|
||||
|
||||
if [[ -f "${MISTRAL_GOVERNANCE_ENV_FILE}" ]]; then
|
||||
# shellcheck disable=SC1090
|
||||
. "${MISTRAL_GOVERNANCE_ENV_FILE}"
|
||||
fi
|
||||
Executable
+187
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source "${ROOT_DIR}/tools/cockpit/json_contract.sh"
|
||||
ARTIFACT_DIR="$ROOT_DIR/artifacts/cockpit"
|
||||
LOG_DIR="$ARTIFACT_DIR/log_ops"
|
||||
mkdir -p "$LOG_DIR"
|
||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
RUN_LOG="$LOG_DIR/log_ops-$STAMP.log"
|
||||
RETENTION_DAYS="${RETENTION_DAYS:-7}"
|
||||
ACTION="summary"
|
||||
COMPONENT="log_ops"
|
||||
JSON=0
|
||||
APPLY=0
|
||||
TARGET_DIRS=(
|
||||
"$ROOT_DIR/artifacts"
|
||||
"$ROOT_DIR/logs"
|
||||
"$ROOT_DIR/tmp"
|
||||
)
|
||||
|
||||
if [ -n "${LOG_OPS_TARGET_DIRS:-}" ]; then
|
||||
IFS=':' read -r -a TARGET_DIRS <<< "${LOG_OPS_TARGET_DIRS}"
|
||||
fi
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage: bash tools/cockpit/log_ops.sh [--action summary|purge|list] [--json] [--apply] [--retention-days N]
|
||||
|
||||
Actions:
|
||||
summary Summarize known logs and age buckets.
|
||||
list Emit the file list considered by the tool.
|
||||
purge Dry-run by default; delete logs older than retention only with --apply.
|
||||
USAGE
|
||||
}
|
||||
|
||||
log() {
|
||||
local level="$1"
|
||||
shift
|
||||
local msg="$*"
|
||||
printf '[%s] [%s] %s\n' "$(date +%H:%M:%S)" "$level" "$msg" | tee -a "$RUN_LOG" >&2
|
||||
}
|
||||
|
||||
collect_logs() {
|
||||
local dir
|
||||
for dir in "${TARGET_DIRS[@]}"; do
|
||||
[ -d "$dir" ] || continue
|
||||
find "$dir" -type f \( -name '*.log' -o -name '*.out' -o -name '*.err' -o -name '*.jsonl' \) -print
|
||||
done | sort -u
|
||||
}
|
||||
|
||||
main() {
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
JSON=1
|
||||
shift
|
||||
;;
|
||||
--apply)
|
||||
APPLY=1
|
||||
shift
|
||||
;;
|
||||
--retention-days)
|
||||
RETENTION_DAYS="${2:-7}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown arg: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
local files
|
||||
files="$(collect_logs || true)"
|
||||
local count=0
|
||||
local stale=0
|
||||
local bytes=0
|
||||
local listed_json=""
|
||||
local purged_json=""
|
||||
local purged_count=0
|
||||
local status="done"
|
||||
local contract_status="ok"
|
||||
local artifacts=("${RUN_LOG}")
|
||||
local degraded_reasons=()
|
||||
local next_steps=()
|
||||
|
||||
if [ -n "$files" ]; then
|
||||
while IFS= read -r file; do
|
||||
[ -n "$file" ] || continue
|
||||
count=$((count + 1))
|
||||
local size
|
||||
size="$(wc -c < "$file" | tr -d ' ')"
|
||||
bytes=$((bytes + size))
|
||||
listed_json="$(json_contract_append_string "$listed_json" "$file")"
|
||||
if find "$file" -mtime +"$RETENTION_DAYS" -print -quit | grep -q .; then
|
||||
stale=$((stale + 1))
|
||||
if [ "$ACTION" = "purge" ] && [ "$APPLY" -eq 1 ]; then
|
||||
rm -f "$file"
|
||||
purged_json="$(json_contract_append_string "$purged_json" "$file")"
|
||||
purged_count=$((purged_count + 1))
|
||||
fi
|
||||
fi
|
||||
done <<EOF_INNER
|
||||
$files
|
||||
EOF_INNER
|
||||
fi
|
||||
|
||||
case "$ACTION" in
|
||||
summary)
|
||||
if [ "$stale" -gt 0 ]; then
|
||||
status="degraded"
|
||||
degraded_reasons+=("stale-logs-detected")
|
||||
next_steps+=("bash tools/cockpit/log_ops.sh --action purge --retention-days ${RETENTION_DAYS} --apply --json")
|
||||
log WARN "$stale stale log(s) older than $RETENTION_DAYS day(s) detected"
|
||||
else
|
||||
log INFO "No stale logs detected"
|
||||
fi
|
||||
log INFO "Count=$count Bytes=$bytes"
|
||||
;;
|
||||
list)
|
||||
log INFO "Listing $count candidate log(s)"
|
||||
;;
|
||||
purge)
|
||||
if [ "$APPLY" -eq 1 ]; then
|
||||
log INFO "Purged ${purged_count} stale log(s)"
|
||||
else
|
||||
log WARN "Dry-run purge only; re-run with --apply to delete stale logs"
|
||||
if [ "$stale" -gt 0 ]; then
|
||||
status="degraded"
|
||||
degraded_reasons+=("dry-run-purge-pending")
|
||||
next_steps+=("bash tools/cockpit/log_ops.sh --action purge --retention-days ${RETENTION_DAYS} --apply --json")
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
log ERROR "Unsupported action: $ACTION"
|
||||
status="blocked"
|
||||
degraded_reasons+=("unsupported-action")
|
||||
next_steps+=("bash tools/cockpit/log_ops.sh --help")
|
||||
;;
|
||||
esac
|
||||
|
||||
contract_status="$(json_contract_map_status "${status}")"
|
||||
|
||||
if [ "$JSON" -eq 1 ]; then
|
||||
printf '{\n'
|
||||
printf ' "contract_version": "cockpit-v1",\n'
|
||||
printf ' "component": "%s",\n' "${COMPONENT}"
|
||||
printf ' "contract_status": "%s",\n' "${contract_status}"
|
||||
printf ' "generated_at": "%s",\n' "$(date '+%Y-%m-%d %H:%M:%S %z')"
|
||||
printf ' "log_file": "%s",\n' "${RUN_LOG}"
|
||||
printf ' "artifacts": %s,\n' "$(json_contract_array_from_args "${artifacts[@]}")"
|
||||
printf ' "degraded_reasons": %s,\n' "$(json_contract_array_from_args "${degraded_reasons[@]}")"
|
||||
printf ' "next_steps": %s,\n' "$(json_contract_array_from_args "${next_steps[@]}")"
|
||||
printf ' "status": "%s",\n' "$status"
|
||||
printf ' "action": "%s",\n' "$ACTION"
|
||||
printf ' "retention_days": %s,\n' "$RETENTION_DAYS"
|
||||
printf ' "count": %s,\n' "$count"
|
||||
printf ' "stale": %s,\n' "$stale"
|
||||
printf ' "purged_count": %s,\n' "$purged_count"
|
||||
printf ' "bytes": %s,\n' "$bytes"
|
||||
printf ' "apply": %s,\n' "$APPLY"
|
||||
printf ' "files": [%s],\n' "$listed_json"
|
||||
printf ' "purged": [%s]\n' "$purged_json"
|
||||
printf '}\n'
|
||||
else
|
||||
printf 'status=%s action=%s count=%s stale=%s bytes=%s retention_days=%s apply=%s\n' \
|
||||
"$status" "$ACTION" "$count" "$stale" "$bytes" "$RETENTION_DAYS" "$APPLY"
|
||||
fi
|
||||
|
||||
case "$status" in
|
||||
blocked) exit 1 ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
+105
-5
@@ -9,6 +9,10 @@ TASKS_FILE="${ROOT_DIR}/specs/04_tasks.md"
|
||||
STATUS_FILE="${ROOT_DIR}/artifacts/cockpit/useful_lots_status.md"
|
||||
QUESTION_FILE="${ROOT_DIR}/artifacts/cockpit/next_question.md"
|
||||
STATE_FILE="${ROOT_DIR}/artifacts/cockpit/last_validation.env"
|
||||
INTELLIGENCE_MEMORY_FILE="${ROOT_DIR}/artifacts/cockpit/intelligence_program/latest.json"
|
||||
INTELLIGENCE_MEMORY_MD="${ROOT_DIR}/artifacts/cockpit/intelligence_program/latest.md"
|
||||
KILL_LIFE_MEMORY_FILE="${ROOT_DIR}/artifacts/cockpit/kill_life_memory/latest.json"
|
||||
KILL_LIFE_MEMORY_MD="${ROOT_DIR}/artifacts/cockpit/kill_life_memory/latest.md"
|
||||
|
||||
COMMAND=""
|
||||
VERBOSE=0
|
||||
@@ -22,8 +26,12 @@ UPSTREAM_STATUS="unknown"
|
||||
UPSTREAM_MODE="status"
|
||||
NEXT_LOT="pending"
|
||||
QUESTION_COUNT=0
|
||||
INTELLIGENCE_STATUS="unknown"
|
||||
INTELLIGENCE_CONTRACT_STATUS="unknown"
|
||||
INTELLIGENCE_OPEN_TASK_COUNT=0
|
||||
|
||||
QUESTION_ITEMS=()
|
||||
INTELLIGENCE_NEXT_STEPS=()
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
@@ -155,6 +163,46 @@ refresh_upstream_autonomous_lane() {
|
||||
fi
|
||||
}
|
||||
|
||||
refresh_intelligence_lane() {
|
||||
local tmp_json
|
||||
local parsed=()
|
||||
|
||||
tmp_json="$(mktemp)"
|
||||
if bash "${ROOT_DIR}/tools/cockpit/intelligence_tui.sh" --action memory --json >"${tmp_json}" 2>/dev/null; then
|
||||
mapfile -t parsed < <(
|
||||
python3 - "${tmp_json}" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
|
||||
print(payload.get("status", "unknown"))
|
||||
print(payload.get("contract_status", "unknown"))
|
||||
print(payload.get("open_task_count", 0))
|
||||
for step in payload.get("next_steps", [])[:3]:
|
||||
print(step)
|
||||
PY
|
||||
)
|
||||
INTELLIGENCE_STATUS="${parsed[0]:-unknown}"
|
||||
INTELLIGENCE_CONTRACT_STATUS="${parsed[1]:-unknown}"
|
||||
INTELLIGENCE_OPEN_TASK_COUNT="${parsed[2]:-0}"
|
||||
INTELLIGENCE_NEXT_STEPS=()
|
||||
if [[ "${#parsed[@]}" -gt 3 ]]; then
|
||||
INTELLIGENCE_NEXT_STEPS=("${parsed[@]:3}")
|
||||
fi
|
||||
else
|
||||
INTELLIGENCE_STATUS="failed"
|
||||
INTELLIGENCE_CONTRACT_STATUS="error"
|
||||
INTELLIGENCE_OPEN_TASK_COUNT=0
|
||||
INTELLIGENCE_NEXT_STEPS=("Refresh intelligence memory with \`bash tools/cockpit/intelligence_tui.sh --action memory --json\`.")
|
||||
fi
|
||||
rm -f "${tmp_json}"
|
||||
if [[ ! -f "${KILL_LIFE_MEMORY_FILE}" || ! -f "${KILL_LIFE_MEMORY_MD}" ]]; then
|
||||
INTELLIGENCE_NEXT_STEPS+=("Refresh kill_life continuity with \`bash tools/cockpit/write_kill_life_memory_entry.sh --component lot_chain --json\`.")
|
||||
fi
|
||||
}
|
||||
|
||||
collect_manual_questions() {
|
||||
local file match line_no line_text clean_text item_id recommended recommended_text
|
||||
|
||||
@@ -162,12 +210,12 @@ collect_manual_questions() {
|
||||
|
||||
while IFS= read -r file; do
|
||||
[[ -n "${file}" ]] || continue
|
||||
match="$(rg -n '^- \[ \]' "${ROOT_DIR}/${file}" | head -n 1 || true)"
|
||||
match="$(rg -n '^[[:space:]]*-[[:space:]]*\[ \]' "${ROOT_DIR}/${file}" | head -n 1 || true)"
|
||||
if [[ -n "${match}" ]]; then
|
||||
line_no="${match%%:*}"
|
||||
line_text="${match#*:}"
|
||||
clean_text="$(printf '%s\n' "${line_text}" | sed -E 's/^- \[ \] //')"
|
||||
item_id="$(printf '%s\n' "${line_text}" | sed -E 's/^- \[ \] ([A-Z]-[0-9]+).*/\1/' )"
|
||||
clean_text="$(printf '%s\n' "${line_text}" | sed -E 's/^[[:space:]]*-[[:space:]]*\[ \][[:space:]]*//')"
|
||||
item_id="$(printf '%s\n' "${line_text}" | sed -E 's/^[[:space:]]*-[[:space:]]*\[ \][[:space:]]*([A-Z][A-Z-]*-[0-9]+).*/\1/' )"
|
||||
if [[ "${item_id}" == "${line_text}" ]]; then
|
||||
item_id="$(printf '%s\n' "${file}" | sed 's#specs/##; s#\.md##')"
|
||||
fi
|
||||
@@ -177,6 +225,7 @@ collect_manual_questions() {
|
||||
QUESTION_ITEMS+=("${item_id}|${file}|${line_no}|${clean_text}")
|
||||
fi
|
||||
done <<'EOF'
|
||||
specs/04_tasks.md
|
||||
specs/mcp_tasks.md
|
||||
specs/zeroclaw_dual_hw_todo.md
|
||||
EOF
|
||||
@@ -193,11 +242,28 @@ EOF
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "${QUESTION_COUNT}" -eq 1 ]]; then
|
||||
NEXT_LOT="selected"
|
||||
IFS='|' read -r item_id file line_no line_text <<< "${QUESTION_ITEMS[0]}"
|
||||
{
|
||||
printf '# Next manual lot\n\n'
|
||||
printf 'Only one curated manual backlog item remains open, so it becomes the selected next active plan.\n\n'
|
||||
printf '## Selected lot\n\n'
|
||||
printf -- '- `%s` in `%s:%s` — %s\n' "${item_id}" "${file}" "${line_no}" "${line_text}"
|
||||
} > "${QUESTION_FILE}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
NEXT_LOT="question"
|
||||
recommended=""
|
||||
recommended_text=""
|
||||
for item in "${QUESTION_ITEMS[@]}"; do
|
||||
IFS='|' read -r item_id file line_no line_text <<< "${item}"
|
||||
if [[ "${file}" == "specs/04_tasks.md" && "${item_id}" == "T-RE-204" ]]; then
|
||||
recommended="${item_id}"
|
||||
recommended_text="${line_text}"
|
||||
break
|
||||
fi
|
||||
if [[ "${file}" == "specs/zeroclaw_dual_hw_todo.md" ]]; then
|
||||
recommended="${item_id}"
|
||||
recommended_text="${line_text}"
|
||||
@@ -225,11 +291,11 @@ item_is_optional() {
|
||||
local item_id="$2"
|
||||
|
||||
awk -v item="${item_id}" '
|
||||
$0 ~ "^- \\[ \\] " item {
|
||||
$0 ~ "^[[:space:]]*- \\[ \\][[:space:]]*" item {
|
||||
in_item = 1
|
||||
next
|
||||
}
|
||||
in_item && $0 ~ "^- \\[[ x]\\] [A-Z]-[0-9]+" {
|
||||
in_item && $0 ~ "^[[:space:]]*- \\[[ x]\\][[:space:]]*[A-Z][A-Z-]*-[0-9]+" {
|
||||
exit
|
||||
}
|
||||
in_item {
|
||||
@@ -280,6 +346,23 @@ write_status_report() {
|
||||
printf '\n'
|
||||
printf -- ' - Command: `bash tools/test_python.sh --suite stable`\n\n'
|
||||
|
||||
printf '## Intelligence governance\n\n'
|
||||
printf -- '- Intelligence memory: `%s`\n' "${INTELLIGENCE_STATUS}"
|
||||
printf -- ' - Command: `bash tools/cockpit/intelligence_tui.sh --action memory --json`\n'
|
||||
printf -- ' - Evidence: `%s`\n' "${INTELLIGENCE_MEMORY_FILE#${ROOT_DIR}/}"
|
||||
printf -- ' - Markdown: `%s`\n' "${INTELLIGENCE_MEMORY_MD#${ROOT_DIR}/}"
|
||||
printf -- ' - kill_life continuity JSON: `%s`\n' "${KILL_LIFE_MEMORY_FILE#${ROOT_DIR}/}"
|
||||
printf -- ' - kill_life continuity Markdown: `%s`\n' "${KILL_LIFE_MEMORY_MD#${ROOT_DIR}/}"
|
||||
printf -- ' - Open tasks: `%s`\n' "${INTELLIGENCE_OPEN_TASK_COUNT}"
|
||||
if [[ "${#INTELLIGENCE_NEXT_STEPS[@]}" -gt 0 ]]; then
|
||||
printf -- ' - Next actions:\n'
|
||||
local step
|
||||
for step in "${INTELLIGENCE_NEXT_STEPS[@]}"; do
|
||||
printf ' - %s\n' "${step}"
|
||||
done
|
||||
fi
|
||||
printf '\n'
|
||||
|
||||
printf '## Next step\n\n'
|
||||
if [[ "${NEXT_LOT}" == "question" ]]; then
|
||||
printf -- '- Manual choice required. See `%s`.\n' "${QUESTION_FILE#${ROOT_DIR}/}"
|
||||
@@ -311,6 +394,8 @@ update_plan_files() {
|
||||
printf -- '- MCP/CAD runtime lane sync: `%s`\n' "${UPSTREAM_STATUS}"
|
||||
printf -- '- Strict spec contract: `%s`\n' "${STRICT_STATUS}"
|
||||
printf -- '- Stable Python suite: `%s`\n' "${PYTHON_STATUS}"
|
||||
printf -- '- Intelligence governance memory: `%s` (`%s` open tasks)\n' "${INTELLIGENCE_STATUS}" "${INTELLIGENCE_OPEN_TASK_COUNT}"
|
||||
printf -- '- kill_life continuity memory: `%s`\n' "${KILL_LIFE_MEMORY_FILE#${ROOT_DIR}/}"
|
||||
if [[ "${NEXT_LOT}" == "question" ]]; then
|
||||
printf -- '- Next real need: ask the operator to choose the next manual lot from `%s`.\n' "${QUESTION_FILE#${ROOT_DIR}/}"
|
||||
elif [[ "${NEXT_LOT}" == "none" ]]; then
|
||||
@@ -356,6 +441,20 @@ update_plan_files() {
|
||||
fi
|
||||
printf -- ' - Evidence: `bash tools/test_python.sh --suite stable`\n'
|
||||
|
||||
if [[ "${INTELLIGENCE_CONTRACT_STATUS}" == "error" ]]; then
|
||||
printf -- '- [ ] T-LC-007 - Keep the intelligence governance memory refreshed for automation surfaces.\n'
|
||||
else
|
||||
printf -- '- [x] T-LC-007 - Keep the intelligence governance memory refreshed for automation surfaces.\n'
|
||||
fi
|
||||
printf -- ' - Evidence: `artifacts/cockpit/intelligence_program/latest.json`\n'
|
||||
|
||||
if [[ -f "${KILL_LIFE_MEMORY_FILE}" ]]; then
|
||||
printf -- '- [x] T-LC-008 - Keep the kill_life continuity memory visible from the chained cockpit entrypoints.\n'
|
||||
else
|
||||
printf -- '- [ ] T-LC-008 - Keep the kill_life continuity memory visible from the chained cockpit entrypoints.\n'
|
||||
fi
|
||||
printf -- ' - Evidence: `artifacts/cockpit/kill_life_memory/latest.json`\n'
|
||||
|
||||
if [[ "${NEXT_LOT}" == "question" ]]; then
|
||||
printf -- '- [ ] T-LC-006 - Choose the next manual lot once automation reaches a real fork.\n'
|
||||
printf -- ' - Evidence: `%s`\n' "${QUESTION_FILE#${ROOT_DIR}/}"
|
||||
@@ -433,6 +532,7 @@ refresh_state() {
|
||||
load_validation_state
|
||||
refresh_auto_lot_status
|
||||
refresh_upstream_autonomous_lane "${UPSTREAM_MODE}"
|
||||
refresh_intelligence_lane
|
||||
collect_manual_questions
|
||||
write_status_report
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
|
||||
source "${ROOT_DIR}/tools/cockpit/json_contract.sh"
|
||||
REGISTRY_FILE="${ROOT_DIR}/specs/contracts/machine_registry.mesh.json"
|
||||
LOG_DIR="${ROOT_DIR}/artifacts/cockpit"
|
||||
mkdir -p "${LOG_DIR}"
|
||||
|
||||
ACTION=""
|
||||
TARGET_ID=""
|
||||
JSON=0
|
||||
RETENTION_DAYS=14
|
||||
LOG_FILE=""
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: bash tools/cockpit/machine_registry.sh [options]
|
||||
|
||||
Options:
|
||||
--action <summary|list|show|clean-logs> Action to run
|
||||
--machine <id> Filter a single machine id
|
||||
--json Emit JSON when available
|
||||
--days <N> Retention for clean-logs (default: 14)
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
have_tty() {
|
||||
[[ -t 0 && -t 1 ]]
|
||||
}
|
||||
|
||||
choose_action_interactive() {
|
||||
if command -v gum >/dev/null 2>&1 && have_tty; then
|
||||
gum choose summary list show clean-logs
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
log_line() {
|
||||
local level="$1"
|
||||
shift
|
||||
local msg="${level} $(date '+%Y-%m-%d %H:%M:%S %z') ${*}"
|
||||
printf '%s\n' "${msg}" | tee -a "${LOG_FILE}" >&2
|
||||
}
|
||||
|
||||
require_registry() {
|
||||
if [[ ! -f "${REGISTRY_FILE}" ]]; then
|
||||
printf 'Missing registry file: %s\n' "${REGISTRY_FILE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
emit_summary() {
|
||||
python3 - "${REGISTRY_FILE}" "${JSON}" "${LOG_FILE}" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
registry_path = Path(sys.argv[1])
|
||||
json_mode = sys.argv[2] == "1"
|
||||
log_file = sys.argv[3]
|
||||
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
||||
targets = data.get("targets", [])
|
||||
reserve = [t["id"] for t in targets if t.get("reserve_only")]
|
||||
order = [t["id"] for t in sorted(targets, key=lambda item: item.get("priority", 999))]
|
||||
payload = {
|
||||
"contract_version": "cockpit-v1",
|
||||
"status": "ok",
|
||||
"contract_status": "ok",
|
||||
"component": "machine_registry",
|
||||
"action": "summary",
|
||||
"artifacts": [log_file, str(registry_path)],
|
||||
"degraded_reasons": [],
|
||||
"next_steps": [
|
||||
"bash tools/cockpit/machine_registry.sh --action list --json",
|
||||
"bash tools/cockpit/machine_registry.sh --action show --machine tower --json",
|
||||
],
|
||||
"registry_file": str(registry_path),
|
||||
"default_profile": data.get("default_profile"),
|
||||
"target_count": len(targets),
|
||||
"reserve_targets": reserve,
|
||||
"priority_order": order,
|
||||
}
|
||||
if json_mode:
|
||||
print(json.dumps(payload, ensure_ascii=False))
|
||||
else:
|
||||
print("# Machine registry summary\n")
|
||||
print(f"- registry: {payload['registry_file']}")
|
||||
print(f"- default_profile: {payload['default_profile']}")
|
||||
print(f"- target_count: {payload['target_count']}")
|
||||
print(f"- reserve_targets: {', '.join(reserve) if reserve else 'none'}")
|
||||
print(f"- priority_order: {' -> '.join(order)}")
|
||||
PY
|
||||
}
|
||||
|
||||
emit_targets() {
|
||||
python3 - "${REGISTRY_FILE}" "${TARGET_ID}" "${JSON}" "${LOG_FILE}" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
registry_path = Path(sys.argv[1])
|
||||
target_id = sys.argv[2]
|
||||
json_mode = sys.argv[3] == "1"
|
||||
log_file = sys.argv[4]
|
||||
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
||||
targets = data.get("targets", [])
|
||||
if target_id:
|
||||
targets = [item for item in targets if item.get("id") == target_id]
|
||||
payload = {
|
||||
"contract_version": "cockpit-v1",
|
||||
"status": "ok",
|
||||
"contract_status": "ok",
|
||||
"component": "machine_registry",
|
||||
"action": "show" if target_id else "list",
|
||||
"artifacts": [log_file, str(registry_path)],
|
||||
"degraded_reasons": [],
|
||||
"next_steps": [
|
||||
"bash tools/cockpit/machine_registry.sh --action summary --json",
|
||||
],
|
||||
"targets": targets,
|
||||
}
|
||||
if json_mode:
|
||||
print(json.dumps(payload, ensure_ascii=False))
|
||||
else:
|
||||
if not targets:
|
||||
print("no targets found")
|
||||
raise SystemExit(0)
|
||||
print("# Machine registry\n")
|
||||
for item in targets:
|
||||
print(f"- id: {item['id']}")
|
||||
print(f" target: {item['target']}")
|
||||
print(f" role: {item['role']}")
|
||||
print(f" priority: {item['priority']}")
|
||||
print(f" placement: {item['placement']}")
|
||||
print(f" profiles: {', '.join(item.get('enabled_profiles', []))}")
|
||||
print(f" reserve_only: {item.get('reserve_only')}")
|
||||
print(f" critical_repos: {', '.join(item.get('critical_repos', []))}")
|
||||
print(f" notes: {item.get('notes', '')}")
|
||||
PY
|
||||
}
|
||||
|
||||
emit_clean_logs() {
|
||||
local stale_files=()
|
||||
local pattern='machine_registry_*.log'
|
||||
while IFS= read -r file; do
|
||||
[[ -n "${file}" ]] || continue
|
||||
stale_files+=("${file}")
|
||||
done < <(find "${LOG_DIR}" -type f -name "${pattern}" -mtime +"${RETENTION_DAYS}" -print | sort)
|
||||
|
||||
local count="${#stale_files[@]}"
|
||||
if [[ "${count}" -gt 0 ]]; then
|
||||
find "${LOG_DIR}" -type f -name "${pattern}" -mtime +"${RETENTION_DAYS}" -delete
|
||||
fi
|
||||
|
||||
if [[ "${JSON}" -eq 1 ]]; then
|
||||
printf '{\n'
|
||||
printf ' "contract_version": "cockpit-v1",\n'
|
||||
printf ' "component": "machine_registry",\n'
|
||||
printf ' "contract_status": "ok",\n'
|
||||
printf ' "status": "ok",\n'
|
||||
printf ' "action": "clean-logs",\n'
|
||||
printf ' "artifacts": %s,\n' "$(json_contract_array_from_args "${LOG_FILE}" "${LOG_DIR}")"
|
||||
printf ' "degraded_reasons": [],\n'
|
||||
printf ' "next_steps": %s,\n' "$(json_contract_array_from_args "bash tools/cockpit/machine_registry.sh --action summary --json")"
|
||||
printf ' "retention_days": %s,\n' "${RETENTION_DAYS}"
|
||||
printf ' "deleted_count": %s,\n' "${count}"
|
||||
printf ' "log_dir": "%s"\n' "${LOG_DIR}"
|
||||
printf '}\n'
|
||||
else
|
||||
printf 'cleaned machine_registry logs older than %s days in %s (%s deleted)\n' "${RETENTION_DAYS}" "${LOG_DIR}" "${count}"
|
||||
fi
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--machine)
|
||||
TARGET_ID="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
JSON=1
|
||||
shift
|
||||
;;
|
||||
--days)
|
||||
RETENTION_DAYS="${2:-14}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "${ACTION}" ]]; then
|
||||
if ACTION="$(choose_action_interactive)"; then
|
||||
:
|
||||
else
|
||||
ACTION="summary"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! [[ "${RETENTION_DAYS}" =~ ^[0-9]+$ ]]; then
|
||||
printf -- '--days requires an integer\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
LOG_FILE="${LOG_DIR}/machine_registry_$(date '+%Y%m%d_%H%M%S').log"
|
||||
require_registry
|
||||
log_line "INFO" "action=${ACTION} target=${TARGET_ID:-all}"
|
||||
|
||||
case "${ACTION}" in
|
||||
summary)
|
||||
emit_summary
|
||||
;;
|
||||
list)
|
||||
emit_targets
|
||||
;;
|
||||
show)
|
||||
if [[ -z "${TARGET_ID}" ]]; then
|
||||
printf -- '--machine is required with --action show\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
emit_targets
|
||||
;;
|
||||
clean-logs)
|
||||
emit_clean_logs
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown action: %s\n' "${ACTION}" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Executable
+156
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
DISPATCH_FILE="${ROOT_DIR}/specs/contracts/mascarade_dispatch.mesh.json"
|
||||
REGISTRY_FILE="${ROOT_DIR}/specs/contracts/machine_registry.mesh.json"
|
||||
ACTION="summary"
|
||||
PROFILE_ID=""
|
||||
FAMILY_ID=""
|
||||
JSON_OUTPUT=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: bash tools/cockpit/mascarade_dispatch_mesh.sh [options]
|
||||
|
||||
Options:
|
||||
--action summary|route Default: summary
|
||||
--profile ID Profile id to route
|
||||
--family ID Explicit family override
|
||||
--dispatch-file FILE Override dispatch contract
|
||||
--registry-file FILE Override registry contract
|
||||
--json Emit JSON
|
||||
--help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--profile)
|
||||
PROFILE_ID="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--family)
|
||||
FAMILY_ID="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--dispatch-file)
|
||||
DISPATCH_FILE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--registry-file)
|
||||
REGISTRY_FILE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
JSON_OUTPUT=1
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "${ACTION}" != "summary" && "${ACTION}" != "route" ]]; then
|
||||
echo "Invalid --action: ${ACTION}" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
python3 - "${DISPATCH_FILE}" "${REGISTRY_FILE}" "${ACTION}" "${PROFILE_ID}" "${FAMILY_ID}" "${JSON_OUTPUT}" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
dispatch = json.loads(Path(sys.argv[1]).read_text())
|
||||
registry = json.loads(Path(sys.argv[2]).read_text())
|
||||
action = sys.argv[3]
|
||||
profile = sys.argv[4]
|
||||
family_override = sys.argv[5]
|
||||
json_output = sys.argv[6] == "1"
|
||||
|
||||
def registry_targets(data):
|
||||
if isinstance(data.get("targets"), list):
|
||||
return data["targets"]
|
||||
if isinstance(data.get("targets"), dict):
|
||||
return [{"id": key, **value} for key, value in data["targets"].items()]
|
||||
if isinstance(data.get("machines"), list):
|
||||
return data["machines"]
|
||||
if isinstance(data.get("hosts"), list):
|
||||
return data["hosts"]
|
||||
return []
|
||||
|
||||
targets = registry_targets(registry)
|
||||
target_map = {}
|
||||
for item in targets:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
ident = item.get("id") or item.get("name")
|
||||
if ident:
|
||||
target_map[ident] = item
|
||||
|
||||
default_order = dispatch.get("default_host_order", [])
|
||||
profile_overrides = dispatch.get("profile_overrides", {})
|
||||
family_defaults = dispatch.get("family_defaults", {})
|
||||
keyword_families = dispatch.get("keyword_families", {})
|
||||
|
||||
def infer_family(profile_name):
|
||||
profile_name = (profile_name or "").lower()
|
||||
for family_name, keywords in keyword_families.items():
|
||||
for keyword in keywords:
|
||||
if keyword in profile_name:
|
||||
return family_name
|
||||
return "interactive-safe"
|
||||
|
||||
def resolve_family(profile_name, family_name):
|
||||
if family_name:
|
||||
return family_name
|
||||
if profile_name in profile_overrides and profile_overrides[profile_name].get("family"):
|
||||
return profile_overrides[profile_name]["family"]
|
||||
return infer_family(profile_name)
|
||||
|
||||
family = resolve_family(profile, family_override)
|
||||
override = profile_overrides.get(profile, {})
|
||||
host_order = override.get("host_order") or family_defaults.get(family, {}).get("host_order") or default_order
|
||||
host_order = [host for host in host_order if host in target_map] + [host for host in default_order if host not in host_order and host in target_map]
|
||||
|
||||
selected_target = host_order[0] if host_order else None
|
||||
selected_meta = target_map.get(selected_target, {})
|
||||
|
||||
summary = {
|
||||
"status": "ok",
|
||||
"action": action,
|
||||
"profile": profile or None,
|
||||
"family": family,
|
||||
"dispatch_file": sys.argv[1],
|
||||
"registry_file": sys.argv[2],
|
||||
"default_host_order": default_order,
|
||||
"resolved_host_order": host_order,
|
||||
"selected_target": selected_target,
|
||||
"selected_host": selected_meta.get("host") or selected_meta.get("ssh_host"),
|
||||
"selected_priority": selected_meta.get("priority"),
|
||||
"selected_placement": selected_meta.get("placement"),
|
||||
"notes": family_defaults.get(family, {}).get("notes"),
|
||||
}
|
||||
|
||||
if json_output:
|
||||
print(json.dumps(summary, indent=2, ensure_ascii=True))
|
||||
else:
|
||||
print("Mascarade dispatch mesh")
|
||||
print(f"- action: {summary['action']}")
|
||||
print(f"- family: {summary['family']}")
|
||||
if summary["profile"]:
|
||||
print(f"- profile: {summary['profile']}")
|
||||
print(f"- route: {' -> '.join(summary['resolved_host_order'])}")
|
||||
print(f"- selected: {summary['selected_target']} ({summary['selected_host']})")
|
||||
PY
|
||||
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
COCKPIT_DIR="$ROOT_DIR/artifacts/cockpit"
|
||||
OPERATOR_DIR="$ROOT_DIR/artifacts/operator_lane"
|
||||
OUTPUT_MODE="text"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
bash tools/cockpit/mascarade_incident_registry.sh [--json]
|
||||
|
||||
Options:
|
||||
--json Emit cockpit-v1 JSON to stdout
|
||||
-h,--help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--json)
|
||||
OUTPUT_MODE="json"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir -p "$COCKPIT_DIR" "$OPERATOR_DIR"
|
||||
|
||||
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
OUT_MD="$COCKPIT_DIR/mascarade_incident_registry_${RUN_ID}.md"
|
||||
OUT_JSON="$COCKPIT_DIR/mascarade_incident_registry_${RUN_ID}.json"
|
||||
|
||||
python3 - "$COCKPIT_DIR" "$OPERATOR_DIR" "$OUT_MD" "$OUT_JSON" <<'PY'
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
cockpit_dir = Path(sys.argv[1])
|
||||
operator_dir = Path(sys.argv[2])
|
||||
out_md = Path(sys.argv[3])
|
||||
out_json = Path(sys.argv[4])
|
||||
memory_json = cockpit_dir / "kill_life_memory" / "latest.json"
|
||||
memory_md = cockpit_dir / "kill_life_memory" / "latest.md"
|
||||
|
||||
|
||||
def load_json(path: Path):
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def iter_files(directory: Path, prefix: str, suffix=".json", exclude=()):
|
||||
if not directory.exists():
|
||||
return []
|
||||
items = []
|
||||
for path in directory.iterdir():
|
||||
if not path.is_file():
|
||||
continue
|
||||
if not path.name.startswith(prefix) or not path.name.endswith(suffix):
|
||||
continue
|
||||
if any(token in path.name for token in exclude):
|
||||
continue
|
||||
items.append(path)
|
||||
return sorted(items, key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
|
||||
|
||||
brief_files = iter_files(cockpit_dir, "mascarade_incident_brief_", exclude=("latest",))
|
||||
operator_files = iter_files(operator_dir, "full_operator_lane_", exclude=("mascarade_health", "mascarade_logs"))
|
||||
memory_payload = load_json(memory_json) if memory_json.exists() else {}
|
||||
memory_entry = memory_payload.get("entry", {}) if isinstance(memory_payload.get("entry"), dict) else {}
|
||||
routing = memory_entry.get("routing", {}) if isinstance(memory_entry.get("routing"), dict) else {}
|
||||
resume_ref = memory_payload.get("resume_ref") or memory_entry.get("resume_ref", "")
|
||||
trust_level = memory_payload.get("trust_level") or memory_entry.get("trust_level", "inferred")
|
||||
|
||||
entries = []
|
||||
|
||||
for path in brief_files[:20]:
|
||||
data = load_json(path)
|
||||
entries.append({
|
||||
"ts": data.get("generated_at", ""),
|
||||
"source": "brief",
|
||||
"status": data.get("status", "unknown"),
|
||||
"path": str(path),
|
||||
"reasons": data.get("degraded_reasons", []),
|
||||
"next_steps": data.get("next_steps", []),
|
||||
})
|
||||
|
||||
for path in operator_files[:20]:
|
||||
data = load_json(path)
|
||||
status = data.get("status", "unknown")
|
||||
error = data.get("error", "")
|
||||
if status in {"ok", "ready", "done", "success"} and not error:
|
||||
continue
|
||||
entries.append({
|
||||
"ts": data.get("generated_at", ""),
|
||||
"source": "operator-lane",
|
||||
"status": status,
|
||||
"path": str(path),
|
||||
"reasons": [error] if error else [],
|
||||
"next_steps": [data.get("suggested_command", "")] if data.get("suggested_command") else [],
|
||||
})
|
||||
|
||||
def classify(entry):
|
||||
status = (entry.get("status") or "").lower()
|
||||
reasons = " ".join(entry.get("reasons", []))
|
||||
if status in {"failed", "error", "blocked", "ko"}:
|
||||
if "run-api-unreachable" in reasons or "poll-api-unreachable" in reasons:
|
||||
return ("high", "P1")
|
||||
if "status-api-unreachable" in reasons or "validate-api-unreachable" in reasons:
|
||||
return ("medium", "P2")
|
||||
return ("high", "P1")
|
||||
if status in {"degraded"}:
|
||||
return ("medium", "P2")
|
||||
return ("low", "P3")
|
||||
|
||||
entries.sort(key=lambda item: item.get("ts", ""), reverse=True)
|
||||
entries = entries[:25]
|
||||
for entry in entries:
|
||||
severity, priority = classify(entry)
|
||||
entry["severity"] = severity
|
||||
entry["priority"] = priority
|
||||
|
||||
overall = "ok" if not entries else "degraded"
|
||||
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
severity_counts = {"high": 0, "medium": 0, "low": 0}
|
||||
priority_counts = {"P1": 0, "P2": 0, "P3": 0}
|
||||
for entry in entries:
|
||||
severity_counts[entry["severity"]] = severity_counts.get(entry["severity"], 0) + 1
|
||||
priority_counts[entry["priority"]] = priority_counts.get(entry["priority"], 0) + 1
|
||||
|
||||
lines = [
|
||||
"# Mascarade incident registry",
|
||||
"",
|
||||
f"- generated_at: {generated_at}",
|
||||
f"- entry_count: {len(entries)}",
|
||||
f"- trust_level: {trust_level}",
|
||||
f"- resume_ref: {resume_ref or 'missing'}",
|
||||
f"- owner: {memory_entry.get('owner', 'unknown')}",
|
||||
f"- selected_target: {routing.get('selected_target', 'unknown')}",
|
||||
f"- kill_life_memory: {memory_md if memory_md.exists() else 'missing'}",
|
||||
"",
|
||||
"## Severity summary",
|
||||
"",
|
||||
"| Severity | Count | Priority |",
|
||||
"| --- | --- | --- |",
|
||||
f"| high | {severity_counts['high']} | P1 |",
|
||||
f"| medium | {severity_counts['medium']} | P2 |",
|
||||
f"| low | {severity_counts['low']} | P3 |",
|
||||
"",
|
||||
"## Incident table",
|
||||
"",
|
||||
"| Timestamp | Source | Status | Severity | Priority | Path | Reasons |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
|
||||
if entries:
|
||||
for entry in entries:
|
||||
reasons = ", ".join([r for r in entry.get("reasons", []) if r]) or "none"
|
||||
lines.append(f"| {entry.get('ts') or 'n/a'} | {entry['source']} | {entry['status']} | {entry['severity']} | {entry['priority']} | `{entry['path']}` | {reasons} |")
|
||||
else:
|
||||
lines.append("| n/a | registry | ok | low | P3 | `n/a` | none |")
|
||||
|
||||
lines.extend(["", "## Next steps", ""])
|
||||
if entries:
|
||||
seen = []
|
||||
for entry in entries:
|
||||
for step in entry.get("next_steps", []):
|
||||
if step and step not in seen:
|
||||
seen.append(step)
|
||||
lines.append(f"- {step}")
|
||||
if not seen:
|
||||
lines.append("- no explicit next step captured")
|
||||
else:
|
||||
lines.append("- no active incident captured")
|
||||
|
||||
out_md.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
shutil.copyfile(out_md, out_md.with_name("mascarade_incident_registry_latest.md"))
|
||||
|
||||
payload = {
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "mascarade-incident-registry",
|
||||
"action": "render",
|
||||
"status": overall,
|
||||
"contract_status": overall,
|
||||
"generated_at": generated_at,
|
||||
"owner": memory_entry.get("owner", "SyncOps"),
|
||||
"resume_ref": resume_ref,
|
||||
"trust_level": trust_level,
|
||||
"routing": routing,
|
||||
"memory_entry": memory_entry,
|
||||
"memory_markdown": str(memory_md) if memory_md.exists() else "",
|
||||
"entry_count": len(entries),
|
||||
"severity_counts": severity_counts,
|
||||
"priority_counts": priority_counts,
|
||||
"entries": entries,
|
||||
"markdown_file": str(out_md),
|
||||
"latest_markdown_file": str(out_md.with_name("mascarade_incident_registry_latest.md")),
|
||||
"artifacts": [
|
||||
str(out_md),
|
||||
str(out_md.with_name("mascarade_incident_registry_latest.md")),
|
||||
] + ([str(memory_json)] if memory_json.exists() else []),
|
||||
"degraded_reasons": [f"{entry['source']}-{entry['status']}" for entry in entries[:5]],
|
||||
"next_steps": [step for entry in entries for step in entry.get("next_steps", []) if step][:5],
|
||||
}
|
||||
|
||||
out_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
shutil.copyfile(out_json, out_json.with_name("mascarade_incident_registry_latest.json"))
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
PY
|
||||
|
||||
if [[ "$OUTPUT_MODE" == "json" ]]; then
|
||||
cat "$OUT_JSON"
|
||||
else
|
||||
cat <<EOF
|
||||
Mascarade incident registry
|
||||
markdown: $OUT_MD
|
||||
json: $OUT_JSON
|
||||
EOF
|
||||
fi
|
||||
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
COCKPIT_DIR="$ROOT_DIR/artifacts/cockpit"
|
||||
ACTION="summary"
|
||||
LINES=18
|
||||
JSON_OUTPUT=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
bash tools/cockpit/mascarade_incidents_tui.sh --action <summary|brief|registry|queue|daily|watch> [--lines N] [--json]
|
||||
|
||||
Options:
|
||||
--action <name> summary|brief|registry|queue|daily|watch
|
||||
--lines <int> Number of tail lines to display (default: 18)
|
||||
--json Emit cockpit-v1 JSON to stdout
|
||||
-h,--help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--lines)
|
||||
LINES="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
JSON_OUTPUT=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! "$ACTION" =~ ^(summary|brief|registry|queue|daily|watch)$ ]]; then
|
||||
echo "Invalid action: $ACTION" >&2
|
||||
exit 2
|
||||
fi
|
||||
if ! [[ "$LINES" =~ ^[0-9]+$ ]]; then
|
||||
echo "--lines must be an integer" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p "$COCKPIT_DIR"
|
||||
|
||||
python3 - "$COCKPIT_DIR" "$ACTION" "$LINES" "$JSON_OUTPUT" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
cockpit_dir = Path(sys.argv[1])
|
||||
action = sys.argv[2]
|
||||
lines = int(sys.argv[3])
|
||||
json_output = sys.argv[4] == "1"
|
||||
|
||||
paths = {
|
||||
"brief": cockpit_dir / "mascarade_incident_brief_latest.md",
|
||||
"registry": cockpit_dir / "mascarade_incident_registry_latest.md",
|
||||
"queue": cockpit_dir / "mascarade_incident_queue_latest.md",
|
||||
"daily": cockpit_dir / "daily_operator_summary_latest.md",
|
||||
}
|
||||
|
||||
json_paths = {
|
||||
"registry": cockpit_dir / "mascarade_incident_registry_latest.json",
|
||||
"queue": cockpit_dir / "mascarade_incident_queue_latest.json",
|
||||
"daily": cockpit_dir / "daily_operator_summary_latest.json",
|
||||
}
|
||||
memory_json = cockpit_dir / "kill_life_memory" / "latest.json"
|
||||
memory_markdown = cockpit_dir / "kill_life_memory" / "latest.md"
|
||||
|
||||
|
||||
def tail(path: Path, count: int):
|
||||
try:
|
||||
return path.read_text(encoding="utf-8", errors="replace").splitlines()[-count:]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def load_json(path: Path):
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def memory_context():
|
||||
payload = load_json(memory_json) if memory_json.exists() else {}
|
||||
entry = payload.get("entry", {}) if isinstance(payload.get("entry"), dict) else {}
|
||||
routing = entry.get("routing", {}) if isinstance(entry.get("routing"), dict) else {}
|
||||
return {
|
||||
"owner": entry.get("owner", ""),
|
||||
"resume_ref": payload.get("resume_ref") or entry.get("resume_ref", ""),
|
||||
"trust_level": payload.get("trust_level") or entry.get("trust_level", "inferred"),
|
||||
"routing": routing,
|
||||
"memory_entry": entry,
|
||||
"memory_artifact": str(memory_json) if memory_json.exists() else "",
|
||||
"memory_markdown": str(memory_markdown) if memory_markdown.exists() else "",
|
||||
}
|
||||
|
||||
|
||||
def summary_payload():
|
||||
entry = {}
|
||||
for key, path in paths.items():
|
||||
entry[key] = {
|
||||
"path": str(path),
|
||||
"exists": path.exists(),
|
||||
"tail": tail(path, min(lines, 10)),
|
||||
}
|
||||
memory = memory_context()
|
||||
artifacts = [item["path"] for item in entry.values() if item["exists"]]
|
||||
if memory["memory_artifact"]:
|
||||
artifacts.append(memory["memory_artifact"])
|
||||
status = "ok" if any(item["exists"] for item in entry.values()) else "degraded"
|
||||
return {
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "mascarade-incidents-tui",
|
||||
"action": "summary",
|
||||
"status": status,
|
||||
"contract_status": status,
|
||||
"owner": memory["owner"] or "SyncOps",
|
||||
"resume_ref": memory["resume_ref"],
|
||||
"trust_level": memory["trust_level"],
|
||||
"routing": memory["routing"],
|
||||
"memory_entry": memory["memory_entry"],
|
||||
"entries": entry,
|
||||
"artifacts": artifacts,
|
||||
"degraded_reasons": [] if status == "ok" else ["mascarade-incidents-missing"],
|
||||
"next_steps": [] if status == "ok" else ["Run the daily and incident rendering scripts to regenerate latest incident artifacts."],
|
||||
}
|
||||
|
||||
|
||||
def document_payload(name: str):
|
||||
path = paths[name]
|
||||
exists = path.exists()
|
||||
status = "ok" if exists else "degraded"
|
||||
memory = memory_context()
|
||||
artifacts = [str(path)] if exists else []
|
||||
if memory["memory_artifact"]:
|
||||
artifacts.append(memory["memory_artifact"])
|
||||
return {
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "mascarade-incidents-tui",
|
||||
"action": name,
|
||||
"status": status,
|
||||
"contract_status": status,
|
||||
"owner": memory["owner"] or "SyncOps",
|
||||
"resume_ref": memory["resume_ref"],
|
||||
"trust_level": memory["trust_level"],
|
||||
"routing": memory["routing"],
|
||||
"memory_entry": memory["memory_entry"],
|
||||
"path": str(path),
|
||||
"exists": exists,
|
||||
"tail": tail(path, lines) if exists else [],
|
||||
"artifacts": artifacts,
|
||||
"degraded_reasons": [] if exists else [f"missing-{name}-artifact"],
|
||||
"next_steps": [] if exists else [f"Regenerate the latest {name} artifact before review."],
|
||||
}
|
||||
|
||||
|
||||
def watch_payload():
|
||||
registry = load_json(json_paths["registry"])
|
||||
queue = load_json(json_paths["queue"])
|
||||
daily = load_json(json_paths["daily"])
|
||||
memory = memory_context()
|
||||
|
||||
priority_counts = registry.get("priority_counts", {}) if isinstance(registry.get("priority_counts"), dict) else {}
|
||||
severity_counts = registry.get("severity_counts", {}) if isinstance(registry.get("severity_counts"), dict) else {}
|
||||
entries = queue.get("entries", []) if isinstance(queue.get("entries"), list) else []
|
||||
|
||||
top_entries = []
|
||||
for entry in entries[:5]:
|
||||
top_entries.append({
|
||||
"priority": entry.get("priority", "P3"),
|
||||
"severity": entry.get("severity", "low"),
|
||||
"status": entry.get("status", "unknown"),
|
||||
"source": entry.get("source", "unknown"),
|
||||
"timestamp": entry.get("ts", ""),
|
||||
"reasons": entry.get("reasons", []),
|
||||
})
|
||||
|
||||
next_steps = []
|
||||
for candidate in (
|
||||
queue.get("next_steps", []),
|
||||
registry.get("next_steps", []),
|
||||
daily.get("next_steps", []),
|
||||
):
|
||||
if isinstance(candidate, list):
|
||||
for step in candidate:
|
||||
if step and step not in next_steps:
|
||||
next_steps.append(step)
|
||||
|
||||
status = "ok" if json_paths["queue"].exists() or json_paths["registry"].exists() else "degraded"
|
||||
artifacts = [str(path) for path in json_paths.values() if path.exists()]
|
||||
if memory["memory_artifact"]:
|
||||
artifacts.append(memory["memory_artifact"])
|
||||
return {
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "mascarade-incidents-tui",
|
||||
"action": "watch",
|
||||
"status": status,
|
||||
"contract_status": status,
|
||||
"owner": memory["owner"] or "SyncOps",
|
||||
"resume_ref": memory["resume_ref"],
|
||||
"trust_level": memory["trust_level"],
|
||||
"routing": memory["routing"],
|
||||
"memory_entry": memory["memory_entry"],
|
||||
"priority_counts": priority_counts,
|
||||
"severity_counts": severity_counts,
|
||||
"top_entries": top_entries,
|
||||
"artifacts": artifacts,
|
||||
"degraded_reasons": [] if status == "ok" else ["incident-watch-missing-artifacts"],
|
||||
"next_steps": next_steps[:5] if next_steps else ["Regenerate queue and registry artifacts before operator watch review."],
|
||||
}
|
||||
|
||||
|
||||
if action == "summary":
|
||||
payload = summary_payload()
|
||||
elif action == "watch":
|
||||
payload = watch_payload()
|
||||
else:
|
||||
payload = document_payload(action)
|
||||
|
||||
if json_output:
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
raise SystemExit(0)
|
||||
|
||||
if action == "summary":
|
||||
print("Mascarade incidents summary")
|
||||
print(f"trust_level: {payload.get('trust_level', 'inferred')}")
|
||||
print(f"resume_ref: {payload.get('resume_ref') or 'n/a'}")
|
||||
for key, item in payload["entries"].items():
|
||||
print(f"{key}: {'ok' if item['exists'] else 'missing'} -> {item['path']}")
|
||||
for line in item["tail"]:
|
||||
print(f" {line}")
|
||||
elif action == "watch":
|
||||
print("Mascarade incident watch")
|
||||
print(f"trust_level: {payload.get('trust_level', 'inferred')}")
|
||||
print(f"resume_ref: {payload.get('resume_ref') or 'n/a'}")
|
||||
routing = payload.get("routing", {}) if isinstance(payload.get("routing"), dict) else {}
|
||||
print(f"routing: {routing.get('selected_target', 'unknown')} -> {routing.get('selected_host', 'unknown')}")
|
||||
priority = payload.get("priority_counts", {})
|
||||
severity = payload.get("severity_counts", {})
|
||||
print(f"priority P1/P2/P3: {priority.get('P1', 0)}/{priority.get('P2', 0)}/{priority.get('P3', 0)}")
|
||||
print(f"severity high/medium/low: {severity.get('high', 0)}/{severity.get('medium', 0)}/{severity.get('low', 0)}")
|
||||
print("top queue:")
|
||||
for entry in payload.get("top_entries", []):
|
||||
reasons = ", ".join(entry.get("reasons", [])) if entry.get("reasons") else "none"
|
||||
print(f" - {entry.get('priority')} {entry.get('severity')} {entry.get('status')} {entry.get('source')} {entry.get('timestamp') or 'n/a'} :: {reasons}")
|
||||
if not payload.get("top_entries"):
|
||||
print(" - no queued incident")
|
||||
print("next steps:")
|
||||
for step in payload.get("next_steps", []):
|
||||
print(f" - {step}")
|
||||
else:
|
||||
print(f"Mascarade incidents view: {action}")
|
||||
print(f"trust_level: {payload.get('trust_level', 'inferred')}")
|
||||
print(f"resume_ref: {payload.get('resume_ref') or 'n/a'}")
|
||||
print(f"path: {payload['path']}")
|
||||
if payload["tail"]:
|
||||
for line in payload["tail"]:
|
||||
print(line)
|
||||
else:
|
||||
print("No content available.")
|
||||
PY
|
||||
Executable
+379
@@ -0,0 +1,379 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
RUNTIME_DIR="$ROOT_DIR/artifacts/ops/mascarade_runtime_health"
|
||||
OPERATOR_DIR="$ROOT_DIR/artifacts/operator_lane"
|
||||
ACTION="summary"
|
||||
RETENTION_DAYS=14
|
||||
LINES=20
|
||||
JSON_OUTPUT=0
|
||||
APPLY=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
bash tools/cockpit/mascarade_logs_tui.sh --action <summary|list|latest|purge> [options]
|
||||
|
||||
Options:
|
||||
--action <name> summary|list|latest|purge
|
||||
--days <int> Retention window for purge/list analysis (default: 14)
|
||||
--lines <int> Max entries or log lines to display (default: 20)
|
||||
--apply Apply purge instead of dry-run
|
||||
--json Emit cockpit-v1 JSON
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
[[ $# -ge 2 ]] || { echo "--action requires a value" >&2; exit 2; }
|
||||
ACTION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--days)
|
||||
[[ $# -ge 2 ]] || { echo "--days requires a value" >&2; exit 2; }
|
||||
RETENTION_DAYS="$2"
|
||||
shift 2
|
||||
;;
|
||||
--lines)
|
||||
[[ $# -ge 2 ]] || { echo "--lines requires a value" >&2; exit 2; }
|
||||
LINES="$2"
|
||||
shift 2
|
||||
;;
|
||||
--apply)
|
||||
APPLY=1
|
||||
shift
|
||||
;;
|
||||
--json)
|
||||
JSON_OUTPUT=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! "$ACTION" =~ ^(summary|list|latest|purge)$ ]]; then
|
||||
echo "Invalid action: $ACTION" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! [[ "$RETENTION_DAYS" =~ ^[0-9]+$ ]] || ! [[ "$LINES" =~ ^[0-9]+$ ]]; then
|
||||
echo "--days and --lines must be integers" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p "$RUNTIME_DIR" "$OPERATOR_DIR"
|
||||
|
||||
python3 - "$ACTION" "$ROOT_DIR" "$RUNTIME_DIR" "$OPERATOR_DIR" "$RETENTION_DAYS" "$LINES" "$JSON_OUTPUT" "$APPLY" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
action, root_dir, runtime_dir_raw, operator_dir_raw, retention_days_raw, lines_raw, json_output_raw, apply_raw = sys.argv[1:]
|
||||
root = Path(root_dir)
|
||||
runtime_dir = Path(runtime_dir_raw)
|
||||
operator_dir = Path(operator_dir_raw)
|
||||
retention_days = int(retention_days_raw)
|
||||
lines = int(lines_raw)
|
||||
json_output = json_output_raw == "1"
|
||||
apply = apply_raw == "1"
|
||||
now = time.time()
|
||||
cutoff = now - retention_days * 86400
|
||||
memory_json = root / "artifacts" / "cockpit" / "kill_life_memory" / "latest.json"
|
||||
memory_md = root / "artifacts" / "cockpit" / "kill_life_memory" / "latest.md"
|
||||
|
||||
|
||||
def read_json(path: Path):
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def memory_context():
|
||||
payload = read_json(memory_json) if memory_json.exists() else {}
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
entry = payload.get("entry", {}) if isinstance(payload.get("entry"), dict) else {}
|
||||
routing = entry.get("routing", {}) if isinstance(entry.get("routing"), dict) else {}
|
||||
return {
|
||||
"owner": entry.get("owner", "SyncOps"),
|
||||
"resume_ref": payload.get("resume_ref") or entry.get("resume_ref", ""),
|
||||
"trust_level": payload.get("trust_level") or entry.get("trust_level", "inferred"),
|
||||
"routing": routing,
|
||||
"memory_entry": entry,
|
||||
"memory_artifact": str(memory_json) if memory_json.exists() else "",
|
||||
"memory_markdown": str(memory_md) if memory_md.exists() else "",
|
||||
}
|
||||
|
||||
|
||||
def runtime_files():
|
||||
if not runtime_dir.exists():
|
||||
return []
|
||||
return sorted([p for p in runtime_dir.iterdir() if p.is_file()], key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
|
||||
|
||||
def operator_health_files():
|
||||
if not operator_dir.exists():
|
||||
return []
|
||||
files = [p for p in operator_dir.iterdir() if p.is_file() and p.name.startswith("full_operator_lane_mascarade_health_") and p.suffix == ".json"]
|
||||
return sorted(files, key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
|
||||
|
||||
def candidate_purge_files():
|
||||
candidates = []
|
||||
for path in runtime_files():
|
||||
if path.name.startswith("latest."):
|
||||
continue
|
||||
if path.stat().st_mtime < cutoff:
|
||||
candidates.append(path)
|
||||
for path in operator_health_files():
|
||||
if path.stat().st_mtime < cutoff:
|
||||
candidates.append(path)
|
||||
return sorted(candidates, key=lambda p: p.stat().st_mtime)
|
||||
|
||||
|
||||
def latest_runtime_summary():
|
||||
latest_json = runtime_dir / "latest.json"
|
||||
data = read_json(latest_json) if latest_json.exists() else None
|
||||
if not isinstance(data, dict):
|
||||
return {
|
||||
"status": "missing",
|
||||
"provider": "unknown",
|
||||
"model": "unknown",
|
||||
"checked_at": "",
|
||||
"path": str(latest_json),
|
||||
}
|
||||
return {
|
||||
"status": data.get("status", "unknown"),
|
||||
"provider": data.get("provider", "unknown"),
|
||||
"model": data.get("model", "unknown"),
|
||||
"checked_at": data.get("checked_at", ""),
|
||||
"path": str(latest_json),
|
||||
}
|
||||
|
||||
|
||||
def latest_operator_summary():
|
||||
files = operator_health_files()
|
||||
if not files:
|
||||
return {"status": "missing", "path": "", "checked_at": ""}
|
||||
latest = files[0]
|
||||
data = read_json(latest)
|
||||
if not isinstance(data, dict):
|
||||
return {"status": "invalid", "path": str(latest), "checked_at": ""}
|
||||
return {
|
||||
"status": data.get("status", "unknown"),
|
||||
"path": str(latest),
|
||||
"checked_at": data.get("checked_at", data.get("generated_at", "")),
|
||||
"provider": data.get("provider", "unknown"),
|
||||
"model": data.get("model", "unknown"),
|
||||
}
|
||||
|
||||
|
||||
def tail_lines(path: Path, count: int):
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
return path.read_text(encoding="utf-8", errors="replace").splitlines()[-count:]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def format_ts(path: Path):
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(path.stat().st_mtime))
|
||||
|
||||
|
||||
def summary_payload():
|
||||
memory = memory_context()
|
||||
latest = latest_runtime_summary()
|
||||
operator = latest_operator_summary()
|
||||
files_runtime = runtime_files()
|
||||
files_operator = operator_health_files()
|
||||
stale = candidate_purge_files()
|
||||
status = latest["status"]
|
||||
if status in {"ok", "ready", "done", "success"}:
|
||||
overall = "ok"
|
||||
elif status in {"missing", "unknown"}:
|
||||
overall = "degraded"
|
||||
else:
|
||||
overall = "degraded"
|
||||
latest_log = runtime_dir / "latest.log"
|
||||
payload = {
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "mascarade-logs-tui",
|
||||
"action": "summary",
|
||||
"status": overall,
|
||||
"contract_status": overall,
|
||||
"owner": memory["owner"],
|
||||
"resume_ref": memory["resume_ref"],
|
||||
"trust_level": memory["trust_level"],
|
||||
"routing": memory["routing"],
|
||||
"memory_entry": memory["memory_entry"],
|
||||
"memory_markdown": memory["memory_markdown"],
|
||||
"retention_days": retention_days,
|
||||
"runtime_dir": str(runtime_dir),
|
||||
"operator_dir": str(operator_dir),
|
||||
"runtime_file_count": len(files_runtime),
|
||||
"operator_health_file_count": len(files_operator),
|
||||
"stale_candidate_count": len(stale),
|
||||
"latest_runtime": latest,
|
||||
"latest_operator_health": operator,
|
||||
"latest_log_tail": tail_lines(latest_log, min(lines, 12)),
|
||||
"artifacts": [str(p) for p in [latest_log, runtime_dir / "latest.json"] if p.exists()] + ([memory["memory_artifact"]] if memory["memory_artifact"] else []),
|
||||
"degraded_reasons": [] if overall == "ok" else [f"mascarade-runtime-{status}"],
|
||||
"next_steps": [] if overall == "ok" else ["bash tools/cockpit/mascarade_runtime_health.sh --json"],
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
def list_payload():
|
||||
memory = memory_context()
|
||||
combined = runtime_files() + operator_health_files()
|
||||
combined = sorted(combined, key=lambda p: p.stat().st_mtime, reverse=True)[:lines]
|
||||
entries = [
|
||||
{
|
||||
"file": str(path),
|
||||
"kind": "operator-health" if path.parent == operator_dir else "runtime-artifact",
|
||||
"modified_at": format_ts(path),
|
||||
"size_bytes": path.stat().st_size,
|
||||
}
|
||||
for path in combined
|
||||
]
|
||||
status = "ok" if entries else "degraded"
|
||||
return {
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "mascarade-logs-tui",
|
||||
"action": "list",
|
||||
"status": status,
|
||||
"contract_status": status,
|
||||
"owner": memory["owner"],
|
||||
"resume_ref": memory["resume_ref"],
|
||||
"trust_level": memory["trust_level"],
|
||||
"routing": memory["routing"],
|
||||
"memory_entry": memory["memory_entry"],
|
||||
"memory_markdown": memory["memory_markdown"],
|
||||
"entries": entries,
|
||||
"artifacts": [entry["file"] for entry in entries] + ([memory["memory_artifact"]] if memory["memory_artifact"] else []),
|
||||
"degraded_reasons": [] if entries else ["mascarade-logs-empty"],
|
||||
"next_steps": [] if entries else ["Run bash tools/cockpit/mascarade_runtime_health.sh --json to create fresh artifacts."],
|
||||
}
|
||||
|
||||
|
||||
def latest_payload():
|
||||
memory = memory_context()
|
||||
summary = summary_payload()
|
||||
latest_runtime_log = runtime_dir / "latest.log"
|
||||
payload = {
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "mascarade-logs-tui",
|
||||
"action": "latest",
|
||||
"status": summary["status"],
|
||||
"contract_status": summary["contract_status"],
|
||||
"owner": memory["owner"],
|
||||
"resume_ref": memory["resume_ref"],
|
||||
"trust_level": memory["trust_level"],
|
||||
"routing": memory["routing"],
|
||||
"memory_entry": memory["memory_entry"],
|
||||
"memory_markdown": memory["memory_markdown"],
|
||||
"latest_runtime": summary["latest_runtime"],
|
||||
"latest_operator_health": summary["latest_operator_health"],
|
||||
"latest_log_tail": tail_lines(latest_runtime_log, lines),
|
||||
"artifacts": summary["artifacts"],
|
||||
"degraded_reasons": summary["degraded_reasons"],
|
||||
"next_steps": summary["next_steps"],
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
def purge_payload():
|
||||
candidates = candidate_purge_files()
|
||||
purged = []
|
||||
if apply:
|
||||
for path in candidates:
|
||||
try:
|
||||
path.unlink()
|
||||
purged.append(str(path))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
status = "done" if apply else "ready"
|
||||
contract_status = "ok" if apply else "ready"
|
||||
return {
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "mascarade-logs-tui",
|
||||
"action": "purge",
|
||||
"status": status,
|
||||
"contract_status": contract_status,
|
||||
"retention_days": retention_days,
|
||||
"apply": apply,
|
||||
"candidate_count": len(candidates),
|
||||
"purged_count": len(purged),
|
||||
"candidates": [str(path) for path in candidates[:lines]],
|
||||
"artifacts": purged if apply else [str(path) for path in candidates[:lines]],
|
||||
"degraded_reasons": [],
|
||||
"next_steps": [] if apply else ["Re-run with --apply to remove stale Mascarade/Ollama artifacts."],
|
||||
}
|
||||
|
||||
|
||||
if action == "summary":
|
||||
payload = summary_payload()
|
||||
elif action == "list":
|
||||
payload = list_payload()
|
||||
elif action == "latest":
|
||||
payload = latest_payload()
|
||||
else:
|
||||
payload = purge_payload()
|
||||
|
||||
if json_output:
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
raise SystemExit(0)
|
||||
|
||||
if action == "summary":
|
||||
print("Mascarade/Ollama logs summary")
|
||||
print(f"runtime dir: {payload['runtime_dir']}")
|
||||
print(f"operator dir: {payload['operator_dir']}")
|
||||
print(f"latest runtime: {payload['latest_runtime']['status']} ({payload['latest_runtime']['provider']}/{payload['latest_runtime']['model']})")
|
||||
if payload["latest_runtime"]["checked_at"]:
|
||||
print(f"checked at: {payload['latest_runtime']['checked_at']}")
|
||||
print(f"runtime files: {payload['runtime_file_count']}")
|
||||
print(f"operator health files: {payload['operator_health_file_count']}")
|
||||
print(f"stale candidates (> {retention_days}j): {payload['stale_candidate_count']}")
|
||||
if payload["latest_operator_health"]["path"]:
|
||||
print(f"latest operator health: {payload['latest_operator_health']['status']} ({payload['latest_operator_health']['path']})")
|
||||
if payload["latest_log_tail"]:
|
||||
print("latest.log tail:")
|
||||
for line in payload["latest_log_tail"]:
|
||||
print(f" {line}")
|
||||
elif action == "list":
|
||||
print("Mascarade/Ollama log artifacts")
|
||||
for entry in payload["entries"]:
|
||||
print(f"{entry['modified_at']} | {entry['kind']} | {entry['size_bytes']} B | {entry['file']}")
|
||||
elif action == "latest":
|
||||
print("Mascarade/Ollama latest state")
|
||||
print(f"runtime: {payload['latest_runtime']['status']} ({payload['latest_runtime']['provider']}/{payload['latest_runtime']['model']})")
|
||||
print(f"runtime json: {payload['latest_runtime']['path']}")
|
||||
if payload["latest_operator_health"]["path"]:
|
||||
print(f"operator health: {payload['latest_operator_health']['status']} ({payload['latest_operator_health']['path']})")
|
||||
print("latest.log tail:")
|
||||
for line in payload["latest_log_tail"]:
|
||||
print(f" {line}")
|
||||
else:
|
||||
mode = "apply" if apply else "dry-run"
|
||||
print(f"Mascarade/Ollama purge ({mode})")
|
||||
print(f"retention days: {retention_days}")
|
||||
print(f"candidate count: {payload['candidate_count']}")
|
||||
print(f"purged count: {payload['purged_count']}")
|
||||
for item in payload["candidates"]:
|
||||
print(f" {item}")
|
||||
PY
|
||||
Executable
+354
@@ -0,0 +1,354 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# mascarade_mesh_env_sync.sh
|
||||
# Canonical Mascarade roots and .env propagation for mesh machines.
|
||||
# Contract: cockpit-v1
|
||||
# Date: 2026-03-22
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
ARTIFACTS_DIR="${ROOT_DIR}/artifacts/cockpit/mascarade_mesh_env_sync"
|
||||
mkdir -p "${ARTIFACTS_DIR}"
|
||||
|
||||
ACTION="status"
|
||||
JSON_MODE=0
|
||||
SOURCE_ENV="${SOURCE_ENV:-/Users/electron/Documents/Projets/mascarade/.env}"
|
||||
|
||||
KEYS=(
|
||||
"ANTHROPIC_API_KEY"
|
||||
"MISTRAL_API_KEY"
|
||||
"OPENAI_API_KEY"
|
||||
)
|
||||
|
||||
HOST_LABELS=(
|
||||
"clems"
|
||||
"root"
|
||||
"kxkm"
|
||||
"cils"
|
||||
)
|
||||
|
||||
HOST_TARGETS=(
|
||||
"clems@192.168.0.120"
|
||||
"root@192.168.0.119"
|
||||
"kxkm@kxkm-ai"
|
||||
"cils@100.126.225.111"
|
||||
)
|
||||
|
||||
HOST_ROOTS=(
|
||||
"/home/clems/mascarade"
|
||||
"/root/mascarade-main"
|
||||
"/home/kxkm/mascarade"
|
||||
"/Users/cils/mascarade-main"
|
||||
)
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: mascarade_mesh_env_sync.sh [--action status|sync|paths] [--json]
|
||||
|
||||
Actions:
|
||||
status Inspect canonical Mascarade roots and .env presence on the mesh
|
||||
sync Propagate selected keys from the local source .env to the remote roots
|
||||
paths Print the canonical roots only
|
||||
|
||||
Options:
|
||||
--json Emit cockpit-v1 JSON
|
||||
--help Show this help
|
||||
|
||||
Env vars:
|
||||
SOURCE_ENV Local source .env (default: /Users/electron/Documents/Projets/mascarade/.env)
|
||||
EOF
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
python3 - "$1" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
print(json.dumps(sys.argv[1]))
|
||||
PY
|
||||
}
|
||||
|
||||
load_updates_json() {
|
||||
python3 - "$SOURCE_ENV" "${KEYS[@]}" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
env_path = Path(sys.argv[1])
|
||||
keys = sys.argv[2:]
|
||||
values = {}
|
||||
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
if "=" not in line or line.lstrip().startswith("#"):
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
if key in keys:
|
||||
values[key] = value
|
||||
|
||||
missing = [key for key in keys if key not in values or not values[key]]
|
||||
if missing:
|
||||
raise SystemExit("missing keys in source env: " + ", ".join(missing))
|
||||
|
||||
print(json.dumps(values))
|
||||
PY
|
||||
}
|
||||
|
||||
remote_status() {
|
||||
local host="$1"
|
||||
local root="$2"
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=8 "$host" python3 - "$root" <<'PY'
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
ports = {}
|
||||
for port in (3000, 3100, 8000, 8080, 11434):
|
||||
s = socket.socket()
|
||||
s.settimeout(0.4)
|
||||
try:
|
||||
s.connect(("127.0.0.1", port))
|
||||
ports[str(port)] = True
|
||||
except Exception:
|
||||
ports[str(port)] = False
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
print(json.dumps({
|
||||
"status": "ok",
|
||||
"hostname": socket.gethostname(),
|
||||
"root": str(root),
|
||||
"root_exists": root.exists(),
|
||||
"env_path": str(root / ".env"),
|
||||
"env_exists": (root / ".env").exists(),
|
||||
"git_exists": (root / ".git").exists(),
|
||||
"compose_exists": any((root / name).exists() for name in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml")),
|
||||
"ports": ports,
|
||||
}))
|
||||
PY
|
||||
}
|
||||
|
||||
remote_sync() {
|
||||
local host="$1"
|
||||
local root="$2"
|
||||
local payload_b64="$3"
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=8 "$host" python3 - "$root" "$payload_b64" <<'PY'
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
updates = json.loads(base64.b64decode(sys.argv[2]).decode("utf-8"))
|
||||
|
||||
if not root.exists():
|
||||
print(json.dumps({
|
||||
"status": "missing-root",
|
||||
"root": str(root),
|
||||
}))
|
||||
raise SystemExit(0)
|
||||
|
||||
env_path = root / ".env"
|
||||
lines = env_path.read_text(encoding="utf-8").splitlines() if env_path.exists() else []
|
||||
|
||||
seen = set()
|
||||
out = []
|
||||
for line in lines:
|
||||
replaced = False
|
||||
for key, value in updates.items():
|
||||
if line.startswith(f"{key}="):
|
||||
out.append(f"{key}={value}")
|
||||
seen.add(key)
|
||||
replaced = True
|
||||
break
|
||||
if not replaced:
|
||||
out.append(line)
|
||||
|
||||
for key, value in updates.items():
|
||||
if key not in seen:
|
||||
out.append(f"{key}={value}")
|
||||
|
||||
env_path.write_text("\n".join(out) + "\n", encoding="utf-8")
|
||||
|
||||
print(json.dumps({
|
||||
"status": "updated",
|
||||
"root": str(root),
|
||||
"env_path": str(env_path),
|
||||
"updated_keys": sorted(updates.keys()),
|
||||
}))
|
||||
PY
|
||||
}
|
||||
|
||||
emit_paths_text() {
|
||||
local idx
|
||||
for idx in "${!HOST_LABELS[@]}"; do
|
||||
printf '%s %s %s\n' "${HOST_LABELS[$idx]}" "${HOST_TARGETS[$idx]}" "${HOST_ROOTS[$idx]}"
|
||||
done
|
||||
}
|
||||
|
||||
run_action() {
|
||||
local stamp artifact_file latest_file now
|
||||
local mode="$1"
|
||||
local updates_json=""
|
||||
|
||||
if [[ "$mode" == "sync" ]]; then
|
||||
updates_json="$(load_updates_json)"
|
||||
fi
|
||||
|
||||
stamp="$(date +%Y%m%d_%H%M%S)"
|
||||
artifact_file="${ARTIFACTS_DIR}/mascarade_mesh_env_sync_${stamp}.json"
|
||||
latest_file="${ARTIFACTS_DIR}/latest.json"
|
||||
now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
python3 - "$mode" "$now" "$SOURCE_ENV" "$artifact_file" "$latest_file" "$updates_json" "${HOST_LABELS[@]}" -- "${HOST_TARGETS[@]}" -- "${HOST_ROOTS[@]}" <<'PY'
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
mode = sys.argv[1]
|
||||
timestamp = sys.argv[2]
|
||||
source_env = sys.argv[3]
|
||||
artifact_file = sys.argv[4]
|
||||
latest_file = sys.argv[5]
|
||||
updates_json = sys.argv[6]
|
||||
|
||||
args = sys.argv[7:]
|
||||
sep1 = args.index("--")
|
||||
labels = args[:sep1]
|
||||
args = args[sep1 + 1 :]
|
||||
sep2 = args.index("--")
|
||||
targets = args[:sep2]
|
||||
roots = args[sep2 + 1 :]
|
||||
|
||||
results = []
|
||||
updates = json.loads(updates_json) if updates_json else {}
|
||||
|
||||
for label, target, root in zip(labels, targets, roots):
|
||||
if mode == "status":
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"/Users/electron/Documents/Lelectron_rare/Kill_LIFE/tools/cockpit/mascarade_mesh_env_sync.sh",
|
||||
"__remote_status__",
|
||||
target,
|
||||
root,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
else:
|
||||
import base64
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"/Users/electron/Documents/Lelectron_rare/Kill_LIFE/tools/cockpit/mascarade_mesh_env_sync.sh",
|
||||
"__remote_sync__",
|
||||
target,
|
||||
root,
|
||||
base64.b64encode(json.dumps(updates).encode("utf-8")).decode("ascii"),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
record = {"label": label, "host": target, "returncode": proc.returncode}
|
||||
stdout = (proc.stdout or "").strip()
|
||||
stderr = (proc.stderr or "").strip()
|
||||
if stdout:
|
||||
try:
|
||||
record.update(json.loads(stdout.splitlines()[-1]))
|
||||
except Exception:
|
||||
record["status"] = "parse-error"
|
||||
record["stdout"] = stdout
|
||||
if stderr:
|
||||
record["stderr"] = stderr
|
||||
if "status" not in record:
|
||||
record["status"] = "ssh-error" if proc.returncode else "empty"
|
||||
results.append(record)
|
||||
|
||||
summary = {
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "mascarade-mesh-env-sync",
|
||||
"action": mode,
|
||||
"timestamp": timestamp,
|
||||
"source_env": source_env,
|
||||
"updated_keys": sorted(updates.keys()),
|
||||
"results": results,
|
||||
}
|
||||
|
||||
summary["ok"] = sum(1 for item in results if item.get("status") in {"ok", "updated"})
|
||||
summary["missing_root"] = sum(1 for item in results if item.get("status") == "missing-root")
|
||||
summary["ssh_errors"] = sum(1 for item in results if item.get("status") == "ssh-error")
|
||||
|
||||
payload = json.dumps(summary, indent=2) + "\n"
|
||||
with open(artifact_file, "w", encoding="utf-8") as handle:
|
||||
handle.write(payload)
|
||||
with open(latest_file, "w", encoding="utf-8") as handle:
|
||||
handle.write(payload)
|
||||
print(payload, end="")
|
||||
PY
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "__remote_status__" ]]; then
|
||||
shift
|
||||
remote_status "$@"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "__remote_sync__" ]]; then
|
||||
shift
|
||||
remote_sync "$@"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
JSON_MODE=1
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$ACTION" in
|
||||
paths)
|
||||
if [[ "$JSON_MODE" -eq 1 ]]; then
|
||||
cat <<EOF
|
||||
{
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "mascarade-mesh-env-sync",
|
||||
"action": "paths",
|
||||
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"source_env": $(json_escape "$SOURCE_ENV"),
|
||||
"machines": [
|
||||
{"label":"${HOST_LABELS[0]}","host":"${HOST_TARGETS[0]}","root":"${HOST_ROOTS[0]}"},
|
||||
{"label":"${HOST_LABELS[1]}","host":"${HOST_TARGETS[1]}","root":"${HOST_ROOTS[1]}"},
|
||||
{"label":"${HOST_LABELS[2]}","host":"${HOST_TARGETS[2]}","root":"${HOST_ROOTS[2]}"},
|
||||
{"label":"${HOST_LABELS[3]}","host":"${HOST_TARGETS[3]}","root":"${HOST_ROOTS[3]}"}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
else
|
||||
emit_paths_text
|
||||
fi
|
||||
;;
|
||||
status|sync)
|
||||
run_action "$ACTION"
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown action: %s\n' "$ACTION" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,287 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
|
||||
CATALOG_FILE="${ROOT_DIR}/specs/contracts/mascarade_model_profiles.kxkm_ai.json"
|
||||
LOG_DIR="${ROOT_DIR}/artifacts/cockpit"
|
||||
mkdir -p "${LOG_DIR}"
|
||||
|
||||
ACTION=""
|
||||
PROFILE=""
|
||||
JSON=0
|
||||
DAYS=14
|
||||
LOG_FILE="${LOG_DIR}/mascarade_models_tui_$(date '+%Y%m%d_%H%M%S').log"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: bash tools/cockpit/mascarade_models_tui.sh [options]
|
||||
|
||||
Options:
|
||||
--action <summary|list|show|env|prompt|agents-json|clean-logs> Action to run
|
||||
--profile <id> Profile id for show/env/prompt
|
||||
--json Emit JSON when available
|
||||
--days <N> Retention for clean-logs (default: 14)
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
have_tty() {
|
||||
[[ -t 0 && -t 1 ]]
|
||||
}
|
||||
|
||||
choose_action_interactive() {
|
||||
if command -v gum >/dev/null 2>&1 && have_tty; then
|
||||
gum choose summary list show env prompt agents-json clean-logs
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
choose_profile_interactive() {
|
||||
if command -v gum >/dev/null 2>&1 && have_tty; then
|
||||
python3 - "${CATALOG_FILE}" <<'PY' | gum choose
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
for item in data.get("profiles", []):
|
||||
if isinstance(item, dict):
|
||||
profile_id = str(item.get("id", "")).strip()
|
||||
if profile_id:
|
||||
print(profile_id)
|
||||
PY
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
log_line() {
|
||||
local level="$1"
|
||||
shift
|
||||
local msg="${level} $(date '+%Y-%m-%d %H:%M:%S %z') ${*}"
|
||||
printf '%s\n' "${msg}" | tee -a "${LOG_FILE}" >/dev/null
|
||||
}
|
||||
|
||||
require_catalog() {
|
||||
if [[ ! -f "${CATALOG_FILE}" ]]; then
|
||||
printf 'Missing catalog file: %s\n' "${CATALOG_FILE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
emit_catalog() {
|
||||
python3 - "${CATALOG_FILE}" "${ACTION}" "${PROFILE}" "${JSON}" <<'PY'
|
||||
import json
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
catalog_path = Path(sys.argv[1])
|
||||
action = sys.argv[2]
|
||||
profile_id = sys.argv[3]
|
||||
json_mode = sys.argv[4] == "1"
|
||||
|
||||
data = json.loads(catalog_path.read_text(encoding="utf-8"))
|
||||
profiles = [item for item in data.get("profiles", []) if isinstance(item, dict)]
|
||||
index = {str(item.get("id", "")).strip(): item for item in profiles}
|
||||
target = index.get(profile_id, {})
|
||||
|
||||
def emit_json(payload: dict) -> None:
|
||||
print(json.dumps(payload, ensure_ascii=False))
|
||||
|
||||
if action == "summary":
|
||||
payload = {
|
||||
"status": "ok",
|
||||
"component": "mascarade_models_tui",
|
||||
"action": action,
|
||||
"catalog_file": str(catalog_path),
|
||||
"target_host": data.get("target_host", ""),
|
||||
"default_profile": data.get("default_profile", ""),
|
||||
"profile_count": len(profiles),
|
||||
"profiles": [str(item.get("id", "")).strip() for item in profiles],
|
||||
}
|
||||
if json_mode:
|
||||
emit_json(payload)
|
||||
else:
|
||||
print("# Mascarade model profiles\n")
|
||||
print(f"- catalog: {payload['catalog_file']}")
|
||||
print(f"- target_host: {payload['target_host']}")
|
||||
print(f"- default_profile: {payload['default_profile']}")
|
||||
print(f"- profile_count: {payload['profile_count']}")
|
||||
print(f"- profiles: {', '.join(payload['profiles'])}")
|
||||
elif action == "list":
|
||||
payload = {
|
||||
"status": "ok",
|
||||
"component": "mascarade_models_tui",
|
||||
"action": action,
|
||||
"profiles": profiles,
|
||||
}
|
||||
if json_mode:
|
||||
emit_json(payload)
|
||||
else:
|
||||
print("# Profiles\n")
|
||||
for item in profiles:
|
||||
print(f"- {item.get('id', '')}: {item.get('label', '')} [{item.get('category', '')}]")
|
||||
elif action == "show":
|
||||
if not target:
|
||||
raise SystemExit("profile not found")
|
||||
payload = {
|
||||
"status": "ok",
|
||||
"component": "mascarade_models_tui",
|
||||
"action": action,
|
||||
"profile": target,
|
||||
}
|
||||
if json_mode:
|
||||
emit_json(payload)
|
||||
else:
|
||||
print(json.dumps(target, indent=2, ensure_ascii=False))
|
||||
elif action == "env":
|
||||
if not target:
|
||||
raise SystemExit("profile not found")
|
||||
provider_preference = ",".join(target.get("provider_preference", []))
|
||||
lines = [
|
||||
f'export MASCARADE_MODEL_CATALOG={shlex.quote(str(catalog_path))}',
|
||||
f'export MASCARADE_OPERATOR_PROFILE={shlex.quote(str(target.get("id", "")))}',
|
||||
f'export MASCARADE_OPERATOR_PROVIDER={shlex.quote(str(target.get("default_provider", "")))}',
|
||||
f'export MASCARADE_OPERATOR_MODEL={shlex.quote(str(target.get("default_model", "")))}',
|
||||
f'export MASCARADE_DEFAULT_MODEL={shlex.quote(str(target.get("default_model", "")))}',
|
||||
f'export MASCARADE_OPERATOR_PROVIDER_PREFERENCE={shlex.quote(provider_preference)}',
|
||||
f'export MASCARADE_OPERATOR_TIMEOUT={shlex.quote("45")}',
|
||||
]
|
||||
payload = {
|
||||
"status": "ok",
|
||||
"component": "mascarade_models_tui",
|
||||
"action": action,
|
||||
"profile": str(target.get("id", "")),
|
||||
"exports": lines,
|
||||
}
|
||||
if json_mode:
|
||||
emit_json(payload)
|
||||
else:
|
||||
print("\n".join(lines))
|
||||
elif action == "prompt":
|
||||
if not target:
|
||||
raise SystemExit("profile not found")
|
||||
payload = {
|
||||
"status": "ok",
|
||||
"component": "mascarade_models_tui",
|
||||
"action": action,
|
||||
"profile": str(target.get("id", "")),
|
||||
"prompt": str(target.get("prompt", "")),
|
||||
}
|
||||
if json_mode:
|
||||
emit_json(payload)
|
||||
else:
|
||||
print(payload["prompt"])
|
||||
elif action == "agents-json":
|
||||
strategy_map = {
|
||||
"local-fast": "fastest",
|
||||
"fallback-safe": "cheapest",
|
||||
}
|
||||
agents = []
|
||||
for item in profiles:
|
||||
profile_id = str(item.get("id", "")).strip()
|
||||
label = str(item.get("label", "")).strip() or profile_id
|
||||
category = str(item.get("category", "")).strip()
|
||||
intended = item.get("intended_tasks")
|
||||
tasks = [entry for entry in intended if isinstance(entry, str) and entry.strip()] if isinstance(intended, list) else []
|
||||
summary = ", ".join(tasks[:3]) if tasks else category or "general assistance"
|
||||
agents.append(
|
||||
{
|
||||
"name": f"kxkm-{profile_id}",
|
||||
"description": f"{label} copilot for {summary}.",
|
||||
"system_prompt": str(item.get("prompt", "")).strip(),
|
||||
"preferred_provider": str(item.get("default_provider", "")).strip() or None,
|
||||
"preferred_model": str(item.get("default_model", "")).strip() or None,
|
||||
"strategy": strategy_map.get(profile_id, "best"),
|
||||
"temperature": float(item.get("temperature", 0.2)),
|
||||
"max_tokens": int(item.get("max_tokens", 700)),
|
||||
}
|
||||
)
|
||||
payload = {
|
||||
"status": "ok",
|
||||
"component": "mascarade_models_tui",
|
||||
"action": action,
|
||||
"target_host": data.get("target_host", ""),
|
||||
"agents": agents,
|
||||
}
|
||||
if json_mode:
|
||||
emit_json(payload)
|
||||
else:
|
||||
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
raise SystemExit(f"unsupported action: {action}")
|
||||
PY
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--profile)
|
||||
PROFILE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
JSON=1
|
||||
shift
|
||||
;;
|
||||
--days)
|
||||
DAYS="${2:-14}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "${ACTION}" ]]; then
|
||||
if ACTION="$(choose_action_interactive)"; then
|
||||
:
|
||||
else
|
||||
ACTION="summary"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${ACTION}" == "show" || "${ACTION}" == "env" || "${ACTION}" == "prompt" ]] && [[ -z "${PROFILE}" ]]; then
|
||||
if PROFILE="$(choose_profile_interactive)"; then
|
||||
:
|
||||
else
|
||||
printf -- '--profile is required with action %s\n' "${ACTION}" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! [[ "${DAYS}" =~ ^[0-9]+$ ]]; then
|
||||
printf -- '--days requires an integer\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
require_catalog
|
||||
log_line "INFO" "action=${ACTION} profile=${PROFILE:-all}"
|
||||
|
||||
case "${ACTION}" in
|
||||
summary|list|show|env|prompt|agents-json)
|
||||
emit_catalog
|
||||
;;
|
||||
clean-logs)
|
||||
find "${LOG_DIR}" -type f -name 'mascarade_models_tui_*.log' -mtime +"${DAYS}" -delete
|
||||
printf 'cleaned mascarade_models_tui logs older than %s days in %s\n' "${DAYS}" "${LOG_DIR}"
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown action: %s\n' "${ACTION}" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Executable
+542
@@ -0,0 +1,542 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
DEFAULT_HOST="kxkm@kxkm-ai"
|
||||
DEFAULT_AGENT="kxkm-fallback-safe"
|
||||
DEFAULT_LOG_DIR="$ROOT_DIR/artifacts/ops/mascarade_runtime_health"
|
||||
SSH_CONNECT_TIMEOUT=6
|
||||
|
||||
HOST="$DEFAULT_HOST"
|
||||
AGENT="$DEFAULT_AGENT"
|
||||
LOG_DIR="$DEFAULT_LOG_DIR"
|
||||
OUTPUT_MODE="text"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: mascarade_runtime_health.sh [options]
|
||||
|
||||
Options:
|
||||
--host <user@host> Remote host to inspect (default: kxkm@kxkm-ai)
|
||||
--agent <name> Agent used for low-cost smoke (default: kxkm-fallback-safe)
|
||||
--log-dir <path> Artifact directory (default: artifacts/ops/mascarade_runtime_health)
|
||||
--json Emit cockpit-v1 JSON to stdout
|
||||
--help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
mkdir_safe() {
|
||||
mkdir -p "$1"
|
||||
}
|
||||
|
||||
timestamp_utc() {
|
||||
date -u +"%Y-%m-%dT%H:%M:%SZ"
|
||||
}
|
||||
|
||||
timestamp_slug() {
|
||||
date -u +"%Y%m%dT%H%M%SZ"
|
||||
}
|
||||
|
||||
json_from_py() {
|
||||
python3 - "$@"
|
||||
}
|
||||
|
||||
copy_latest_artifacts() {
|
||||
cp "$RUN_LOG" "$LOG_DIR/latest.log"
|
||||
cp "$RUN_JSON" "$LOG_DIR/latest.json"
|
||||
}
|
||||
|
||||
append_reason() {
|
||||
DEGRADED_REASONS+=("$1")
|
||||
}
|
||||
|
||||
append_next_step() {
|
||||
NEXT_STEPS+=("$1")
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--host)
|
||||
[[ $# -ge 2 ]] || {
|
||||
echo "Missing value for --host" >&2
|
||||
exit 2
|
||||
}
|
||||
HOST="$2"
|
||||
shift 2
|
||||
;;
|
||||
--agent)
|
||||
[[ $# -ge 2 ]] || {
|
||||
echo "Missing value for --agent" >&2
|
||||
exit 2
|
||||
}
|
||||
AGENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--log-dir)
|
||||
[[ $# -ge 2 ]] || {
|
||||
echo "Missing value for --log-dir" >&2
|
||||
exit 2
|
||||
}
|
||||
LOG_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
OUTPUT_MODE="json"
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir_safe "$LOG_DIR"
|
||||
|
||||
RUN_ID="$(timestamp_slug)"
|
||||
RUN_LOG="$LOG_DIR/$RUN_ID.log"
|
||||
RUN_JSON="$LOG_DIR/$RUN_ID.json"
|
||||
SMOKE_CAPTURE="$LOG_DIR/$RUN_ID.smoke.json"
|
||||
REMOTE_CAPTURE="$LOG_DIR/$RUN_ID.remote.txt"
|
||||
ROUTING_CAPTURE="$LOG_DIR/$RUN_ID.routing.json"
|
||||
MEMORY_CAPTURE="$LOG_DIR/$RUN_ID.memory.json"
|
||||
|
||||
touch "$RUN_LOG"
|
||||
|
||||
log() {
|
||||
printf '[%s] %s\n' "$(timestamp_utc)" "$*" | tee -a "$RUN_LOG" >&2
|
||||
}
|
||||
|
||||
SSH_OK="ko"
|
||||
DOCKER_STATUS="unknown"
|
||||
API_AGENTS_STATUS="unknown"
|
||||
OLLAMA_TAGS_STATUS="unknown"
|
||||
AGENT_SMOKE_STATUS="unknown"
|
||||
AGENT_SMOKE_PROVIDER=""
|
||||
AGENT_SMOKE_MODEL=""
|
||||
ROUTING_STATUS="unknown"
|
||||
MEMORY_STATUS="unknown"
|
||||
TRUST_LEVEL="inferred"
|
||||
RESUME_REF="kill-life:mascarade-runtime-health:${RUN_ID}:${HOST}:${AGENT}"
|
||||
STATUS="ok"
|
||||
CONTRACT_STATUS="ready"
|
||||
|
||||
declare -A CONTAINERS=(
|
||||
["mascarade-api"]="unknown"
|
||||
["mascarade-core"]="unknown"
|
||||
["mascarade-ollama-runtime"]="unknown"
|
||||
)
|
||||
|
||||
declare -a DEGRADED_REASONS=()
|
||||
declare -a NEXT_STEPS=()
|
||||
declare -a ARTIFACTS=()
|
||||
|
||||
ARTIFACTS+=("$RUN_LOG")
|
||||
ARTIFACTS+=("$RUN_JSON")
|
||||
|
||||
log "Checking Mascarade runtime on $HOST"
|
||||
|
||||
REMOTE_SCRIPT='
|
||||
set -eu
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
echo "docker=ok"
|
||||
running="$(docker ps --format "{{.Names}}" 2>/dev/null || true)"
|
||||
existing="$(docker ps -a --format "{{.Names}}" 2>/dev/null || true)"
|
||||
else
|
||||
echo "docker=missing"
|
||||
running=""
|
||||
existing=""
|
||||
fi
|
||||
for name in mascarade-api mascarade-core mascarade-ollama-runtime; do
|
||||
if printf "%s\n" "$running" | grep -Fxq "$name"; then
|
||||
echo "container:$name:running"
|
||||
elif printf "%s\n" "$existing" | grep -Fxq "$name"; then
|
||||
echo "container:$name:stopped"
|
||||
else
|
||||
echo "container:$name:missing"
|
||||
fi
|
||||
done
|
||||
api_agents_code="$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://127.0.0.1:3100/api/agents || true)"
|
||||
if [[ "$api_agents_code" == "200" || "$api_agents_code" == "401" ]] || curl -fsS --max-time 5 http://127.0.0.1:3100/health >/dev/null 2>&1; then
|
||||
echo "api_agents=ok"
|
||||
else
|
||||
echo "api_agents=ko"
|
||||
fi
|
||||
if curl -fsS --max-time 5 http://127.0.0.1:11434/api/tags >/dev/null 2>&1; then
|
||||
echo "ollama_tags=ok"
|
||||
else
|
||||
echo "ollama_tags=ko"
|
||||
fi
|
||||
'
|
||||
|
||||
if ssh -o BatchMode=yes -o ConnectTimeout="$SSH_CONNECT_TIMEOUT" "$HOST" "bash -s" >"$REMOTE_CAPTURE" 2>>"$RUN_LOG" <<<"$REMOTE_SCRIPT"; then
|
||||
SSH_OK="ok"
|
||||
ARTIFACTS+=("$REMOTE_CAPTURE")
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
docker=ok)
|
||||
DOCKER_STATUS="ok"
|
||||
;;
|
||||
docker=missing)
|
||||
DOCKER_STATUS="missing"
|
||||
append_reason "docker indisponible sur $HOST"
|
||||
append_next_step "Installer ou rétablir Docker sur $HOST si Mascarade doit rester containerisé."
|
||||
;;
|
||||
container:mascarade-api:*)
|
||||
CONTAINERS["mascarade-api"]="${line##*:}"
|
||||
;;
|
||||
container:mascarade-core:*)
|
||||
CONTAINERS["mascarade-core"]="${line##*:}"
|
||||
;;
|
||||
container:mascarade-ollama-runtime:*)
|
||||
CONTAINERS["mascarade-ollama-runtime"]="${line##*:}"
|
||||
;;
|
||||
api_agents=ok)
|
||||
API_AGENTS_STATUS="ok"
|
||||
;;
|
||||
api_agents=ko)
|
||||
API_AGENTS_STATUS="ko"
|
||||
append_reason "l'API agents Mascarade ne répond pas sur $HOST"
|
||||
append_next_step "Vérifier mascarade-api/mascarade-core et l'exposition locale 127.0.0.1:3100."
|
||||
;;
|
||||
ollama_tags=ok)
|
||||
OLLAMA_TAGS_STATUS="ok"
|
||||
;;
|
||||
ollama_tags=ko)
|
||||
OLLAMA_TAGS_STATUS="ko"
|
||||
append_reason "le runtime Ollama ne répond pas sur $HOST"
|
||||
append_next_step "Contrôler mascarade-ollama-runtime et l'endpoint 127.0.0.1:11434."
|
||||
;;
|
||||
esac
|
||||
done <"$REMOTE_CAPTURE"
|
||||
else
|
||||
SSH_OK="ko"
|
||||
DOCKER_STATUS="unreachable"
|
||||
API_AGENTS_STATUS="unreachable"
|
||||
OLLAMA_TAGS_STATUS="unreachable"
|
||||
CONTAINERS["mascarade-api"]="unreachable"
|
||||
CONTAINERS["mascarade-core"]="unreachable"
|
||||
CONTAINERS["mascarade-ollama-runtime"]="unreachable"
|
||||
append_reason "SSH indisponible vers $HOST"
|
||||
append_next_step "Relancer le health-check SSH mesh avant toute action Mascarade."
|
||||
fi
|
||||
|
||||
if [[ -f "$ROOT_DIR/tools/ops/smoke_mascarade_agents_kxkm.sh" ]]; then
|
||||
if bash "$ROOT_DIR/tools/ops/smoke_mascarade_agents_kxkm.sh" --agents "$AGENT" --json >"$SMOKE_CAPTURE" 2>>"$RUN_LOG"; then
|
||||
ARTIFACTS+=("$SMOKE_CAPTURE")
|
||||
AGENT_SMOKE_STATUS="$(python3 - "$SMOKE_CAPTURE" <<'PY'
|
||||
import json, sys
|
||||
path = sys.argv[1]
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except Exception:
|
||||
print("invalid")
|
||||
raise SystemExit(0)
|
||||
status = data.get("status") or data.get("contract_status") or "ok"
|
||||
if isinstance(status, str):
|
||||
print(status)
|
||||
else:
|
||||
print("ok")
|
||||
PY
|
||||
)"
|
||||
AGENT_SMOKE_PROVIDER="$(python3 - "$SMOKE_CAPTURE" <<'PY'
|
||||
import json, sys
|
||||
path = sys.argv[1]
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except Exception:
|
||||
print("")
|
||||
raise SystemExit(0)
|
||||
provider = ""
|
||||
model = ""
|
||||
for key in ("provider", "preferred_provider"):
|
||||
if isinstance(data.get(key), str):
|
||||
provider = data[key]
|
||||
break
|
||||
checks = data.get("checks")
|
||||
if isinstance(checks, dict):
|
||||
smoke = checks.get("agent_smoke")
|
||||
if isinstance(smoke, dict):
|
||||
provider = smoke.get("provider") or provider
|
||||
model = smoke.get("model") or model
|
||||
agents = data.get("agents")
|
||||
if isinstance(agents, list) and agents:
|
||||
first = agents[0]
|
||||
if isinstance(first, dict):
|
||||
provider = first.get("provider") or provider
|
||||
model = first.get("model") or model
|
||||
print(provider or "")
|
||||
PY
|
||||
)"
|
||||
AGENT_SMOKE_MODEL="$(python3 - "$SMOKE_CAPTURE" <<'PY'
|
||||
import json, sys
|
||||
path = sys.argv[1]
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except Exception:
|
||||
print("")
|
||||
raise SystemExit(0)
|
||||
model = data.get("model") if isinstance(data.get("model"), str) else ""
|
||||
checks = data.get("checks")
|
||||
if isinstance(checks, dict):
|
||||
smoke = checks.get("agent_smoke")
|
||||
if isinstance(smoke, dict):
|
||||
model = smoke.get("model") or model
|
||||
agents = data.get("agents")
|
||||
if isinstance(agents, list) and agents:
|
||||
first = agents[0]
|
||||
if isinstance(first, dict):
|
||||
model = first.get("model") or model
|
||||
print(model or "")
|
||||
PY
|
||||
)"
|
||||
else
|
||||
AGENT_SMOKE_STATUS="ko"
|
||||
append_reason "le smoke agent $AGENT a échoué"
|
||||
append_next_step "Consulter $(basename "$SMOKE_CAPTURE") ou relancer smoke_mascarade_agents_kxkm.sh pour diagnostic ciblé."
|
||||
fi
|
||||
else
|
||||
AGENT_SMOKE_STATUS="missing"
|
||||
append_reason "le script smoke_mascarade_agents_kxkm.sh est absent ou non exécutable"
|
||||
append_next_step "Rétablir l'outillage tools/ops/smoke_mascarade_agents_kxkm.sh avant l'automatisation complète."
|
||||
fi
|
||||
|
||||
for container_name in "mascarade-api" "mascarade-core" "mascarade-ollama-runtime"; do
|
||||
state="${CONTAINERS[$container_name]}"
|
||||
case "$state" in
|
||||
running)
|
||||
;;
|
||||
*)
|
||||
append_reason "conteneur $container_name en état $state"
|
||||
append_next_step "Contrôler $container_name sur $HOST puis republier si nécessaire."
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$SSH_OK" != "ok" ]] || [[ "$API_AGENTS_STATUS" != "ok" ]] || [[ "$OLLAMA_TAGS_STATUS" != "ok" ]]; then
|
||||
STATUS="degraded"
|
||||
CONTRACT_STATUS="degraded"
|
||||
fi
|
||||
|
||||
case "$AGENT_SMOKE_STATUS" in
|
||||
ok|ready|success)
|
||||
;;
|
||||
*)
|
||||
STATUS="degraded"
|
||||
CONTRACT_STATUS="degraded"
|
||||
;;
|
||||
esac
|
||||
|
||||
if bash "$ROOT_DIR/tools/cockpit/mascarade_dispatch_mesh.sh" --action route --profile "$AGENT" --json >"$ROUTING_CAPTURE" 2>>"$RUN_LOG"; then
|
||||
ROUTING_STATUS="$(python3 - "$ROUTING_CAPTURE" <<'PY'
|
||||
import json, sys
|
||||
from pathlib import Path
|
||||
path = Path(sys.argv[1])
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
print("invalid")
|
||||
raise SystemExit(0)
|
||||
print(data.get("status", "unknown"))
|
||||
PY
|
||||
)"
|
||||
ARTIFACTS+=("$ROUTING_CAPTURE")
|
||||
else
|
||||
ROUTING_STATUS="failed"
|
||||
append_reason "le dispatch mesh Mascarade n'a pas pu etre calcule pour $AGENT"
|
||||
append_next_step "Relancer bash tools/cockpit/mascarade_dispatch_mesh.sh --action route --profile $AGENT --json."
|
||||
fi
|
||||
|
||||
if [[ "$STATUS" == "ok" && "$ROUTING_STATUS" == "ok" ]]; then
|
||||
TRUST_LEVEL="verified"
|
||||
elif [[ "$ROUTING_STATUS" == "ok" ]]; then
|
||||
TRUST_LEVEL="bounded"
|
||||
else
|
||||
TRUST_LEVEL="inferred"
|
||||
fi
|
||||
|
||||
if bash "$ROOT_DIR/tools/cockpit/write_kill_life_memory_entry.sh" \
|
||||
--component "mascarade_runtime_health" \
|
||||
--status "$CONTRACT_STATUS" \
|
||||
--owner "Runtime-Companion" \
|
||||
--decision-action "mascarade-runtime-health-check" \
|
||||
--decision-reason "Mascarade runtime state is checked against the active mesh routing contract." \
|
||||
--next-step "Review $RUN_JSON and continue from $RESUME_REF." \
|
||||
--resume-ref "$RESUME_REF" \
|
||||
--trust-level "$TRUST_LEVEL" \
|
||||
--routing-file "$ROUTING_CAPTURE" \
|
||||
--artifact "$RUN_LOG" \
|
||||
--artifact "$RUN_JSON" \
|
||||
--artifact "$SMOKE_CAPTURE" \
|
||||
--json >"$MEMORY_CAPTURE" 2>>"$RUN_LOG"; then
|
||||
MEMORY_STATUS="$(python3 - "$MEMORY_CAPTURE" <<'PY'
|
||||
import json, sys
|
||||
from pathlib import Path
|
||||
path = Path(sys.argv[1])
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
print("invalid")
|
||||
raise SystemExit(0)
|
||||
print(data.get("status", "unknown"))
|
||||
PY
|
||||
)"
|
||||
ARTIFACTS+=("$MEMORY_CAPTURE")
|
||||
else
|
||||
MEMORY_STATUS="failed"
|
||||
append_reason "la memoire kill_life n'a pas pu etre ecrite pour mascarade_runtime_health"
|
||||
append_next_step "Relancer bash tools/cockpit/write_kill_life_memory_entry.sh --component mascarade_runtime_health --json."
|
||||
fi
|
||||
|
||||
RUN_AT="$(timestamp_utc)"
|
||||
|
||||
json_from_py \
|
||||
"$RUN_AT" \
|
||||
"$HOST" \
|
||||
"$AGENT" \
|
||||
"$STATUS" \
|
||||
"$CONTRACT_STATUS" \
|
||||
"$SSH_OK" \
|
||||
"$DOCKER_STATUS" \
|
||||
"$API_AGENTS_STATUS" \
|
||||
"$OLLAMA_TAGS_STATUS" \
|
||||
"$AGENT_SMOKE_STATUS" \
|
||||
"$AGENT_SMOKE_PROVIDER" \
|
||||
"$AGENT_SMOKE_MODEL" \
|
||||
"$RUN_LOG" \
|
||||
"$RUN_JSON" \
|
||||
"${CONTAINERS[mascarade-api]}" \
|
||||
"${CONTAINERS[mascarade-core]}" \
|
||||
"${CONTAINERS[mascarade-ollama-runtime]}" \
|
||||
"$ROUTING_CAPTURE" \
|
||||
"$ROUTING_STATUS" \
|
||||
"$MEMORY_CAPTURE" \
|
||||
"$MEMORY_STATUS" \
|
||||
"$TRUST_LEVEL" \
|
||||
"$RESUME_REF" \
|
||||
"${ARTIFACTS[@]}" \
|
||||
--reasons \
|
||||
"${DEGRADED_REASONS[@]}" \
|
||||
--next-steps \
|
||||
"${NEXT_STEPS[@]}" \
|
||||
<<'PY' >"$RUN_JSON"
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
run_at, host, agent, status, contract_status, ssh_ok, docker_status, api_agents_status, ollama_tags_status, agent_smoke_status, agent_smoke_provider, agent_smoke_model, run_log, run_json, api_container, core_container, ollama_container, routing_capture, routing_status, memory_capture, memory_status, trust_level, resume_ref, *tail = sys.argv[1:]
|
||||
|
||||
def split_sections(values):
|
||||
artifacts = []
|
||||
reasons = []
|
||||
next_steps = []
|
||||
mode = "artifacts"
|
||||
for value in values:
|
||||
if value == "--reasons":
|
||||
mode = "reasons"
|
||||
continue
|
||||
if value == "--next-steps":
|
||||
mode = "next_steps"
|
||||
continue
|
||||
if mode == "artifacts":
|
||||
artifacts.append(value)
|
||||
elif mode == "reasons":
|
||||
reasons.append(value)
|
||||
else:
|
||||
next_steps.append(value)
|
||||
return artifacts, reasons, next_steps
|
||||
|
||||
artifacts, degraded_reasons, next_steps = split_sections(tail)
|
||||
|
||||
def load_json(path_str):
|
||||
path = Path(path_str)
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
routing = load_json(routing_capture)
|
||||
memory = load_json(memory_capture)
|
||||
memory_entry = memory.get("entry", {}) if isinstance(memory.get("entry"), dict) else {}
|
||||
|
||||
payload = {
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "mascarade-runtime-health",
|
||||
"action": "health-check",
|
||||
"status": status,
|
||||
"contract_status": contract_status,
|
||||
"checked_at": run_at,
|
||||
"host": host,
|
||||
"agent": agent,
|
||||
"owner": "Runtime-Companion",
|
||||
"decision": {
|
||||
"action": "mascarade-runtime-health-check",
|
||||
"reason": "Mascarade runtime state checked and projected on the active mesh routing contract."
|
||||
},
|
||||
"resume_ref": resume_ref,
|
||||
"trust_level": trust_level,
|
||||
"provider": agent_smoke_provider or "unknown",
|
||||
"model": agent_smoke_model or "unknown",
|
||||
"runtime_status": status,
|
||||
"routing_status": routing_status,
|
||||
"routing_artifact": routing_capture,
|
||||
"routing": routing,
|
||||
"memory_entry_status": memory_status,
|
||||
"memory_entry_artifact": memory_capture,
|
||||
"memory_entry": memory_entry,
|
||||
"checks": {
|
||||
"ssh": {"status": ssh_ok},
|
||||
"docker": {"status": docker_status},
|
||||
"api_agents": {"status": api_agents_status},
|
||||
"ollama_tags": {"status": ollama_tags_status},
|
||||
"agent_smoke": {
|
||||
"status": agent_smoke_status,
|
||||
"provider": agent_smoke_provider or "unknown",
|
||||
"model": agent_smoke_model or "unknown",
|
||||
},
|
||||
"containers": {
|
||||
"mascarade-api": api_container,
|
||||
"mascarade-core": core_container,
|
||||
"mascarade-ollama-runtime": ollama_container,
|
||||
},
|
||||
},
|
||||
"artifacts": artifacts,
|
||||
"degraded_reasons": degraded_reasons,
|
||||
"next_steps": next_steps,
|
||||
"log_file": run_log,
|
||||
"json_file": run_json,
|
||||
}
|
||||
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
PY
|
||||
|
||||
copy_latest_artifacts
|
||||
|
||||
if [[ "$OUTPUT_MODE" == "json" ]]; then
|
||||
cat "$RUN_JSON"
|
||||
else
|
||||
cat <<EOF
|
||||
Mascarade runtime health
|
||||
host: $HOST
|
||||
status: $STATUS
|
||||
agent smoke: $AGENT_SMOKE_STATUS (${AGENT_SMOKE_PROVIDER:-unknown}/${AGENT_SMOKE_MODEL:-unknown})
|
||||
containers: api=${CONTAINERS[mascarade-api]} core=${CONTAINERS[mascarade-core]} ollama=${CONTAINERS[mascarade-ollama-runtime]}
|
||||
artifacts:
|
||||
- $RUN_LOG
|
||||
- $RUN_JSON
|
||||
EOF
|
||||
if [[ ${#DEGRADED_REASONS[@]} -gt 0 ]]; then
|
||||
printf 'degraded reasons:\n'
|
||||
printf ' - %s\n' "${DEGRADED_REASONS[@]}"
|
||||
fi
|
||||
fi
|
||||
Executable
+165
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
LOG_DIR="${ROOT_DIR}/artifacts/cockpit"
|
||||
mkdir -p "${LOG_DIR}"
|
||||
LOG_FILE="${LOG_DIR}/mesh_dirtyset_sync_$(date '+%Y%m%d_%H%M%S').log"
|
||||
|
||||
MODE="apply"
|
||||
JSON=0
|
||||
|
||||
KILL_LIFE_SRC="/Users/electron/Documents/Lelectron_rare/Github_Repos/Perso/Kill_LIFE-main"
|
||||
MASCARADE_SRC="/Users/electron/Documents/Lelectron_rare/Github_Repos/Perso/mascarade-main"
|
||||
CRAZY_SRC="/Users/electron/Documents/Lelectron_rare/Github_Repos/Perso/crazy_life-main"
|
||||
|
||||
TARGETS=(
|
||||
"clems@192.168.0.120|/home/clems/Kill_LIFE-main|/home/clems/mascarade-main|/home/clems/crazy_life-main"
|
||||
"kxkm@kxkm-ai|/home/kxkm/Kill_LIFE-main|/home/kxkm/mascarade-main|/home/kxkm/crazy_life-main"
|
||||
"root@192.168.0.119|/root/Kill_LIFE-main|/root/mascarade-main|/root/crazy_life-main"
|
||||
"cils@100.126.225.111|/Users/cils/Kill_LIFE-main|/Users/cils/mascarade-main|/Users/cils/crazy_life-main"
|
||||
)
|
||||
|
||||
log_line() {
|
||||
local level="$1"
|
||||
shift
|
||||
local msg="${level} $(date '+%Y-%m-%d %H:%M:%S %z') ${*}"
|
||||
printf '%s\n' "${msg}" | tee -a "${LOG_FILE}" >&2
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: bash tools/cockpit/mesh_dirtyset_sync.sh [--dry-run] [--json]
|
||||
|
||||
Synchronise uniquement les fichiers dirty des lanes mesh locales vers les lanes distantes,
|
||||
et purge les artefacts Apple (`._*`, `.DS_Store`) qui polluent les dirty-sets.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run)
|
||||
MODE="dry-run"
|
||||
shift
|
||||
;;
|
||||
--json)
|
||||
JSON=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
should_skip_path() {
|
||||
local rel="$1"
|
||||
case "${rel}" in
|
||||
.DS_Store|*/.DS_Store|._*|*/._*|artifacts/cockpit/*.log|artifacts/cockpit/*.json)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
collect_sync_list() {
|
||||
local repo_root="$1"
|
||||
local out_file="$2"
|
||||
: > "${out_file}"
|
||||
|
||||
while IFS= read -r rel; do
|
||||
[[ -n "${rel}" ]] || continue
|
||||
should_skip_path "${rel}" && continue
|
||||
printf '%s\n' "${rel}" >> "${out_file}"
|
||||
done < <(git -C "${repo_root}" ls-files -m -o --exclude-standard)
|
||||
}
|
||||
|
||||
collect_delete_list() {
|
||||
local repo_root="$1"
|
||||
local out_file="$2"
|
||||
: > "${out_file}"
|
||||
|
||||
while IFS= read -r rel; do
|
||||
[[ -n "${rel}" ]] || continue
|
||||
should_skip_path "${rel}" && continue
|
||||
printf '%s\n' "${rel}" >> "${out_file}"
|
||||
done < <(git -C "${repo_root}" ls-files -d)
|
||||
}
|
||||
|
||||
clean_local_apple_junk() {
|
||||
local repo_root="$1"
|
||||
find "${repo_root}" \( -name '._*' -o -name '.DS_Store' \) -type f -delete 2>/dev/null || true
|
||||
}
|
||||
|
||||
clean_remote_apple_junk() {
|
||||
local host="$1"
|
||||
local repo_root="$2"
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=5 "${host}" \
|
||||
"find '${repo_root}' \\( -name '._*' -o -name '.DS_Store' \\) -type f -delete 2>/dev/null || true" >/dev/null
|
||||
}
|
||||
|
||||
sync_repo_dirtyset() {
|
||||
local repo_name="$1"
|
||||
local src_root="$2"
|
||||
local host="$3"
|
||||
local dst_root="$4"
|
||||
local sync_list delete_list sync_count delete_count
|
||||
|
||||
sync_list="$(mktemp)"
|
||||
delete_list="$(mktemp)"
|
||||
collect_sync_list "${src_root}" "${sync_list}"
|
||||
collect_delete_list "${src_root}" "${delete_list}"
|
||||
clean_local_apple_junk "${src_root}"
|
||||
|
||||
sync_count="$(wc -l < "${sync_list}" | tr -d ' ')"
|
||||
delete_count="$(wc -l < "${delete_list}" | tr -d ' ')"
|
||||
|
||||
log_line "INFO" "repo=${repo_name} target=${host} sync=${sync_count} delete=${delete_count} mode=${MODE}"
|
||||
|
||||
if [[ "${MODE}" == "apply" ]]; then
|
||||
clean_remote_apple_junk "${host}" "${dst_root}"
|
||||
|
||||
if [[ "${sync_count}" != "0" ]]; then
|
||||
tar -C "${src_root}" -cf - -T "${sync_list}" | \
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=5 "${host}" \
|
||||
"mkdir -p '${dst_root}' && tar -C '${dst_root}' -xf -" >/dev/null
|
||||
fi
|
||||
|
||||
if [[ "${delete_count}" != "0" ]]; then
|
||||
while IFS= read -r rel; do
|
||||
[[ -n "${rel}" ]] || continue
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=5 "${host}" \
|
||||
"rm -f '${dst_root}/${rel}'" >/dev/null
|
||||
done < "${delete_list}"
|
||||
fi
|
||||
|
||||
clean_remote_apple_junk "${host}" "${dst_root}"
|
||||
fi
|
||||
|
||||
rm -f "${sync_list}" "${delete_list}"
|
||||
}
|
||||
|
||||
for target in "${TARGETS[@]}"; do
|
||||
IFS='|' read -r host kill_dst masc_dst crazy_dst <<< "${target}"
|
||||
sync_repo_dirtyset "Kill_LIFE" "${KILL_LIFE_SRC}" "${host}" "${kill_dst}"
|
||||
sync_repo_dirtyset "mascarade" "${MASCARADE_SRC}" "${host}" "${masc_dst}"
|
||||
sync_repo_dirtyset "crazy_life" "${CRAZY_SRC}" "${host}" "${crazy_dst}"
|
||||
done
|
||||
|
||||
if [[ "${JSON}" -eq 1 ]]; then
|
||||
printf '{\n'
|
||||
printf ' "generated_at": "%s",\n' "$(date '+%Y-%m-%d %H:%M:%S %z')"
|
||||
printf ' "mode": "%s",\n' "${MODE}"
|
||||
printf ' "targets": %s,\n' "${#TARGETS[@]}"
|
||||
printf ' "log_file": "%s"\n' "${LOG_FILE}"
|
||||
printf '}\n'
|
||||
else
|
||||
log_line "INFO" "Dirty-set sync complete"
|
||||
fi
|
||||
Executable
+315
@@ -0,0 +1,315 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
|
||||
source "${ROOT_DIR}/tools/cockpit/json_contract.sh"
|
||||
ARTIFACT_DIR="${ROOT_DIR}/artifacts/cockpit"
|
||||
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
|
||||
REPORT_DIR="${ARTIFACT_DIR}/health_reports"
|
||||
mkdir -p "${REPORT_DIR}"
|
||||
REGISTRY_FILE="${ROOT_DIR}/specs/contracts/machine_registry.mesh.json"
|
||||
|
||||
JSON=0
|
||||
LOAD_PROFILE="tower-first"
|
||||
RETENTION_DAYS="7"
|
||||
OUTPUT_STATUS="blocked"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: bash tools/cockpit/mesh_health_check.sh [options]
|
||||
|
||||
Options:
|
||||
--load-profile <tower-first|photon-safe> Mode de précheck P2P (défaut: tower-first)
|
||||
--photon-safe Alias de --load-profile photon-safe
|
||||
--retention-days N Retention utilisée pour la lecture log_ops
|
||||
--json Sortie JSON
|
||||
-h, --help Affiche cette aide
|
||||
|
||||
Cette commande exécute en chaîne:
|
||||
1) mesh_sync_preflight --json
|
||||
2) readme_repo_coherence.sh audit
|
||||
3) log_ops.sh summary --json
|
||||
La sortie JSON contient le `mesh_report` et l'ordre `host_order` du préflight.
|
||||
|
||||
Retourne un statut consolidé: ready | degraded | blocked.
|
||||
USAGE
|
||||
}
|
||||
|
||||
parse_json_field() {
|
||||
local file="$1"
|
||||
local field="$2"
|
||||
local raw=""
|
||||
|
||||
raw="$(sed -n "s/.*\"${field}\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" "${file}" | head -n1 || true)"
|
||||
if [[ -z "${raw}" ]]; then
|
||||
raw="$(sed -n "s/.*\"${field}\"[[:space:]]*:[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p" "${file}" | head -n1 || true)"
|
||||
fi
|
||||
printf '%s' "${raw}"
|
||||
}
|
||||
|
||||
normalize_log_status() {
|
||||
local status="$1"
|
||||
case "${status}" in
|
||||
done|ok|ready)
|
||||
echo "ready"
|
||||
;;
|
||||
degraded|warn|warning)
|
||||
echo "degraded"
|
||||
;;
|
||||
blocked|error|fail|failed)
|
||||
echo "blocked"
|
||||
;;
|
||||
*)
|
||||
echo "unknown"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
parse_json_array_string() {
|
||||
local file="$1"
|
||||
local field="$2"
|
||||
awk -v field="\"${field}\"" '
|
||||
match($0, field"[[:space:]]*:[[:space:]]*\\[") { capture=1; line=$0; next }
|
||||
capture {
|
||||
line = line "\n" $0
|
||||
if ($0 ~ /\]/) {
|
||||
gsub(/^[[:space:]]*/, "", line)
|
||||
start = index(line, "[")
|
||||
end = index(line, "]")
|
||||
if (start > 0 && end > start) {
|
||||
value = substr(line, start + 1, end - start - 1)
|
||||
gsub(/"/, "", value)
|
||||
gsub(/[[:space:]]/, "", value)
|
||||
print value
|
||||
}
|
||||
exit
|
||||
}
|
||||
}
|
||||
' "${file}"
|
||||
}
|
||||
|
||||
json_array_count() {
|
||||
local value="$1"
|
||||
if [[ -z "${value}" ]]; then
|
||||
printf '0'
|
||||
return
|
||||
fi
|
||||
printf '%s' "${value}" | awk 'BEGIN{FS=","} {print NF}'
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--load-profile)
|
||||
LOAD_PROFILE="${2:-}"
|
||||
if [[ -z "${LOAD_PROFILE}" || ! "${LOAD_PROFILE}" =~ ^(tower-first|photon-safe)$ ]]; then
|
||||
echo "[error] --load-profile requires tower-first|photon-safe" >&2
|
||||
exit 2
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
--photon-safe)
|
||||
LOAD_PROFILE="photon-safe"
|
||||
shift
|
||||
;;
|
||||
--retention-days)
|
||||
RETENTION_DAYS="${2:-7}"
|
||||
if ! [[ "${RETENTION_DAYS}" =~ ^[0-9]+$ ]]; then
|
||||
echo "[error] --retention-days requires an integer" >&2
|
||||
exit 2
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
JSON=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
mesh_report="${REPORT_DIR}/mesh_health_check_mesh_${TIMESTAMP}.json"
|
||||
readme_report="${ROOT_DIR}/artifacts/doc/readme_repo_audit_${TIMESTAMP}.md"
|
||||
log_report="${REPORT_DIR}/mesh_health_check_logops_${TIMESTAMP}.json"
|
||||
registry_report="${REPORT_DIR}/mesh_health_check_registry_${TIMESTAMP}.json"
|
||||
|
||||
if bash "${ROOT_DIR}/tools/cockpit/machine_registry.sh" --action summary --json > "${registry_report}" 2>&1; then
|
||||
registry_rc=0
|
||||
else
|
||||
registry_rc=$?
|
||||
fi
|
||||
registry_status="$(normalize_log_status "$(parse_json_field "${registry_report}" status)")"
|
||||
if [[ "${registry_status}" == "unknown" ]]; then
|
||||
if (( registry_rc == 0 )); then
|
||||
registry_status="ready"
|
||||
else
|
||||
registry_status="blocked"
|
||||
fi
|
||||
fi
|
||||
registry_target_count="$(parse_json_field "${registry_report}" target_count)"
|
||||
registry_default_profile="$(parse_json_field "${registry_report}" default_profile)"
|
||||
if [[ -z "${registry_target_count}" ]]; then
|
||||
registry_target_count="0"
|
||||
fi
|
||||
if [[ -z "${registry_default_profile}" ]]; then
|
||||
registry_default_profile="unknown"
|
||||
fi
|
||||
|
||||
if bash "${ROOT_DIR}/tools/cockpit/mesh_sync_preflight.sh" --json --no-log --load-profile "${LOAD_PROFILE}" >"${mesh_report}" 2>&1; then
|
||||
mesh_rc=0
|
||||
else
|
||||
mesh_rc=$?
|
||||
fi
|
||||
mesh_status="$(parse_json_field "${mesh_report}" mesh_status)"
|
||||
mesh_host_order="$(parse_json_array_string "${mesh_report}" host_order)"
|
||||
mesh_host_order_count="$(json_array_count "${mesh_host_order}")"
|
||||
if [[ -z "${mesh_status}" ]]; then
|
||||
mesh_status="blocked"
|
||||
if (( mesh_rc != 0 )); then
|
||||
mesh_status="blocked"
|
||||
else
|
||||
mesh_status="degraded"
|
||||
fi
|
||||
fi
|
||||
|
||||
if bash "${ROOT_DIR}/tools/doc/readme_repo_coherence.sh" audit --report "${readme_report}" > /dev/null 2>&1; then
|
||||
readme_rc=0
|
||||
else
|
||||
readme_rc=$?
|
||||
fi
|
||||
if [[ -f "${readme_report}" ]]; then
|
||||
readme_findings="$(awk '/^-[[:space:]]*Findings:/ { if (match($0, /[0-9]+/)) { print substr($0, RSTART, RLENGTH); exit } }' "${readme_report}" || true)"
|
||||
if [[ -z "${readme_findings}" ]]; then
|
||||
readme_findings="$(grep -m1 'Findings:' "${readme_report}" | tr -cd '0-9' || true)"
|
||||
if [[ -z "${readme_findings}" ]]; then
|
||||
readme_findings="0"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
readme_findings="0"
|
||||
readme_report=""
|
||||
fi
|
||||
if [[ "${readme_rc}" -eq 0 ]]; then
|
||||
readme_status="ready"
|
||||
else
|
||||
readme_status="degraded"
|
||||
fi
|
||||
if [[ -n "${readme_findings}" && "${readme_findings}" != "0" ]]; then
|
||||
readme_status="degraded"
|
||||
fi
|
||||
|
||||
if bash "${ROOT_DIR}/tools/cockpit/log_ops.sh" --action summary --json --retention-days "${RETENTION_DAYS}" > "${log_report}" 2>&1; then
|
||||
log_rc=0
|
||||
else
|
||||
log_rc=$?
|
||||
fi
|
||||
log_status="$(normalize_log_status "$(parse_json_field "${log_report}" status)")"
|
||||
log_stale="$(parse_json_field "${log_report}" stale)"
|
||||
if [[ -z "${log_stale}" ]]; then
|
||||
log_stale="0"
|
||||
fi
|
||||
if [[ "${log_status}" == "unknown" ]]; then
|
||||
log_status="degraded"
|
||||
fi
|
||||
|
||||
if [[ "${mesh_status}" == "blocked" || "${readme_status}" == "blocked" || "${log_status}" == "blocked" || "${registry_status}" == "blocked" ]]; then
|
||||
OUTPUT_STATUS="blocked"
|
||||
elif [[ "${mesh_status}" == "degraded" || "${readme_status}" == "degraded" || "${log_status}" == "degraded" || "${registry_status}" == "degraded" ]]; then
|
||||
OUTPUT_STATUS="degraded"
|
||||
else
|
||||
OUTPUT_STATUS="ready"
|
||||
fi
|
||||
|
||||
if [[ "${mesh_status}" == "${readme_status}" && "${mesh_status}" == "${log_status}" && "${mesh_status}" == "${registry_status}" ]]; then
|
||||
health_consistency="aligned"
|
||||
elif [[ "${mesh_status}" == "blocked" || "${readme_status}" == "blocked" || "${log_status}" == "blocked" || "${registry_status}" == "blocked" ]]; then
|
||||
health_consistency="blocked_divergence"
|
||||
else
|
||||
health_consistency="degraded_divergence"
|
||||
fi
|
||||
|
||||
degraded_reasons=()
|
||||
next_steps=()
|
||||
artifacts=("${registry_report}" "${mesh_report}")
|
||||
[[ -n "${readme_report}" ]] && artifacts+=("${readme_report}")
|
||||
[[ -n "${log_report}" ]] && artifacts+=("${log_report}")
|
||||
|
||||
if [[ "${registry_status}" != "ready" ]]; then
|
||||
degraded_reasons+=("registry-${registry_status}")
|
||||
next_steps+=("bash tools/cockpit/machine_registry.sh --action summary --json")
|
||||
fi
|
||||
if [[ "${mesh_status}" != "ready" ]]; then
|
||||
degraded_reasons+=("mesh-${mesh_status}")
|
||||
next_steps+=("bash tools/cockpit/mesh_sync_preflight.sh --json --load-profile ${LOAD_PROFILE}")
|
||||
fi
|
||||
if [[ "${readme_status}" != "ready" ]]; then
|
||||
degraded_reasons+=("readme-${readme_status}")
|
||||
next_steps+=("bash tools/doc/readme_repo_coherence.sh audit")
|
||||
fi
|
||||
if [[ "${log_status}" != "ready" ]]; then
|
||||
degraded_reasons+=("log-${log_status}")
|
||||
next_steps+=("bash tools/cockpit/log_ops.sh --action purge --retention-days ${RETENTION_DAYS} --apply --json")
|
||||
fi
|
||||
|
||||
contract_status="$(json_contract_map_status "${OUTPUT_STATUS}")"
|
||||
|
||||
if [[ "${JSON}" -eq 1 ]]; then
|
||||
printf '{\n'
|
||||
printf ' "contract_version":"cockpit-v1",\n'
|
||||
printf ' "component":"mesh_health_check",\n'
|
||||
printf ' "action":"summary",\n'
|
||||
printf ' "contract_status":"%s",\n' "${contract_status}"
|
||||
printf ' "generated_at":"%s",\n' "$(date '+%Y-%m-%d %H:%M:%S %z')"
|
||||
printf ' "registry_file":"%s",\n' "${REGISTRY_FILE}"
|
||||
printf ' "artifacts":%s,\n' "$(json_contract_array_from_args "${artifacts[@]}")"
|
||||
printf ' "degraded_reasons":%s,\n' "$(json_contract_array_from_args "${degraded_reasons[@]}")"
|
||||
printf ' "next_steps":%s,\n' "$(json_contract_array_from_args "${next_steps[@]}")"
|
||||
printf ' "mesh_load_profile":"%s",\n' "${LOAD_PROFILE}"
|
||||
printf ' "registry_status":"%s",\n' "${registry_status}"
|
||||
printf ' "registry_target_count":%s,\n' "${registry_target_count}"
|
||||
printf ' "registry_default_profile":"%s",\n' "${registry_default_profile}"
|
||||
printf ' "registry_report":"%s",\n' "${registry_report}"
|
||||
printf ' "mesh_status":"%s",\n' "${mesh_status}"
|
||||
printf ' "mesh_host_order":"%s",\n' "${mesh_host_order:-}"
|
||||
printf ' "mesh_host_order_count":%s,\n' "${mesh_host_order_count:-0}"
|
||||
printf ' "mesh_status_reason_code":"%s",\n' "${mesh_rc}"
|
||||
printf ' "readme_status":"%s",\n' "${readme_status}"
|
||||
printf ' "readme_findings":%s,\n' "${readme_findings:-0}"
|
||||
printf ' "readme_report":"%s",\n' "${readme_report}"
|
||||
printf ' "log_status":"%s",\n' "${log_status}"
|
||||
printf ' "log_stale":%s,\n' "${log_stale}"
|
||||
printf ' "log_retention_days":%s,\n' "${RETENTION_DAYS}"
|
||||
printf ' "health_consistency":"%s",\n' "${health_consistency}"
|
||||
printf ' "status":"%s",\n' "${OUTPUT_STATUS}"
|
||||
printf ' "mesh_report":"%s"\n' "${mesh_report}"
|
||||
printf '}\n'
|
||||
else
|
||||
cat <<EOF_SUMMARY
|
||||
mesh_health_check report:
|
||||
generated_at: $(date '+%Y-%m-%d %H:%M:%S %z')
|
||||
load_profile: ${LOAD_PROFILE}
|
||||
machine_registry: ${registry_status} (targets=${registry_target_count}, default_profile=${registry_default_profile})
|
||||
mesh_sync_preflight: ${mesh_status}
|
||||
mesh_host_order: ${mesh_host_order}
|
||||
readme_repo_coherence: ${readme_status} (findings=${readme_findings:-0})
|
||||
log_ops: ${log_status}
|
||||
consistency: ${health_consistency}
|
||||
status: ${OUTPUT_STATUS}
|
||||
evidence_registry: ${registry_report}
|
||||
evidence mesh-host-order-count: ${mesh_host_order_count}
|
||||
evidence_mesh: ${mesh_report}
|
||||
evidence_readme: ${readme_report}
|
||||
evidence_log: ${log_report}
|
||||
EOF_SUMMARY
|
||||
fi
|
||||
|
||||
if [[ "${OUTPUT_STATUS}" == "blocked" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,380 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# mistral_agents_tui.sh — TUI pour gérer les 4 agents Mistral
|
||||
# Contrat: cockpit-v1
|
||||
# Lot: 23 — Intégration Mistral Agents
|
||||
# Date: 2026-03-21
|
||||
# MAJ: 2026-03-22 — Migration Beta Conversations API (T-MA-037)
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
MISTRAL_API_KEY="${MISTRAL_API_KEY:-}"
|
||||
MISTRAL_BASE="https://api.mistral.ai/v1"
|
||||
|
||||
# API mode: beta (Conversations API) ou deprecated (agents/completions)
|
||||
API_MODE="${MISTRAL_API_MODE:-beta}"
|
||||
|
||||
# Couleurs
|
||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'
|
||||
CYAN='\033[0;36m'; BOLD='\033[1m'; DIM='\033[2m'; NC='\033[0m'
|
||||
|
||||
# Agents avec IDs réels (console.mistral.ai)
|
||||
AGENTS=(
|
||||
"ag_019d124c302375a8bf06f9ff8a99fb5f:Sentinelle:Ops/Monitoring:mistral-medium-latest:0.1"
|
||||
"ag_019d124e760877359ad3ff5031179ebc:Tower:Commercial/CRM:magistral-medium-latest:0.4"
|
||||
"ag_019d1251023f73258b80ac73f90458f6:Forge:Fine-tune Pipeline:codestral-latest:0.21"
|
||||
"ag_019d125348eb77e880df33acbd395efa:Devstral:Code/Dev Workflow:devstral-latest:0.17"
|
||||
)
|
||||
|
||||
# --- Fonctions utilitaires ---
|
||||
log_json() {
|
||||
local component="$1" action="$2" status="$3"
|
||||
shift 3
|
||||
local artifacts="${*:-}"
|
||||
cat <<EOF
|
||||
{
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "$component",
|
||||
"action": "$action",
|
||||
"status": "$status",
|
||||
"api_mode": "$API_MODE",
|
||||
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"artifacts": [${artifacts}]
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
check_api_key() {
|
||||
if [ -z "$MISTRAL_API_KEY" ]; then
|
||||
echo -e "${RED}ERREUR: MISTRAL_API_KEY non définie${NC}" >&2
|
||||
echo " export MISTRAL_API_KEY=your_key" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# --- API Abstraction Layer ---
|
||||
# Appel agent via Beta Conversations API (préféré)
|
||||
_call_beta() {
|
||||
local agent_id="$1" message="$2" timeout="${3:-30}"
|
||||
curl -s -X POST "${MISTRAL_BASE}/conversations" \
|
||||
-H "Authorization: Bearer $MISTRAL_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"agent_id\": \"${agent_id}\",
|
||||
\"inputs\": [{\"role\": \"user\", \"content\": ${message}}]
|
||||
}" \
|
||||
--max-time "$timeout" 2>/dev/null || echo '{"error":"timeout"}'
|
||||
}
|
||||
|
||||
# Appel agent via deprecated agents/completions (fallback)
|
||||
_call_deprecated() {
|
||||
local agent_id="$1" message="$2" timeout="${3:-30}"
|
||||
curl -s -X POST "${MISTRAL_BASE}/agents/${agent_id}/completions" \
|
||||
-H "Authorization: Bearer $MISTRAL_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"messages\":[{\"role\":\"user\",\"content\":${message}}]}" \
|
||||
--max-time "$timeout" 2>/dev/null || echo '{"error":"timeout"}'
|
||||
}
|
||||
|
||||
# Appel unifié avec fallback automatique
|
||||
call_agent() {
|
||||
local agent_id="$1" message="$2" timeout="${3:-30}"
|
||||
|
||||
if [ "$API_MODE" = "beta" ]; then
|
||||
local response
|
||||
response=$(_call_beta "$agent_id" "$message" "$timeout")
|
||||
# Si erreur Beta, fallback vers deprecated
|
||||
if echo "$response" | grep -q '"error"' || echo "$response" | grep -q '"object":"error"'; then
|
||||
echo -e " ${DIM}(beta → fallback deprecated)${NC}" >&2
|
||||
response=$(_call_deprecated "$agent_id" "$message" "$timeout")
|
||||
fi
|
||||
echo "$response"
|
||||
else
|
||||
_call_deprecated "$agent_id" "$message" "$timeout"
|
||||
fi
|
||||
}
|
||||
|
||||
# Extraire le contenu de la réponse (compatible beta + deprecated)
|
||||
extract_content() {
|
||||
python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
# Beta Conversations API format
|
||||
if 'outputs' in d:
|
||||
for out in d['outputs']:
|
||||
if out.get('role') == 'assistant':
|
||||
print(out.get('content', 'no content'))
|
||||
sys.exit(0)
|
||||
# Deprecated agents/completions format
|
||||
choices = d.get('choices', [])
|
||||
if choices:
|
||||
print(choices[0].get('message', {}).get('content', 'no content'))
|
||||
sys.exit(0)
|
||||
# Error
|
||||
print(d.get('message', d.get('error', 'no response')))
|
||||
except Exception as e:
|
||||
print(f'parse error: {e}')
|
||||
" 2>/dev/null || echo "parse error"
|
||||
}
|
||||
|
||||
# --- Actions ---
|
||||
action_status() {
|
||||
check_api_key || return 1
|
||||
echo -e "${BOLD}${CYAN}=== Mistral Agents Status ===${NC}"
|
||||
echo -e " Date: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo -e " API: ${BOLD}${API_MODE}${NC} (Conversations API)"
|
||||
echo ""
|
||||
|
||||
local ok=0 fail=0 missing=0
|
||||
|
||||
for entry in "${AGENTS[@]}"; do
|
||||
IFS=':' read -r id name role model temp <<< "$entry"
|
||||
|
||||
# Ping rapide via une question minimale
|
||||
local response
|
||||
response=$(call_agent "$id" '"ping"' 10 2>/dev/null)
|
||||
|
||||
if echo "$response" | grep -q '"error"'; then
|
||||
local err_msg
|
||||
err_msg=$(echo "$response" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('message',d.get('error','unknown')))" 2>/dev/null || echo "unknown")
|
||||
if echo "$err_msg" | grep -qi "not found\|404"; then
|
||||
echo -e " ${YELLOW}○${NC} ${BOLD}${name}${NC} [${model} t=${temp}] (${role}) — ${YELLOW}Not deployed${NC}"
|
||||
((missing++))
|
||||
else
|
||||
echo -e " ${RED}✗${NC} ${BOLD}${name}${NC} [${model} t=${temp}] (${role}) — ${RED}Error: ${err_msg}${NC}"
|
||||
((fail++))
|
||||
fi
|
||||
else
|
||||
echo -e " ${GREEN}●${NC} ${BOLD}${name}${NC} [${model} t=${temp}] (${role}) — ${GREEN}Active${NC}"
|
||||
((ok++))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo -e " Active: ${GREEN}${ok}${NC} | Missing: ${YELLOW}${missing}${NC} | Error: ${RED}${fail}${NC}"
|
||||
echo ""
|
||||
|
||||
log_json "mistral-agents" "status" "$([ "$fail" -eq 0 ] && echo "ok" || echo "degraded")"
|
||||
}
|
||||
|
||||
action_deploy() {
|
||||
check_api_key || return 1
|
||||
local target="${1:-all}"
|
||||
echo -e "${BOLD}${CYAN}=== Deploy Mistral Agents ===${NC}"
|
||||
echo -e " ${DIM}Note: Les agents sont créés via console.mistral.ai, pas via API.${NC}"
|
||||
echo -e " ${DIM}IDs réels configurés dans ce script.${NC}"
|
||||
echo ""
|
||||
|
||||
for entry in "${AGENTS[@]}"; do
|
||||
IFS=':' read -r id name role model temp <<< "$entry"
|
||||
|
||||
if [ "$target" != "all" ] && [ "$target" != "$name" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
echo -e " ${GREEN}●${NC} ${BOLD}${name}${NC} — ${id}"
|
||||
echo -e " Model: ${model} | Temp: ${temp} | Role: ${role}"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo -e " ${YELLOW}→${NC} Pour modifier un agent, aller sur https://console.mistral.ai/build/agents"
|
||||
log_json "mistral-agents" "deploy" "info"
|
||||
}
|
||||
|
||||
action_test() {
|
||||
check_api_key || return 1
|
||||
echo -e "${BOLD}${CYAN}=== Smoke Test Agents (${API_MODE}) ===${NC}"
|
||||
echo ""
|
||||
|
||||
local pass=0 total=0
|
||||
|
||||
for entry in "${AGENTS[@]}"; do
|
||||
IFS=':' read -r id name role model temp <<< "$entry"
|
||||
echo -ne " ${YELLOW}→${NC} Testing ${BOLD}${name}${NC} [${model}]... "
|
||||
((total++))
|
||||
|
||||
local msg
|
||||
msg=$(python3 -c 'import json; print(json.dumps("Health check: respond OK + your role in 10 words max"))')
|
||||
|
||||
local response
|
||||
response=$(call_agent "$id" "$msg" 30 2>/dev/null)
|
||||
|
||||
if echo "$response" | grep -q '"error"'; then
|
||||
echo -e "${RED}FAIL${NC}"
|
||||
local err
|
||||
err=$(echo "$response" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('message',d.get('error','?')))" 2>/dev/null || echo "?")
|
||||
echo -e " ${DIM}${err}${NC}"
|
||||
else
|
||||
local reply
|
||||
reply=$(echo "$response" | extract_content | head -c 80)
|
||||
echo -e "${GREEN}OK${NC} → ${reply}"
|
||||
((pass++))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo -e " Results: ${GREEN}${pass}/${total}${NC} passed"
|
||||
echo ""
|
||||
|
||||
log_json "mistral-agents" "test" "$([ "$pass" -eq "$total" ] && echo "ok" || echo "degraded")"
|
||||
}
|
||||
|
||||
action_chat() {
|
||||
check_api_key || return 1
|
||||
echo -e "${BOLD}${CYAN}=== Agent Chat (${API_MODE}) ===${NC}"
|
||||
echo ""
|
||||
|
||||
echo "Agents disponibles:"
|
||||
local i=1
|
||||
for entry in "${AGENTS[@]}"; do
|
||||
IFS=':' read -r id name role model temp <<< "$entry"
|
||||
echo " ${i}) ${name} (${role}) [${model}]"
|
||||
((i++))
|
||||
done
|
||||
|
||||
read -p "Choix (1-4): " choice
|
||||
local idx=$((choice - 1))
|
||||
if [ "$idx" -lt 0 ] || [ "$idx" -ge "${#AGENTS[@]}" ]; then
|
||||
echo -e "${RED}Choix invalide${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
IFS=':' read -r id name role model temp <<< "${AGENTS[$idx]}"
|
||||
echo -e "Chat avec ${BOLD}${name}${NC} (${model}, API=${API_MODE}). Tapez 'quit' pour sortir."
|
||||
echo ""
|
||||
|
||||
local conversation_id=""
|
||||
|
||||
while true; do
|
||||
read -p "${name}> " user_input
|
||||
[ "$user_input" = "quit" ] && break
|
||||
|
||||
local msg
|
||||
msg=$(echo "$user_input" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))')
|
||||
|
||||
local response
|
||||
response=$(call_agent "$id" "$msg" 60 2>/dev/null)
|
||||
|
||||
# Capturer conversation_id pour les tours suivants (Beta API)
|
||||
if [ "$API_MODE" = "beta" ] && [ -z "$conversation_id" ]; then
|
||||
conversation_id=$(echo "$response" | python3 -c "import sys,json; print(json.load(sys.stdin).get('conversation_id',''))" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
local reply
|
||||
reply=$(echo "$response" | extract_content)
|
||||
|
||||
echo -e "${CYAN}${reply}${NC}"
|
||||
echo ""
|
||||
done
|
||||
}
|
||||
|
||||
action_handoff() {
|
||||
check_api_key || return 1
|
||||
echo -e "${BOLD}${CYAN}=== Agent Handoff Test ===${NC}"
|
||||
echo ""
|
||||
|
||||
# Sentinelle diagnostique → Devstral corrige
|
||||
local from_id from_name to_id to_name
|
||||
IFS=':' read -r from_id from_name _ _ _ <<< "${AGENTS[0]}"
|
||||
IFS=':' read -r to_id to_name _ _ _ <<< "${AGENTS[3]}"
|
||||
|
||||
echo -e " ${YELLOW}→${NC} Handoff: ${BOLD}${from_name}${NC} → ${BOLD}${to_name}${NC}"
|
||||
|
||||
local msg1
|
||||
msg1=$(python3 -c 'import json; print(json.dumps("Diagnostic: le service mascarade retourne HTTP 503. Identifie la cause probable en 2 lignes."))')
|
||||
|
||||
echo -ne " ${from_name} analyse... "
|
||||
local resp1
|
||||
resp1=$(call_agent "$from_id" "$msg1" 30 2>/dev/null)
|
||||
local diag
|
||||
diag=$(echo "$resp1" | extract_content)
|
||||
echo -e "${GREEN}OK${NC}"
|
||||
echo -e " ${DIM}${diag}${NC}"
|
||||
|
||||
local msg2
|
||||
msg2=$(python3 -c "import json; print(json.dumps('Basé sur ce diagnostic: ' + '''$diag''' + ' — Propose un fix en 3 lignes de code.'))")
|
||||
|
||||
echo -ne " ${to_name} corrige... "
|
||||
local resp2
|
||||
resp2=$(call_agent "$to_id" "$msg2" 30 2>/dev/null)
|
||||
local fix
|
||||
fix=$(echo "$resp2" | extract_content)
|
||||
echo -e "${GREEN}OK${NC}"
|
||||
echo -e " ${DIM}${fix}${NC}"
|
||||
|
||||
echo ""
|
||||
log_json "mistral-agents" "handoff" "completed" "\"${from_name}→${to_name}\""
|
||||
}
|
||||
|
||||
# --- Menu principal ---
|
||||
show_menu() {
|
||||
echo -e "${BOLD}${CYAN}"
|
||||
echo "╔══════════════════════════════════════════╗"
|
||||
echo "║ Mistral Agents — Control Panel v2 ║"
|
||||
echo "║ Lot 23 · cockpit-v1 · Beta API ║"
|
||||
echo "╠══════════════════════════════════════════╣"
|
||||
echo "║ 1) Status — État des 4 agents ║"
|
||||
echo "║ 2) Deploy — Info agents déployés ║"
|
||||
echo "║ 3) Test — Smoke test (${API_MODE}) ║"
|
||||
echo "║ 4) Chat — Conversation interactive ║"
|
||||
echo "║ 5) Handoff — Test Sentinelle→Devstral ║"
|
||||
echo "║ 6) API Mode — Toggle beta/deprecated ║"
|
||||
echo "║ q) Quit ║"
|
||||
echo "╚══════════════════════════════════════════╝"
|
||||
echo -e "${NC}"
|
||||
}
|
||||
|
||||
# --- Main ---
|
||||
case "${1:-menu}" in
|
||||
--action)
|
||||
case "${2:-status}" in
|
||||
status) action_status ;;
|
||||
deploy) action_deploy "${3:-all}" ;;
|
||||
test) action_test ;;
|
||||
chat) action_chat ;;
|
||||
handoff) action_handoff ;;
|
||||
*) echo "Actions: status|deploy|test|chat|handoff" ;;
|
||||
esac
|
||||
;;
|
||||
--json)
|
||||
action_status 2>/dev/null | grep -A 999 '{' | head -n -0
|
||||
;;
|
||||
--api-mode)
|
||||
API_MODE="${2:-beta}"
|
||||
echo "API mode: $API_MODE"
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--action status|deploy|test|chat|handoff] [--json] [--api-mode beta|deprecated] [--help]"
|
||||
echo ""
|
||||
echo " --action status Check all 4 Mistral agents"
|
||||
echo " --action deploy Show deployed agents info"
|
||||
echo " --action test Run smoke tests"
|
||||
echo " --action chat Interactive chat with an agent"
|
||||
echo " --action handoff Test Sentinelle→Devstral handoff"
|
||||
echo " --json Output cockpit-v1 JSON only"
|
||||
echo " --api-mode MODE Set API mode: beta (default) or deprecated"
|
||||
echo ""
|
||||
echo "Environment:"
|
||||
echo " MISTRAL_API_KEY Required. Mistral API key."
|
||||
echo " MISTRAL_API_MODE API mode: beta (default) or deprecated."
|
||||
;;
|
||||
*)
|
||||
show_menu
|
||||
read -p "Choix: " choice
|
||||
case "$choice" in
|
||||
1) action_status ;;
|
||||
2) action_deploy ;;
|
||||
3) action_test ;;
|
||||
4) action_chat ;;
|
||||
5) action_handoff ;;
|
||||
6) if [ "$API_MODE" = "beta" ]; then API_MODE="deprecated"; else API_MODE="beta"; fi
|
||||
echo -e "API mode: ${BOLD}${API_MODE}${NC}"
|
||||
;;
|
||||
q|Q) exit 0 ;;
|
||||
*) echo "Choix invalide" ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,907 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# mistral_studio_tui.sh — Interface TUI pour Mistral AI Studio (toutes options)
|
||||
# Contrat: cockpit-v1
|
||||
# Lot: 24 — Intégration Mistral Studio Complète
|
||||
# Date: 2026-03-21
|
||||
#
|
||||
# Couvre: Agents (Beta Conversations API), Batches, IA Documentaire, Audio,
|
||||
# Fine-tune, Fichiers, Libraries (Document Library RAG), Vibe CLI, Codestral
|
||||
# MAJ: 2026-03-22 — Migration Beta API + Libraries + Small 4
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'
|
||||
CYAN='\033[0;36m'; BOLD='\033[1m'; MAGENTA='\033[0;35m'; NC='\033[0m'
|
||||
|
||||
MISTRAL_API_KEY="${MISTRAL_API_KEY:-}"
|
||||
MISTRAL_BASE="https://api.mistral.ai/v1"
|
||||
CODESTRAL_BASE="https://codestral.mistral.ai/v1"
|
||||
LOG_DIR="${LOG_DIR:-/tmp/mistral_studio_logs}"
|
||||
LOG_FILE="${LOG_DIR}/studio_$(date +%Y%m%d_%H%M%S).log"
|
||||
|
||||
mkdir -p "$LOG_DIR" 2>/dev/null || true
|
||||
|
||||
# Agents
|
||||
declare -A AGENTS=(
|
||||
["sentinelle"]="ag_019d124c302375a8bf06f9ff8a99fb5f:mistral-medium-latest:ops-monitoring"
|
||||
["tower"]="ag_019d124e760877359ad3ff5031179ebc:magistral-medium-latest:commercial-crm"
|
||||
["forge"]="ag_019d1251023f73258b80ac73f90458f6:codestral-latest:finetune-pipeline"
|
||||
["devstral"]="ag_019d125348eb77e880df33acbd395efa:devstral-latest:code-workflow"
|
||||
)
|
||||
|
||||
# --- Logging ---
|
||||
log() {
|
||||
local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
||||
echo "$msg" >> "$LOG_FILE" 2>/dev/null || true
|
||||
echo -e "$msg"
|
||||
}
|
||||
|
||||
log_json() {
|
||||
local component="$1" action="$2" status="$3"
|
||||
shift 3
|
||||
local extra="${*:-}"
|
||||
cat <<EOF
|
||||
{
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "mistral-studio-tui",
|
||||
"sub_component": "$component",
|
||||
"action": "$action",
|
||||
"status": "$status",
|
||||
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"log_file": "$LOG_FILE"${extra:+,
|
||||
$extra}
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
check_api_key() {
|
||||
if [ -z "$MISTRAL_API_KEY" ]; then
|
||||
echo -e "${RED}✗ MISTRAL_API_KEY non définie${NC}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
api_call() {
|
||||
local method="$1" endpoint="$2" data="${3:-}"
|
||||
local base="${4:-$MISTRAL_BASE}"
|
||||
|
||||
local args=(-s -w "\n%{http_code}" --max-time 30
|
||||
-H "Authorization: Bearer $MISTRAL_API_KEY"
|
||||
-H "Content-Type: application/json")
|
||||
|
||||
if [ "$method" = "POST" ] && [ -n "$data" ]; then
|
||||
args+=(-X POST -d "$data")
|
||||
elif [ "$method" = "DELETE" ]; then
|
||||
args+=(-X DELETE)
|
||||
fi
|
||||
|
||||
curl "${args[@]}" "${base}${endpoint}" 2>/dev/null
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 1. AGENTS
|
||||
# ============================================================================
|
||||
|
||||
# API mode: "beta" uses /conversations/completions, "deprecated" uses /agents/completions
|
||||
API_MODE="${API_MODE:-beta}"
|
||||
|
||||
# Helper: call agent with beta fallback
|
||||
call_agent_api() {
|
||||
local agent_id="$1" payload="$2"
|
||||
local response http_code
|
||||
|
||||
if [ "$API_MODE" = "beta" ]; then
|
||||
# Try Beta Conversations API first
|
||||
response=$(api_call POST "/conversations/completions" \
|
||||
"$(echo "$payload" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
d['agent_id'] = '${agent_id}'
|
||||
print(json.dumps(d))
|
||||
" 2>/dev/null)")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
if [ "$http_code" = "200" ]; then
|
||||
echo "$response"
|
||||
return 0
|
||||
fi
|
||||
# Fallback to deprecated
|
||||
log "WARN: Beta API failed (HTTP $http_code), falling back to deprecated"
|
||||
fi
|
||||
|
||||
# Deprecated API
|
||||
response=$(api_call POST "/agents/completions" \
|
||||
"$(echo "$payload" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
d['agent_id'] = '${agent_id}'
|
||||
print(json.dumps(d))
|
||||
" 2>/dev/null)")
|
||||
echo "$response"
|
||||
}
|
||||
|
||||
action_agents_status() {
|
||||
echo -e "\n${BOLD}${CYAN}[Agents — Status (API: $API_MODE)]${NC}"
|
||||
check_api_key || return 1
|
||||
|
||||
for name in sentinelle tower forge devstral; do
|
||||
IFS=':' read -r id model role <<< "${AGENTS[$name]}"
|
||||
echo -e " ${BOLD}$name${NC} ($role)"
|
||||
echo -e " Model: ${MAGENTA}$model${NC}"
|
||||
echo -e " ID: $id"
|
||||
|
||||
local response
|
||||
response=$(call_agent_api "$id" '{"messages":[{"role":"user","content":"ping"}],"max_tokens":5}')
|
||||
local http_code
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
|
||||
if [ "$http_code" = "200" ]; then
|
||||
echo -e " Status: ${GREEN}✓ ONLINE${NC}"
|
||||
else
|
||||
echo -e " Status: ${RED}✗ HTTP $http_code${NC}"
|
||||
fi
|
||||
done
|
||||
log "agents_status completed (api=$API_MODE)"
|
||||
}
|
||||
|
||||
action_agents_chat() {
|
||||
local agent="${1:-sentinelle}"
|
||||
echo -e "\n${BOLD}${CYAN}[Agent Chat — $agent (API: $API_MODE)]${NC}"
|
||||
check_api_key || return 1
|
||||
|
||||
IFS=':' read -r id model role <<< "${AGENTS[$agent]}"
|
||||
echo -e " Agent: $agent ($model, $role)"
|
||||
echo -e " Tapez 'quit' pour quitter\n"
|
||||
|
||||
local conv_id=""
|
||||
|
||||
while true; do
|
||||
echo -ne "${GREEN}> ${NC}"
|
||||
read -r user_input
|
||||
[ "$user_input" = "quit" ] && break
|
||||
|
||||
local payload
|
||||
payload=$(python3 -c "
|
||||
import json
|
||||
d = {'messages': [{'role': 'user', 'content': '''$user_input'''}], 'max_tokens': 500}
|
||||
conv_id = '''$conv_id'''
|
||||
if conv_id:
|
||||
d['conversation_id'] = conv_id
|
||||
print(json.dumps(d))
|
||||
" 2>/dev/null)
|
||||
|
||||
local response
|
||||
response=$(call_agent_api "$id" "$payload")
|
||||
local http_code body
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [ "$http_code" = "200" ]; then
|
||||
local content
|
||||
content=$(echo "$body" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
# Capture conversation_id for stateful Beta API
|
||||
cid = d.get('conversation_id', '')
|
||||
if cid:
|
||||
print('CONV_ID:' + cid, file=sys.stderr)
|
||||
print(d.get('choices',[{}])[0].get('message',{}).get('content','no response'))
|
||||
" 2>/dev/null 2> >(grep 'CONV_ID:' | head -1 | sed 's/CONV_ID://'))
|
||||
# Capture conversation_id from stderr for stateful chat
|
||||
local new_conv_id
|
||||
new_conv_id=$(echo "$body" | python3 -c "import sys,json;print(json.load(sys.stdin).get('conversation_id',''))" 2>/dev/null || echo "")
|
||||
[ -n "$new_conv_id" ] && conv_id="$new_conv_id"
|
||||
echo -e "\n${CYAN}$agent${NC}: $content\n"
|
||||
else
|
||||
echo -e "\n${RED}Error HTTP $http_code${NC}\n"
|
||||
fi
|
||||
log "chat:$agent user='$user_input' http=$http_code conv=$conv_id"
|
||||
done
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 2. FICHIERS
|
||||
# ============================================================================
|
||||
|
||||
action_files_list() {
|
||||
echo -e "\n${BOLD}${CYAN}[Fichiers — Liste]${NC}"
|
||||
check_api_key || return 1
|
||||
|
||||
local response
|
||||
response=$(api_call GET "/files")
|
||||
local http_code body
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [ "$http_code" = "200" ]; then
|
||||
python3 -c "
|
||||
import json, sys
|
||||
d = json.loads('''$body''')
|
||||
files = d.get('data', [])
|
||||
if not files:
|
||||
print(' (aucun fichier)')
|
||||
else:
|
||||
for f in files:
|
||||
print(f' {f[\"id\"]:30} {f.get(\"filename\",\"?\"):40} {f.get(\"bytes\",0):>10} bytes {f.get(\"purpose\",\"?\")}')
|
||||
print(f'\n Total: {len(files)} fichiers')
|
||||
" 2>/dev/null
|
||||
else
|
||||
echo -e " ${RED}Erreur HTTP $http_code${NC}"
|
||||
fi
|
||||
log "files_list http=$http_code"
|
||||
}
|
||||
|
||||
action_files_upload() {
|
||||
local filepath="$1" purpose="${2:-fine-tune}"
|
||||
echo -e "\n${BOLD}${CYAN}[Fichiers — Upload]${NC}"
|
||||
check_api_key || return 1
|
||||
|
||||
if [ ! -f "$filepath" ]; then
|
||||
echo -e " ${RED}Fichier non trouvé: $filepath${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo -e " Uploading: $(basename "$filepath") (purpose: $purpose)"
|
||||
|
||||
local response
|
||||
response=$(curl -s -w "\n%{http_code}" --max-time 120 \
|
||||
-H "Authorization: Bearer $MISTRAL_API_KEY" \
|
||||
-F "file=@$filepath" \
|
||||
-F "purpose=$purpose" \
|
||||
"${MISTRAL_BASE}/files" 2>/dev/null)
|
||||
|
||||
local http_code body
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [ "$http_code" = "200" ]; then
|
||||
local file_id
|
||||
file_id=$(echo "$body" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id','?'))" 2>/dev/null)
|
||||
echo -e " ${GREEN}✓ Upload OK${NC} — ID: $file_id"
|
||||
log_json "files" "upload" "success" "\"file_id\": \"$file_id\", \"filename\": \"$(basename "$filepath")\""
|
||||
else
|
||||
echo -e " ${RED}✗ Upload failed HTTP $http_code${NC}"
|
||||
echo " $body"
|
||||
fi
|
||||
log "files_upload file=$(basename "$filepath") purpose=$purpose http=$http_code"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 3. FINE-TUNE
|
||||
# ============================================================================
|
||||
|
||||
action_finetune_list() {
|
||||
echo -e "\n${BOLD}${CYAN}[Fine-tune — Jobs]${NC}"
|
||||
check_api_key || return 1
|
||||
|
||||
local response
|
||||
response=$(api_call GET "/fine_tuning/jobs")
|
||||
local http_code body
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [ "$http_code" = "200" ]; then
|
||||
python3 -c "
|
||||
import json, sys
|
||||
d = json.loads('''$body''')
|
||||
jobs = d.get('data', [])
|
||||
if not jobs:
|
||||
print(' (aucun job)')
|
||||
else:
|
||||
for j in jobs:
|
||||
status = j.get('status','?')
|
||||
color = '32' if status == 'SUCCEEDED' else '33' if status == 'RUNNING' else '31'
|
||||
print(f' \033[{color}m●\033[0m {j.get(\"id\",\"?\"):30} {j.get(\"fine_tuned_model\",\"?\"):30} {status}')
|
||||
print(f'\n Total: {len(jobs)} jobs')
|
||||
" 2>/dev/null
|
||||
else
|
||||
echo -e " ${RED}Erreur HTTP $http_code${NC}"
|
||||
fi
|
||||
log "finetune_list http=$http_code"
|
||||
}
|
||||
|
||||
action_finetune_create() {
|
||||
local model="$1" training_file="$2"
|
||||
local suffix="${3:-v1}"
|
||||
echo -e "\n${BOLD}${CYAN}[Fine-tune — Create Job]${NC}"
|
||||
check_api_key || return 1
|
||||
|
||||
echo -e " Model: $model"
|
||||
echo -e " Training file: $training_file"
|
||||
echo -e " Suffix: $suffix"
|
||||
|
||||
local payload="{
|
||||
\"model\": \"$model\",
|
||||
\"training_files\": [\"$training_file\"],
|
||||
\"suffix\": \"$suffix\",
|
||||
\"hyperparameters\": {
|
||||
\"training_steps\": 100,
|
||||
\"learning_rate\": 1e-5
|
||||
}
|
||||
}"
|
||||
|
||||
local response
|
||||
response=$(api_call POST "/fine_tuning/jobs" "$payload")
|
||||
local http_code body
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [ "$http_code" = "200" ]; then
|
||||
local job_id
|
||||
job_id=$(echo "$body" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id','?'))" 2>/dev/null)
|
||||
echo -e " ${GREEN}✓ Job créé${NC} — ID: $job_id"
|
||||
log_json "finetune" "create" "success" "\"job_id\": \"$job_id\""
|
||||
else
|
||||
echo -e " ${RED}✗ Création failed HTTP $http_code${NC}"
|
||||
echo " $body"
|
||||
fi
|
||||
log "finetune_create model=$model file=$training_file http=$http_code"
|
||||
}
|
||||
|
||||
action_finetune_models() {
|
||||
echo -e "\n${BOLD}${CYAN}[Fine-tune — Modèles personnalisés]${NC}"
|
||||
check_api_key || return 1
|
||||
|
||||
local response
|
||||
response=$(api_call GET "/models")
|
||||
local http_code body
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [ "$http_code" = "200" ]; then
|
||||
python3 -c "
|
||||
import json, sys
|
||||
d = json.loads(sys.stdin.read())
|
||||
models = [m for m in d.get('data', []) if m.get('id','').startswith('ft:')]
|
||||
if not models:
|
||||
print(' (aucun modèle fine-tuné)')
|
||||
else:
|
||||
for m in models:
|
||||
print(f' {m[\"id\"]:40} owner={m.get(\"owned_by\",\"?\")}')
|
||||
print(f'\n Fine-tuned: {len(models)}')
|
||||
" <<< "$body" 2>/dev/null
|
||||
else
|
||||
echo -e " ${RED}Erreur HTTP $http_code${NC}"
|
||||
fi
|
||||
log "finetune_models http=$http_code"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 4. BATCHES
|
||||
# ============================================================================
|
||||
|
||||
action_batches_list() {
|
||||
echo -e "\n${BOLD}${CYAN}[Batches — Liste]${NC}"
|
||||
check_api_key || return 1
|
||||
|
||||
local response
|
||||
response=$(api_call GET "/batches")
|
||||
local http_code body
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [ "$http_code" = "200" ]; then
|
||||
python3 -c "
|
||||
import json, sys
|
||||
d = json.loads('''$body''')
|
||||
batches = d.get('data', [])
|
||||
if not batches:
|
||||
print(' (aucun batch)')
|
||||
else:
|
||||
for b in batches:
|
||||
print(f' {b.get(\"id\",\"?\"):30} {b.get(\"status\",\"?\"):15} {b.get(\"model\",\"?\")}')
|
||||
print(f'\n Total: {len(batches)} batches')
|
||||
" 2>/dev/null
|
||||
else
|
||||
echo -e " ${RED}Erreur HTTP $http_code${NC}"
|
||||
fi
|
||||
log "batches_list http=$http_code"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 5. IA DOCUMENTAIRE (OCR)
|
||||
# ============================================================================
|
||||
|
||||
action_docai_ocr() {
|
||||
local filepath="$1"
|
||||
echo -e "\n${BOLD}${CYAN}[IA Documentaire — OCR]${NC}"
|
||||
check_api_key || return 1
|
||||
|
||||
if [ ! -f "$filepath" ]; then
|
||||
echo -e " ${RED}Fichier non trouvé: $filepath${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo -e " Processing: $(basename "$filepath")"
|
||||
|
||||
# Upload file first, then use document AI
|
||||
local mime_type="application/pdf"
|
||||
[[ "$filepath" == *.png ]] && mime_type="image/png"
|
||||
[[ "$filepath" == *.jpg ]] && mime_type="image/jpeg"
|
||||
|
||||
local response
|
||||
response=$(curl -s -w "\n%{http_code}" --max-time 120 \
|
||||
-H "Authorization: Bearer $MISTRAL_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"model\": \"mistral-ocr-latest\",
|
||||
\"document\": {
|
||||
\"type\": \"document_url\",
|
||||
\"document_url\": \"data:${mime_type};base64,$(base64 -w0 "$filepath")\"
|
||||
}
|
||||
}" \
|
||||
"${MISTRAL_BASE}/ocr" 2>/dev/null)
|
||||
|
||||
local http_code body
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [ "$http_code" = "200" ]; then
|
||||
echo -e " ${GREEN}✓ OCR OK${NC}"
|
||||
python3 -c "
|
||||
import json, sys
|
||||
d = json.loads(sys.stdin.read())
|
||||
pages = d.get('pages', [])
|
||||
for p in pages:
|
||||
print(f' --- Page {p.get(\"index\",\"?\")} ---')
|
||||
text = p.get('markdown', p.get('text', ''))
|
||||
print(text[:500])
|
||||
if len(text) > 500:
|
||||
print(' ...[truncated]')
|
||||
" <<< "$body" 2>/dev/null
|
||||
else
|
||||
echo -e " ${RED}✗ OCR failed HTTP $http_code${NC}"
|
||||
fi
|
||||
log "docai_ocr file=$(basename "$filepath") http=$http_code"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 6. AUDIO (STT)
|
||||
# ============================================================================
|
||||
|
||||
action_audio_transcribe() {
|
||||
local filepath="$1"
|
||||
echo -e "\n${BOLD}${CYAN}[Audio — Transcription]${NC}"
|
||||
check_api_key || return 1
|
||||
|
||||
if [ ! -f "$filepath" ]; then
|
||||
echo -e " ${RED}Fichier non trouvé: $filepath${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo -e " Transcribing: $(basename "$filepath")"
|
||||
|
||||
local response
|
||||
response=$(curl -s -w "\n%{http_code}" --max-time 300 \
|
||||
-H "Authorization: Bearer $MISTRAL_API_KEY" \
|
||||
-F "file=@$filepath" \
|
||||
-F "model=mistral-stt-latest" \
|
||||
"${MISTRAL_BASE}/audio/transcriptions" 2>/dev/null)
|
||||
|
||||
local http_code body
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [ "$http_code" = "200" ]; then
|
||||
echo -e " ${GREEN}✓ Transcription OK${NC}"
|
||||
python3 -c "
|
||||
import json, sys
|
||||
d = json.loads(sys.stdin.read())
|
||||
print(d.get('text', 'no text'))
|
||||
" <<< "$body" 2>/dev/null
|
||||
else
|
||||
echo -e " ${RED}✗ Transcription failed HTTP $http_code${NC}"
|
||||
fi
|
||||
log "audio_transcribe file=$(basename "$filepath") http=$http_code"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 7. CODESTRAL
|
||||
# ============================================================================
|
||||
|
||||
action_codestral_complete() {
|
||||
local prompt="$1"
|
||||
local suffix="${2:-}"
|
||||
echo -e "\n${BOLD}${CYAN}[Codestral — FIM Completion]${NC}"
|
||||
|
||||
local codestral_key="${CODESTRAL_API_KEY:-$MISTRAL_API_KEY}"
|
||||
|
||||
local payload="{\"model\":\"codestral-latest\",\"prompt\":\"$prompt\",\"suffix\":\"$suffix\",\"max_tokens\":200}"
|
||||
|
||||
local response
|
||||
response=$(curl -s -w "\n%{http_code}" --max-time 30 \
|
||||
-H "Authorization: Bearer $codestral_key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" \
|
||||
"${CODESTRAL_BASE}/fim/completions" 2>/dev/null)
|
||||
|
||||
local http_code body
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [ "$http_code" = "200" ]; then
|
||||
echo -e " ${GREEN}✓ Completion OK${NC}"
|
||||
python3 -c "
|
||||
import json, sys
|
||||
d = json.loads(sys.stdin.read())
|
||||
choices = d.get('choices', [])
|
||||
if choices:
|
||||
print(choices[0].get('message',{}).get('content', choices[0].get('text','')))
|
||||
" <<< "$body" 2>/dev/null
|
||||
else
|
||||
echo -e " ${RED}✗ Codestral failed HTTP $http_code${NC}"
|
||||
fi
|
||||
log "codestral_complete http=$http_code"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 8. CONVERSATIONS (Beta API)
|
||||
# ============================================================================
|
||||
|
||||
action_conversations_list() {
|
||||
echo -e "\n${BOLD}${CYAN}[Conversations — Liste (Beta)]${NC}"
|
||||
check_api_key || return 1
|
||||
|
||||
local response
|
||||
response=$(api_call GET "/conversations")
|
||||
local http_code body
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [ "$http_code" = "200" ]; then
|
||||
python3 -c "
|
||||
import json, sys
|
||||
d = json.loads(sys.stdin.read())
|
||||
convs = d.get('data', [])
|
||||
if not convs:
|
||||
print(' (aucune conversation)')
|
||||
else:
|
||||
for c in convs:
|
||||
print(f' {c.get(\"id\",\"?\"):36} agent={c.get(\"agent_id\",\"?\"):36} created={c.get(\"created_at\",\"?\")}')
|
||||
print(f'\n Total: {len(convs)} conversations')
|
||||
" <<< "$body" 2>/dev/null
|
||||
else
|
||||
echo -e " ${RED}Erreur HTTP $http_code${NC} (Beta API may not be available yet)"
|
||||
fi
|
||||
log "conversations_list http=$http_code"
|
||||
}
|
||||
|
||||
action_conversations_create() {
|
||||
local agent="${1:-sentinelle}"
|
||||
echo -e "\n${BOLD}${CYAN}[Conversations — Créer (Beta)]${NC}"
|
||||
check_api_key || return 1
|
||||
|
||||
IFS=':' read -r id model role <<< "${AGENTS[$agent]}"
|
||||
echo -e " Agent: $agent ($id)"
|
||||
|
||||
local response
|
||||
response=$(api_call POST "/conversations" "{\"agent_id\":\"$id\"}")
|
||||
local http_code body
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [ "$http_code" = "200" ]; then
|
||||
local conv_id
|
||||
conv_id=$(echo "$body" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id','?'))" 2>/dev/null)
|
||||
echo -e " ${GREEN}✓ Conversation créée${NC} — ID: $conv_id"
|
||||
log_json "conversations" "create" "success" "\"conversation_id\": \"$conv_id\", \"agent\": \"$agent\""
|
||||
else
|
||||
echo -e " ${RED}✗ Création failed HTTP $http_code${NC} (Beta API may not be available yet)"
|
||||
fi
|
||||
log "conversations_create agent=$agent http=$http_code"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 9. LIBRARIES (Document Library RAG — Beta)
|
||||
# ============================================================================
|
||||
|
||||
action_libraries_list() {
|
||||
echo -e "\n${BOLD}${CYAN}[Libraries — Liste (Beta)]${NC}"
|
||||
check_api_key || return 1
|
||||
|
||||
local response
|
||||
response=$(api_call GET "/libraries")
|
||||
local http_code body
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [ "$http_code" = "200" ]; then
|
||||
python3 -c "
|
||||
import json, sys
|
||||
d = json.loads(sys.stdin.read())
|
||||
libs = d.get('data', [])
|
||||
if not libs:
|
||||
print(' (aucune library)')
|
||||
else:
|
||||
for l in libs:
|
||||
print(f' {l.get(\"id\",\"?\"):36} {l.get(\"name\",\"?\"):30} docs={l.get(\"document_count\",0)}')
|
||||
print(f'\n Total: {len(libs)} libraries')
|
||||
" <<< "$body" 2>/dev/null
|
||||
else
|
||||
echo -e " ${RED}Erreur HTTP $http_code${NC} (Beta API may not be available yet)"
|
||||
fi
|
||||
log "libraries_list http=$http_code"
|
||||
}
|
||||
|
||||
action_libraries_create() {
|
||||
local name="$1" description="${2:-}"
|
||||
echo -e "\n${BOLD}${CYAN}[Libraries — Créer (Beta)]${NC}"
|
||||
check_api_key || return 1
|
||||
|
||||
echo -e " Name: $name"
|
||||
[ -n "$description" ] && echo -e " Description: $description"
|
||||
|
||||
local payload
|
||||
payload=$(python3 -c "
|
||||
import json
|
||||
d = {'name': '''$name'''}
|
||||
desc = '''$description'''
|
||||
if desc:
|
||||
d['description'] = desc
|
||||
print(json.dumps(d))
|
||||
" 2>/dev/null)
|
||||
|
||||
local response
|
||||
response=$(api_call POST "/libraries" "$payload")
|
||||
local http_code body
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [ "$http_code" = "200" ]; then
|
||||
local lib_id
|
||||
lib_id=$(echo "$body" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id','?'))" 2>/dev/null)
|
||||
echo -e " ${GREEN}✓ Library créée${NC} — ID: $lib_id"
|
||||
log_json "libraries" "create" "success" "\"library_id\": \"$lib_id\", \"name\": \"$name\""
|
||||
else
|
||||
echo -e " ${RED}✗ Création failed HTTP $http_code${NC}"
|
||||
fi
|
||||
log "libraries_create name=$name http=$http_code"
|
||||
}
|
||||
|
||||
action_libraries_add_doc() {
|
||||
local library_id="$1" file_id="$2"
|
||||
echo -e "\n${BOLD}${CYAN}[Libraries — Ajouter Document]${NC}"
|
||||
check_api_key || return 1
|
||||
|
||||
local response
|
||||
response=$(api_call POST "/libraries/$library_id/documents" "{\"file_id\":\"$file_id\"}")
|
||||
local http_code body
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [ "$http_code" = "200" ]; then
|
||||
echo -e " ${GREEN}✓ Document ajouté${NC}"
|
||||
log_json "libraries" "add_document" "success" "\"library_id\": \"$library_id\", \"file_id\": \"$file_id\""
|
||||
else
|
||||
echo -e " ${RED}✗ Ajout failed HTTP $http_code${NC}"
|
||||
fi
|
||||
log "libraries_add_doc lib=$library_id file=$file_id http=$http_code"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 10. MODELS CATALOG
|
||||
# ============================================================================
|
||||
|
||||
action_models_list() {
|
||||
echo -e "\n${BOLD}${CYAN}[Modèles — Catalogue]${NC}"
|
||||
check_api_key || return 1
|
||||
|
||||
local response
|
||||
response=$(api_call GET "/models")
|
||||
local http_code body
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [ "$http_code" = "200" ]; then
|
||||
python3 -c "
|
||||
import json, sys
|
||||
d = json.loads(sys.stdin.read())
|
||||
models = sorted(d.get('data', []), key=lambda m: m.get('id', ''))
|
||||
ft_models = [m for m in models if m.get('id','').startswith('ft:')]
|
||||
base_models = [m for m in models if not m.get('id','').startswith('ft:')]
|
||||
|
||||
print(' === Base Models ===')
|
||||
for m in base_models:
|
||||
print(f' {m[\"id\"]:45} owner={m.get(\"owned_by\",\"?\")}')
|
||||
print(f' Total: {len(base_models)} base models')
|
||||
|
||||
if ft_models:
|
||||
print('\n === Fine-tuned Models ===')
|
||||
for m in ft_models:
|
||||
print(f' {m[\"id\"]:45} owner={m.get(\"owned_by\",\"?\")}')
|
||||
print(f' Total: {len(ft_models)} fine-tuned')
|
||||
" <<< "$body" 2>/dev/null
|
||||
else
|
||||
echo -e " ${RED}Erreur HTTP $http_code${NC}"
|
||||
fi
|
||||
log "models_list http=$http_code"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 11. LOGS
|
||||
# ============================================================================
|
||||
|
||||
action_logs_view() {
|
||||
echo -e "\n${BOLD}${CYAN}[Logs — Derniers]${NC}"
|
||||
if [ -f "$LOG_FILE" ]; then
|
||||
tail -20 "$LOG_FILE"
|
||||
else
|
||||
echo " (aucun log)"
|
||||
fi
|
||||
echo -e "\n Log dir: $LOG_DIR"
|
||||
echo -e " Fichiers: $(ls "$LOG_DIR"/*.log 2>/dev/null | wc -l) logs"
|
||||
}
|
||||
|
||||
action_logs_purge() {
|
||||
echo -e "\n${BOLD}${CYAN}[Logs — Purge]${NC}"
|
||||
local count
|
||||
count=$(find "$LOG_DIR" -name "*.log" -mtime +7 2>/dev/null | wc -l)
|
||||
find "$LOG_DIR" -name "*.log" -mtime +7 -delete 2>/dev/null || true
|
||||
echo -e " ${GREEN}✓ $count logs supprimés (>7 jours)${NC}"
|
||||
log "logs_purge deleted=$count"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# MENU PRINCIPAL
|
||||
# ============================================================================
|
||||
|
||||
show_menu() {
|
||||
echo ""
|
||||
echo -e "${BOLD}${CYAN}╔══════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BOLD}${CYAN}║ Mistral AI Studio — TUI Cockpit (Lot 24) ║${NC}"
|
||||
echo -e "${BOLD}${CYAN}║ $(date '+%Y-%m-%d %H:%M') API: $API_MODE ║${NC}"
|
||||
echo -e "${BOLD}${CYAN}╚══════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "${BOLD} Agents${NC}"
|
||||
echo " 1) Agents — status 2) Agents — chat"
|
||||
echo ""
|
||||
echo -e "${BOLD} Conversations (Beta)${NC}"
|
||||
echo " 3) Conversations — lister 4) Conversation — créer"
|
||||
echo ""
|
||||
echo -e "${BOLD} Fichiers & Libraries${NC}"
|
||||
echo " 5) Fichiers — lister 6) Fichiers — uploader"
|
||||
echo " 7) Libraries — lister 8) Library — créer"
|
||||
echo " 9) Library — ajouter doc"
|
||||
echo ""
|
||||
echo -e "${BOLD} Fine-tune${NC}"
|
||||
echo " 10) Jobs — lister 11) Job — créer"
|
||||
echo " 12) Modèles personnalisés 13) Catalogue complet"
|
||||
echo ""
|
||||
echo -e "${BOLD} Batches${NC}"
|
||||
echo " 14) Batches — lister"
|
||||
echo ""
|
||||
echo -e "${BOLD} IA${NC}"
|
||||
echo " 15) IA Documentaire — OCR 16) Audio — transcrire"
|
||||
echo " 17) Codestral — complétion"
|
||||
echo ""
|
||||
echo -e "${BOLD} Maintenance${NC}"
|
||||
echo " 18) Logs — voir 19) Logs — purger"
|
||||
echo " 20) Health check complet"
|
||||
echo ""
|
||||
echo " q) Quitter"
|
||||
echo ""
|
||||
}
|
||||
|
||||
action_health_full() {
|
||||
echo -e "\n${BOLD}${CYAN}[Health Check Complet — Mistral Studio (API: $API_MODE)]${NC}"
|
||||
check_api_key || return 1
|
||||
|
||||
action_agents_status
|
||||
action_files_list
|
||||
action_finetune_list
|
||||
action_finetune_models
|
||||
action_batches_list
|
||||
action_libraries_list
|
||||
action_conversations_list
|
||||
|
||||
echo ""
|
||||
log_json "studio" "full-health" "completed" "\"api_mode\": \"$API_MODE\""
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# MAIN
|
||||
# ============================================================================
|
||||
|
||||
# CLI mode
|
||||
case "${1:-}" in
|
||||
--agents-status) action_agents_status ;;
|
||||
--agents-chat) action_agents_chat "${2:-sentinelle}" ;;
|
||||
--conversations-list) action_conversations_list ;;
|
||||
--conversations-create) action_conversations_create "${2:-sentinelle}" ;;
|
||||
--files-list) action_files_list ;;
|
||||
--files-upload) action_files_upload "${2:?filepath required}" "${3:-fine-tune}" ;;
|
||||
--libraries-list) action_libraries_list ;;
|
||||
--libraries-create) action_libraries_create "${2:?name required}" "${3:-}" ;;
|
||||
--libraries-add-doc) action_libraries_add_doc "${2:?library_id required}" "${3:?file_id required}" ;;
|
||||
--finetune-list) action_finetune_list ;;
|
||||
--finetune-create) action_finetune_create "${2:?model}" "${3:?training_file}" "${4:-v1}" ;;
|
||||
--finetune-models) action_finetune_models ;;
|
||||
--models-list) action_models_list ;;
|
||||
--batches-list) action_batches_list ;;
|
||||
--ocr) action_docai_ocr "${2:?filepath required}" ;;
|
||||
--transcribe) action_audio_transcribe "${2:?filepath required}" ;;
|
||||
--codestral) action_codestral_complete "${2:?prompt}" "${3:-}" ;;
|
||||
--health) action_health_full ;;
|
||||
--logs) action_logs_view ;;
|
||||
--purge-logs) action_logs_purge ;;
|
||||
--json) action_health_full 2>/dev/null | grep -A 999 '{' ;;
|
||||
--api-mode) API_MODE="${2:-beta}"; shift ;;&
|
||||
--help|-h)
|
||||
echo "Usage: $0 [action] [args]"
|
||||
echo ""
|
||||
echo " Agents:"
|
||||
echo " --agents-status Status de tous les agents"
|
||||
echo " --agents-chat [name] Chat interactif avec un agent"
|
||||
echo ""
|
||||
echo " Conversations (Beta API):"
|
||||
echo " --conversations-list Lister les conversations"
|
||||
echo " --conversations-create [agent] Créer une conversation"
|
||||
echo ""
|
||||
echo " Fichiers:"
|
||||
echo " --files-list Lister les fichiers"
|
||||
echo " --files-upload FILE [purpose] Uploader un fichier"
|
||||
echo ""
|
||||
echo " Libraries (Beta — Document RAG):"
|
||||
echo " --libraries-list Lister les document libraries"
|
||||
echo " --libraries-create NAME [desc] Créer une library"
|
||||
echo " --libraries-add-doc LIB FILE Ajouter un document à une library"
|
||||
echo ""
|
||||
echo " Fine-tune:"
|
||||
echo " --finetune-list Lister les jobs fine-tune"
|
||||
echo " --finetune-create MODEL FILE Créer un job fine-tune"
|
||||
echo " --finetune-models Lister les modèles fine-tunés"
|
||||
echo " --models-list Catalogue complet des modèles"
|
||||
echo ""
|
||||
echo " Batches / IA:"
|
||||
echo " --batches-list Lister les batches"
|
||||
echo " --ocr FILE OCR d'un document"
|
||||
echo " --transcribe FILE Transcrire un audio"
|
||||
echo " --codestral PROMPT Complétion Codestral"
|
||||
echo ""
|
||||
echo " Maintenance:"
|
||||
echo " --health Health check complet"
|
||||
echo " --logs Voir les logs"
|
||||
echo " --purge-logs Purger logs >7j"
|
||||
echo ""
|
||||
echo " Options globales:"
|
||||
echo " --api-mode beta|deprecated Mode API (défaut: beta)"
|
||||
echo ""
|
||||
;;
|
||||
"")
|
||||
# Mode interactif
|
||||
while true; do
|
||||
show_menu
|
||||
echo -ne "${GREEN}Choix> ${NC}"
|
||||
read -r choice
|
||||
case "$choice" in
|
||||
1) action_agents_status ;;
|
||||
2) echo -ne "Agent (sentinelle/tower/forge/devstral): "; read -r a; action_agents_chat "$a" ;;
|
||||
3) action_conversations_list ;;
|
||||
4) echo -ne "Agent (sentinelle/tower/forge/devstral): "; read -r a; action_conversations_create "$a" ;;
|
||||
5) action_files_list ;;
|
||||
6) echo -ne "Chemin fichier: "; read -r f; echo -ne "Purpose (fine-tune/batch/ocr): "; read -r p; action_files_upload "$f" "${p:-fine-tune}" ;;
|
||||
7) action_libraries_list ;;
|
||||
8) echo -ne "Nom library: "; read -r n; echo -ne "Description: "; read -r d; action_libraries_create "$n" "$d" ;;
|
||||
9) echo -ne "Library ID: "; read -r lid; echo -ne "File ID: "; read -r fid; action_libraries_add_doc "$lid" "$fid" ;;
|
||||
10) action_finetune_list ;;
|
||||
11) echo -ne "Modèle (open-mistral-7b/mistral-small-latest): "; read -r m; echo -ne "Training file ID: "; read -r tf; action_finetune_create "$m" "$tf" ;;
|
||||
12) action_finetune_models ;;
|
||||
13) action_models_list ;;
|
||||
14) action_batches_list ;;
|
||||
15) echo -ne "Chemin document: "; read -r f; action_docai_ocr "$f" ;;
|
||||
16) echo -ne "Chemin audio: "; read -r f; action_audio_transcribe "$f" ;;
|
||||
17) echo -ne "Prompt code: "; read -r p; action_codestral_complete "$p" ;;
|
||||
18) action_logs_view ;;
|
||||
19) action_logs_purge ;;
|
||||
20) action_health_full ;;
|
||||
q|Q) echo "Au revoir."; exit 0 ;;
|
||||
*) echo -e "${RED}Choix invalide${NC}" ;;
|
||||
esac
|
||||
done
|
||||
;;
|
||||
*)
|
||||
echo "Action inconnue: $1 — utiliser --help"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+165
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# mistral_workspace_guard.sh
|
||||
# Guardrail for the single authorized workspace layout used by Kill_LIFE Mistral lots.
|
||||
# Contract: cockpit-v1
|
||||
# Date: 2026-03-22
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
STAMP="$(date +%Y%m%d_%H%M%S)"
|
||||
ARTIFACTS_DIR="${ROOT_DIR}/artifacts/cockpit/mistral_workspace_guard"
|
||||
mkdir -p "${ARTIFACTS_DIR}"
|
||||
|
||||
ACTION="status"
|
||||
JSON_MODE=0
|
||||
|
||||
KILL_LIFE_ROOT="/Users/electron/Documents/Lelectron_rare/Kill_LIFE"
|
||||
MASCARADE_ROOT="/Users/electron/Documents/Projets/mascarade"
|
||||
FORBIDDEN_ROOT="/Users/electron/Documents/Lelectron_rare/Github_Repos/Perso/mascarade-main"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: mistral_workspace_guard.sh [--action status|assert|paths] [--json]
|
||||
|
||||
Actions:
|
||||
status Show current workspace policy state
|
||||
assert Exit non-zero if the policy is violated
|
||||
paths Print the canonical paths only
|
||||
|
||||
Options:
|
||||
--json Emit cockpit-v1 JSON
|
||||
--help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
python3 - "$1" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
print(json.dumps(sys.argv[1]))
|
||||
PY
|
||||
}
|
||||
|
||||
path_exists_json() {
|
||||
local path="$1"
|
||||
if [[ -e "$path" ]]; then
|
||||
printf 'true'
|
||||
else
|
||||
printf 'false'
|
||||
fi
|
||||
}
|
||||
|
||||
build_json() {
|
||||
local status="$1"
|
||||
local action="$2"
|
||||
local reason="$3"
|
||||
local required_kill_life_exists required_mascarade_exists forbidden_exists
|
||||
|
||||
required_kill_life_exists="$(path_exists_json "$KILL_LIFE_ROOT")"
|
||||
required_mascarade_exists="$(path_exists_json "$MASCARADE_ROOT")"
|
||||
forbidden_exists="$(path_exists_json "$FORBIDDEN_ROOT")"
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "mistral-workspace-guard",
|
||||
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"action": "${action}",
|
||||
"status": "${status}",
|
||||
"reason": $(json_escape "${reason}"),
|
||||
"kill_life_root": $(json_escape "${KILL_LIFE_ROOT}"),
|
||||
"mascarade_root": $(json_escape "${MASCARADE_ROOT}"),
|
||||
"forbidden_root": $(json_escape "${FORBIDDEN_ROOT}"),
|
||||
"kill_life_exists": ${required_kill_life_exists},
|
||||
"mascarade_exists": ${required_mascarade_exists},
|
||||
"forbidden_exists": ${forbidden_exists},
|
||||
"policy": {
|
||||
"tracking_root": $(json_escape "${KILL_LIFE_ROOT}"),
|
||||
"active_mascarade_root": $(json_escape "${MASCARADE_ROOT}"),
|
||||
"forbidden_copy": $(json_escape "${FORBIDDEN_ROOT}")
|
||||
}
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
status_report() {
|
||||
local status="ok"
|
||||
local reason="single-workspace policy respected"
|
||||
|
||||
if [[ ! -d "$KILL_LIFE_ROOT" ]]; then
|
||||
status="error"
|
||||
reason="Kill_LIFE root missing"
|
||||
elif [[ ! -d "$MASCARADE_ROOT" ]]; then
|
||||
status="error"
|
||||
reason="Mascarade root missing"
|
||||
elif [[ -e "$FORBIDDEN_ROOT" ]]; then
|
||||
status="degraded"
|
||||
reason="forbidden duplicate present; do not use it"
|
||||
fi
|
||||
|
||||
if [[ "$JSON_MODE" -eq 1 ]]; then
|
||||
build_json "$status" "$ACTION" "$reason"
|
||||
else
|
||||
printf 'Mistral workspace guard\n'
|
||||
printf 'status: %s\n' "$status"
|
||||
printf 'reason: %s\n' "$reason"
|
||||
printf 'tracking_root: %s\n' "$KILL_LIFE_ROOT"
|
||||
printf 'active_mascarade_root: %s\n' "$MASCARADE_ROOT"
|
||||
printf 'forbidden_copy: %s\n' "$FORBIDDEN_ROOT"
|
||||
fi
|
||||
|
||||
local latest_file="${ARTIFACTS_DIR}/latest.json"
|
||||
build_json "$status" "$ACTION" "$reason" > "${ARTIFACTS_DIR}/mistral_workspace_guard_${STAMP}.json"
|
||||
build_json "$status" "$ACTION" "$reason" > "$latest_file"
|
||||
|
||||
[[ "$status" == "error" ]] && return 1
|
||||
[[ "$ACTION" == "assert" && "$status" != "ok" ]] && return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
paths_report() {
|
||||
if [[ "$JSON_MODE" -eq 1 ]]; then
|
||||
build_json "ok" "$ACTION" "canonical paths only"
|
||||
else
|
||||
printf '%s\n' "$KILL_LIFE_ROOT"
|
||||
printf '%s\n' "$MASCARADE_ROOT"
|
||||
printf '%s\n' "$FORBIDDEN_ROOT"
|
||||
fi
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
JSON_MODE=1
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$ACTION" in
|
||||
status|assert)
|
||||
status_report
|
||||
;;
|
||||
paths)
|
||||
paths_report
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown action: %s\n' "$ACTION" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Executable
+135
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ops_erp_registry_tui.sh
|
||||
# Read the ERP / L'electronrare Ops registry for Kill_LIFE.
|
||||
# Contract: cockpit-v1
|
||||
# Date: 2026-03-22
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
REGISTRY_FILE="${REGISTRY_FILE:-${ROOT_DIR}/specs/contracts/ops_kill_life_erp_registry.json}"
|
||||
ACTION="summary"
|
||||
JSON_MODE=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: ops_erp_registry_tui.sh [--action summary|machines|modules|secrets] [--json]
|
||||
|
||||
Actions:
|
||||
summary Show top-level ERP bridge summary
|
||||
machines Show canonical machine table
|
||||
modules Show module-to-layer ownership table
|
||||
secrets Show secret scopes and consumers
|
||||
|
||||
Options:
|
||||
--json Emit cockpit-v1 JSON
|
||||
--help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
emit_view() {
|
||||
local view="$1"
|
||||
python3 - "$REGISTRY_FILE" "$view" "$JSON_MODE" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
registry_path = Path(sys.argv[1])
|
||||
view = sys.argv[2]
|
||||
json_mode = sys.argv[3] == "1"
|
||||
|
||||
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
||||
|
||||
payload = {
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "ops-erp-registry",
|
||||
"action": view,
|
||||
"status": "ok",
|
||||
"registry_file": str(registry_path),
|
||||
"source_contract": data.get("contract_version"),
|
||||
}
|
||||
|
||||
if view == "summary":
|
||||
payload["ops_surface"] = data.get("ops_surface")
|
||||
payload["layers"] = data.get("layers")
|
||||
payload["machines_total"] = len(data.get("machines", []))
|
||||
payload["modules_total"] = len(data.get("modules", []))
|
||||
payload["secret_scopes_total"] = len(data.get("secret_scopes", []))
|
||||
elif view == "machines":
|
||||
payload["machines"] = data.get("machines", [])
|
||||
elif view == "modules":
|
||||
payload["modules"] = data.get("modules", [])
|
||||
elif view == "secrets":
|
||||
payload["secret_scopes"] = data.get("secret_scopes", [])
|
||||
else:
|
||||
raise SystemExit(f"unknown action: {view}")
|
||||
|
||||
if json_mode:
|
||||
print(json.dumps(payload, indent=2))
|
||||
raise SystemExit(0)
|
||||
|
||||
if view == "summary":
|
||||
print("ERP / L'electronrare Ops Registry")
|
||||
print(f"registry: {registry_path}")
|
||||
print(f"ops surface: {data['ops_surface']['url']}")
|
||||
print(f"layers: {len(data.get('layers', []))}")
|
||||
print(f"machines: {len(data.get('machines', []))}")
|
||||
print(f"modules: {len(data.get('modules', []))}")
|
||||
print(f"secret scopes: {len(data.get('secret_scopes', []))}")
|
||||
elif view == "machines":
|
||||
print("Machines")
|
||||
for machine in data.get("machines", []):
|
||||
print(
|
||||
f"- {machine['label']}: {machine['host']} | root={machine['canonical_root']} | "
|
||||
f"role={machine['role']} | priority={machine['priority_order']} | "
|
||||
f"load={machine['load_policy']} | owner={machine['owner_agent']}"
|
||||
)
|
||||
elif view == "modules":
|
||||
print("Modules")
|
||||
for module in data.get("modules", []):
|
||||
print(
|
||||
f"- {module['path']} | layer={module['layer']} | "
|
||||
f"owner={module['owner_agent']} | purpose={module['purpose']}"
|
||||
)
|
||||
elif view == "secrets":
|
||||
print("Secret scopes")
|
||||
for secret in data.get("secret_scopes", []):
|
||||
print(
|
||||
f"- {secret['name']} | env={secret['env_var']} | "
|
||||
f"owner={secret['owner_agent']} | consumer={secret['consumer_scope']}"
|
||||
)
|
||||
PY
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
JSON_MODE=1
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$ACTION" in
|
||||
summary|machines|modules|secrets)
|
||||
emit_view "$ACTION"
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown action: %s\n' "$ACTION" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Executable
+142
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
REGISTRY_PATH="${ROOT_DIR}/specs/contracts/pcb_ai_fab_registry.json"
|
||||
ACTION="summary"
|
||||
OUTPUT_JSON=0
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage: $(basename "$0") [--action summary|tools|gaps|lots|pipeline|readiness] [--json]
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
OUTPUT_JSON=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
python3 - "$REGISTRY_PATH" "$ACTION" "$OUTPUT_JSON" <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
registry_path = pathlib.Path(sys.argv[1])
|
||||
action = sys.argv[2]
|
||||
as_json = sys.argv[3] == "1"
|
||||
|
||||
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
||||
tools = data.get("tools", [])
|
||||
|
||||
if action == "summary":
|
||||
payload = {
|
||||
"status": "ok",
|
||||
"scope": data.get("scope"),
|
||||
"tool_count": len(tools),
|
||||
"tool_ids": [tool["id"] for tool in tools],
|
||||
"gaps": data.get("gaps", []),
|
||||
"recommended_lots": data.get("recommended_lots", []),
|
||||
}
|
||||
elif action == "tools":
|
||||
payload = {
|
||||
"status": "ok",
|
||||
"tools": [
|
||||
{
|
||||
"id": tool["id"],
|
||||
"name": tool["name"],
|
||||
"status": tool["status"],
|
||||
"owner_agent": tool["owner_agent"],
|
||||
"sub_agent": tool["sub_agent"],
|
||||
"fit_with_kill_life": tool.get("fit_with_kill_life", []),
|
||||
}
|
||||
for tool in tools
|
||||
],
|
||||
}
|
||||
elif action == "gaps":
|
||||
payload = {
|
||||
"status": "ok",
|
||||
"gaps": data.get("gaps", []),
|
||||
}
|
||||
elif action == "lots":
|
||||
payload = {
|
||||
"status": "ok",
|
||||
"recommended_lots": data.get("recommended_lots", []),
|
||||
}
|
||||
elif action in {"pipeline", "readiness"}:
|
||||
payload = {
|
||||
"status": "ok",
|
||||
"pipeline": [
|
||||
"T-HP-013: hypnoled bom analysis",
|
||||
"T-RE-297: fab package contract",
|
||||
"T-HP-035 / T-RE-298: kicad-happy parity",
|
||||
"T-RE-296 / T-HP-033: quilter canary",
|
||||
"T-HP-034: pcbdesigner evaluation",
|
||||
"T-MS-002/003 + T-MA-016/017/021: mistral vm lots"
|
||||
],
|
||||
"current_priority": "fab-package-first",
|
||||
"blocking_facts": [
|
||||
"eda providers and agent from plan 26 are not implemented in active Mascarade repo",
|
||||
"Hypnoled assets referenced by TODO 25 are not present in the current checkout"
|
||||
]
|
||||
}
|
||||
else:
|
||||
print(f"Unknown action: {action}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if as_json:
|
||||
print(json.dumps(payload, ensure_ascii=True, indent=2))
|
||||
sys.exit(0)
|
||||
|
||||
if action == "summary":
|
||||
print("PCB AI / FAB stack")
|
||||
print(f"- scope: {payload['scope']}")
|
||||
print(f"- tools: {payload['tool_count']}")
|
||||
print("- ids: " + ", ".join(payload["tool_ids"]))
|
||||
print("- gaps:")
|
||||
for gap in payload["gaps"]:
|
||||
print(f" - {gap}")
|
||||
print("- lots:")
|
||||
for lot in payload["recommended_lots"]:
|
||||
print(f" - {lot['id']} [{lot['status']}] {lot['title']}")
|
||||
elif action == "tools":
|
||||
print("PCB AI tools")
|
||||
for tool in payload["tools"]:
|
||||
print(f"- {tool['id']}: {tool['name']} [{tool['status']}] owner={tool['owner_agent']}/{tool['sub_agent']}")
|
||||
for fit in tool["fit_with_kill_life"]:
|
||||
print(f" - {fit}")
|
||||
elif action == "gaps":
|
||||
print("PCB AI gaps")
|
||||
for gap in payload["gaps"]:
|
||||
print(f"- {gap}")
|
||||
elif action == "lots":
|
||||
print("PCB AI lots")
|
||||
for lot in payload["recommended_lots"]:
|
||||
print(f"- {lot['id']} [{lot['status']}] {lot['title']}")
|
||||
elif action in {"pipeline", "readiness"}:
|
||||
print("PCB AI pipeline")
|
||||
print(f"- priority: {payload['current_priority']}")
|
||||
print("- order:")
|
||||
for item in payload["pipeline"]:
|
||||
print(f" - {item}")
|
||||
print("- blockers:")
|
||||
for item in payload["blocking_facts"]:
|
||||
print(f" - {item}")
|
||||
PY
|
||||
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
CONTRACT_FILE="${ROOT_DIR}/specs/contracts/ops_mascarade_kill_life.contract.json"
|
||||
OUTPUT_DIR="${ROOT_DIR}/artifacts/cockpit/product_contract_audit"
|
||||
|
||||
JSON_ONLY=0
|
||||
MARKDOWN_ONLY=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
bash tools/cockpit/product_contract_audit.sh [--json|--markdown]
|
||||
|
||||
Description:
|
||||
Audit statique du contrat produit ops / Mascarade / kill_life.
|
||||
Le script ne relance pas les lanes runtime ; il vérifie les surfaces
|
||||
cockpit et les points d'entrée source pour s'assurer que les ancrages
|
||||
de continuité et les champs du contrat restent visibles.
|
||||
|
||||
Options:
|
||||
--json Affiche seulement le JSON latest.
|
||||
--markdown Affiche seulement le Markdown latest.
|
||||
-h, --help Affiche cette aide.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--json)
|
||||
JSON_ONLY=1
|
||||
shift
|
||||
;;
|
||||
--markdown)
|
||||
MARKDOWN_ONLY=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "${JSON_ONLY}" == "1" && "${MARKDOWN_ONLY}" == "1" ]]; then
|
||||
printf 'Use only one of --json or --markdown.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p "${OUTPUT_DIR}"
|
||||
|
||||
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
|
||||
JSON_FILE="${OUTPUT_DIR}/product_contract_audit_${TIMESTAMP}.json"
|
||||
MARKDOWN_FILE="${OUTPUT_DIR}/product_contract_audit_${TIMESTAMP}.md"
|
||||
LATEST_JSON="${OUTPUT_DIR}/latest.json"
|
||||
LATEST_MARKDOWN="${OUTPUT_DIR}/latest.md"
|
||||
|
||||
read -r -d '' TARGETS_JSON <<'EOF_TARGETS' || true
|
||||
[
|
||||
{
|
||||
"name": "render_product_contract_handoff",
|
||||
"file": "tools/cockpit/render_product_contract_handoff.sh",
|
||||
"kind": "handoff-runtime",
|
||||
"required": ["--no-refresh", "degraded_reasons", "prereqs_refreshed", "kill_life_refresh_status", "daily_refresh_status"]
|
||||
},
|
||||
{
|
||||
"name": "full_operator_lane",
|
||||
"file": "tools/cockpit/full_operator_lane.sh",
|
||||
"kind": "json-surface",
|
||||
"required": ["owner", "decision", "resume_ref", "trust_level", "routing", "memory_entry", "product_contract_handoff_status", "product_contract_handoff_artifact", "product_contract_handoff_markdown"]
|
||||
},
|
||||
{
|
||||
"name": "run_alignment_daily",
|
||||
"file": "tools/cockpit/run_alignment_daily.sh",
|
||||
"kind": "json-surface",
|
||||
"required": ["owner", "decision", "resume_ref", "trust_level", "routing", "memory_entry", "product_contract_handoff_status", "product_contract_handoff_artifact", "product_contract_handoff_markdown"]
|
||||
},
|
||||
{
|
||||
"name": "mascarade_runtime_health",
|
||||
"file": "tools/cockpit/mascarade_runtime_health.sh",
|
||||
"kind": "json-surface",
|
||||
"required": ["owner", "decision", "resume_ref", "trust_level", "routing", "memory_entry"]
|
||||
},
|
||||
{
|
||||
"name": "mascarade_incidents_tui",
|
||||
"file": "tools/cockpit/mascarade_incidents_tui.sh",
|
||||
"kind": "json-surface",
|
||||
"required": ["resume_ref", "trust_level", "routing", "memory_entry"]
|
||||
},
|
||||
{
|
||||
"name": "mascarade_incident_registry",
|
||||
"file": "tools/cockpit/mascarade_incident_registry.sh",
|
||||
"kind": "registry",
|
||||
"required": ["resume_ref", "trust_level", "routing", "memory_entry"]
|
||||
},
|
||||
{
|
||||
"name": "mascarade_logs_tui",
|
||||
"file": "tools/cockpit/mascarade_logs_tui.sh",
|
||||
"kind": "logs",
|
||||
"required": ["resume_ref", "trust_level", "routing", "memory_entry"]
|
||||
},
|
||||
{
|
||||
"name": "render_daily_operator_summary",
|
||||
"file": "tools/cockpit/render_daily_operator_summary.sh",
|
||||
"kind": "handoff",
|
||||
"required": ["trust_level", "resume_ref", "routing", "memory_entry"]
|
||||
},
|
||||
{
|
||||
"name": "render_weekly_refonte_summary",
|
||||
"file": "tools/cockpit/render_weekly_refonte_summary.sh",
|
||||
"kind": "handoff",
|
||||
"required": ["trust_level", "resume_ref", "routing", "memory_entry"]
|
||||
},
|
||||
{
|
||||
"name": "yiacad_operator_index",
|
||||
"file": "tools/cockpit/yiacad_operator_index.sh",
|
||||
"kind": "entrypoint",
|
||||
"required": [
|
||||
"artifacts/cockpit/kill_life_memory/latest.json",
|
||||
"artifacts/cockpit/kill_life_memory/latest.md",
|
||||
"artifacts/cockpit/daily_operator_summary_latest.md",
|
||||
"artifacts/cockpit/product_contract_handoff/latest.json",
|
||||
"artifacts/cockpit/product_contract_handoff/latest.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "intelligence_tui",
|
||||
"file": "tools/cockpit/intelligence_tui.sh",
|
||||
"kind": "governance",
|
||||
"required": ["kill_life_memory", "kill_life_json", "kill_life_markdown"]
|
||||
},
|
||||
{
|
||||
"name": "refonte_tui",
|
||||
"file": "tools/cockpit/refonte_tui.sh",
|
||||
"kind": "entrypoint",
|
||||
"required": [
|
||||
"artifacts/cockpit/kill_life_memory/latest.json",
|
||||
"artifacts/cockpit/kill_life_memory/latest.md",
|
||||
"artifacts/cockpit/daily_operator_summary_latest.md",
|
||||
"artifacts/cockpit/product_contract_handoff/latest.json",
|
||||
"artifacts/cockpit/product_contract_handoff/latest.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "lot_chain",
|
||||
"file": "tools/cockpit/lot_chain.sh",
|
||||
"kind": "pilot-chain",
|
||||
"required": [
|
||||
"artifacts/cockpit/kill_life_memory/latest.json",
|
||||
"artifacts/cockpit/kill_life_memory/latest.md",
|
||||
"T-LC-008"
|
||||
]
|
||||
}
|
||||
]
|
||||
EOF_TARGETS
|
||||
|
||||
PRODUCT_CONTRACT_TARGETS="${TARGETS_JSON}" python3 - "${CONTRACT_FILE}" "${JSON_FILE}" "${MARKDOWN_FILE}" <<'PY'
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
contract_path = pathlib.Path(sys.argv[1])
|
||||
json_path = pathlib.Path(sys.argv[2])
|
||||
markdown_path = pathlib.Path(sys.argv[3])
|
||||
root_dir = contract_path.parents[2]
|
||||
|
||||
contract = json.loads(contract_path.read_text(encoding="utf-8"))
|
||||
targets = json.loads(os.environ["PRODUCT_CONTRACT_TARGETS"])
|
||||
|
||||
required_fields = [
|
||||
entry["name"]
|
||||
for entry in contract.get("contract_fields", [])
|
||||
if entry.get("required")
|
||||
]
|
||||
expected_required = [
|
||||
"status",
|
||||
"decision",
|
||||
"owner",
|
||||
"artifacts",
|
||||
"next_step",
|
||||
"resume_ref",
|
||||
"trust_level",
|
||||
"routing",
|
||||
"memory_entry",
|
||||
]
|
||||
contract_gaps = [field for field in expected_required if field not in required_fields]
|
||||
|
||||
results = []
|
||||
ok_count = 0
|
||||
gap_count = 0
|
||||
missing_count = 0
|
||||
|
||||
for target in targets:
|
||||
file_path = root_dir / target["file"]
|
||||
record = {
|
||||
"name": target["name"],
|
||||
"kind": target["kind"],
|
||||
"file": target["file"],
|
||||
"required": target["required"],
|
||||
"missing": [],
|
||||
}
|
||||
if not file_path.exists():
|
||||
record["status"] = "missing-file"
|
||||
record["missing"] = target["required"]
|
||||
missing_count += 1
|
||||
results.append(record)
|
||||
continue
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
missing = [needle for needle in target["required"] if needle not in content]
|
||||
record["missing"] = missing
|
||||
if missing:
|
||||
record["status"] = "gap"
|
||||
gap_count += 1
|
||||
else:
|
||||
record["status"] = "ok"
|
||||
ok_count += 1
|
||||
results.append(record)
|
||||
|
||||
status = "ok" if not contract_gaps and gap_count == 0 and missing_count == 0 else "degraded"
|
||||
|
||||
next_steps = []
|
||||
if contract_gaps:
|
||||
next_steps.append(
|
||||
"Aligner specs/contracts/ops_mascarade_kill_life.contract.json sur les champs requis attendus."
|
||||
)
|
||||
if gap_count or missing_count:
|
||||
next_steps.append(
|
||||
"Corriger les surfaces en ecart pour garder la meme reprise entre ops, Mascarade et kill_life."
|
||||
)
|
||||
if not next_steps:
|
||||
next_steps.append(
|
||||
"Maintenir l audit statique a chaque lot de consolidation des surfaces cockpit."
|
||||
)
|
||||
|
||||
payload = {
|
||||
"contract_version": contract.get("version", "unknown"),
|
||||
"component": "product-contract-audit",
|
||||
"status": status,
|
||||
"contract_status": status,
|
||||
"generated_at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"required_contract_fields": required_fields,
|
||||
"expected_required_fields": expected_required,
|
||||
"contract_field_gaps": contract_gaps,
|
||||
"target_count": len(results),
|
||||
"targets_ok": ok_count,
|
||||
"targets_gap": gap_count,
|
||||
"targets_missing_file": missing_count,
|
||||
"targets": results,
|
||||
"next_steps": next_steps,
|
||||
"artifacts": [
|
||||
"artifacts/cockpit/product_contract_audit/latest.json",
|
||||
"artifacts/cockpit/product_contract_audit/latest.md",
|
||||
"specs/contracts/ops_mascarade_kill_life.contract.json"
|
||||
]
|
||||
}
|
||||
|
||||
json_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Audit statique du contrat produit ops / Mascarade / kill_life",
|
||||
"",
|
||||
f"- Généré: `{payload['generated_at']}`",
|
||||
f"- Version contrat: `{payload['contract_version']}`",
|
||||
f"- Statut: `{payload['status']}`",
|
||||
f"- Surfaces OK: `{ok_count}` / `{len(results)}`",
|
||||
f"- Surfaces en écart: `{gap_count}`",
|
||||
f"- Fichiers manquants: `{missing_count}`",
|
||||
"",
|
||||
"## Champs requis",
|
||||
"",
|
||||
f"- Attendus: `{', '.join(expected_required)}`",
|
||||
f"- Contrat courant: `{', '.join(required_fields)}`",
|
||||
]
|
||||
|
||||
if contract_gaps:
|
||||
lines.extend([
|
||||
"",
|
||||
"## Écarts contrat",
|
||||
"",
|
||||
f"- Champs absents: `{', '.join(contract_gaps)}`",
|
||||
])
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Couverture par surface",
|
||||
"",
|
||||
])
|
||||
|
||||
for result in results:
|
||||
lines.append(
|
||||
f"- `{result['name']}` (`{result['kind']}`) via `{result['file']}`: `{result['status']}`"
|
||||
)
|
||||
if result["missing"]:
|
||||
lines.append(f" - Manquants: `{', '.join(result['missing'])}`")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Prochaines actions",
|
||||
"",
|
||||
])
|
||||
for step in next_steps:
|
||||
lines.append(f"- {step}")
|
||||
|
||||
markdown_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
PY
|
||||
|
||||
cp "${JSON_FILE}" "${LATEST_JSON}"
|
||||
cp "${MARKDOWN_FILE}" "${LATEST_MARKDOWN}"
|
||||
|
||||
if [[ "${JSON_ONLY}" == "1" ]]; then
|
||||
cat "${LATEST_JSON}"
|
||||
elif [[ "${MARKDOWN_ONLY}" == "1" ]]; then
|
||||
cat "${LATEST_MARKDOWN}"
|
||||
else
|
||||
printf 'product_contract_audit status=%s json=%s markdown=%s\n' \
|
||||
"$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["status"])' "${LATEST_JSON}")" \
|
||||
"${LATEST_JSON#${ROOT_DIR}/}" \
|
||||
"${LATEST_MARKDOWN#${ROOT_DIR}/}"
|
||||
fi
|
||||
Executable
+640
@@ -0,0 +1,640 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
LOG_DIR="${ROOT_DIR}/artifacts/refonte_tui"
|
||||
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
|
||||
LOG_FILE="${LOG_DIR}/refonte_tui_${TIMESTAMP}.log"
|
||||
ACTION=""
|
||||
DAYS_KEEP=14
|
||||
YES=0
|
||||
AUTO_PURGE=0
|
||||
VERBOSE=0
|
||||
MESH_LOAD_PROFILE="tower-first"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF_USAGE'
|
||||
Usage:
|
||||
bash tools/cockpit/refonte_tui.sh [options]
|
||||
bash tools/cockpit/refonte_tui.sh --action <action> [options]
|
||||
|
||||
Options:
|
||||
--action <readme|mesh-preflight|ssh-health|mascarade-health|mascarade-logs|mascarade-logs-summary|mascarade-logs-latest|mascarade-logs-list|mascarade-logs-purge|mascarade-incidents|mascarade-incidents-watch|mascarade-incidents-brief|mascarade-incidents-registry|mascarade-incidents-queue|mascarade-incidents-daily|mesh-health|lot-chain|mcp-check|yiacad-fusion|yiacad-fusion:prepare|yiacad-fusion:smoke|yiacad-fusion:status|yiacad-fusion:logs|yiacad-fusion:clean-logs|intelligence|intelligence:audit|intelligence:feature-map|intelligence:spec|intelligence:plan|intelligence:todo|intelligence:research|intelligence:owners|intelligence:logs-summary|intelligence:logs-list|intelligence:logs-latest|intelligence:purge-logs|weekly-summary|daily-align|validate|logs|clean-logs|log-ops|status|all>
|
||||
Exécuter un lot directement au lieu d'un menu interactif.
|
||||
--days <int> Nombre de jours pour la rétention de logs (clean-logs). Défaut: 14.
|
||||
--mesh-load-profile <tower-first|photon-safe>
|
||||
Profil P2P pour le préflight mesh (tower-first|photon-safe).
|
||||
--yes Accepter les mises à jour écrivant les fichiers de plan.
|
||||
--yes-auto Accepter automatiquement les purges via clean-logs.
|
||||
--verbose Afficher la sortie live des commandes appelées.
|
||||
-h, --help Affiche ce message.
|
||||
|
||||
Description:
|
||||
Outil TUI textuel pour la refonte Kill_LIFE.
|
||||
- loge toutes les exécutions dans artifacts/refonte_tui/
|
||||
- lit les artefacts docs/plans/specs
|
||||
- supprime les logs en mode contrôlé
|
||||
- expose les opérations log_ops (résumé + purge) en mode contrôlé
|
||||
EOF_USAGE
|
||||
}
|
||||
|
||||
log_event() {
|
||||
local level="$1"
|
||||
shift
|
||||
local msg="$*"
|
||||
printf '[%s] %-7s %s\n' "$(date '+%Y-%m-%d %H:%M:%S %z')" "$level" "$msg" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
run_and_log() {
|
||||
local label="$1"
|
||||
shift
|
||||
log_event "START" "${label}"
|
||||
|
||||
if [[ "${VERBOSE}" == "1" ]]; then
|
||||
"$@" 2>&1 | tee -a "$LOG_FILE"
|
||||
local rc="${PIPESTATUS[0]}"
|
||||
else
|
||||
"$@" >>"$LOG_FILE" 2>&1
|
||||
local rc="$?"
|
||||
fi
|
||||
|
||||
if [[ "$rc" -eq 0 ]]; then
|
||||
log_event "OK" "${label}"
|
||||
return 0
|
||||
fi
|
||||
log_event "FAIL" "${label} (code=$rc)"
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
ensure_dirs() {
|
||||
mkdir -p "$LOG_DIR"
|
||||
[[ -f "$LOG_FILE" ]] || touch "$LOG_FILE"
|
||||
}
|
||||
|
||||
cmd_readme_audit() {
|
||||
local args=(bash tools/doc/readme_repo_coherence.sh all)
|
||||
if [[ "$YES" -eq 1 ]]; then
|
||||
args+=(--yes)
|
||||
fi
|
||||
run_and_log "Readme coherence + plan sync" "${args[@]}"
|
||||
}
|
||||
|
||||
cmd_mesh_preflight() {
|
||||
local args=(
|
||||
bash tools/cockpit/mesh_sync_preflight.sh --json --load-profile "${MESH_LOAD_PROFILE}"
|
||||
)
|
||||
run_and_log "Tri-repo mesh sync preflight" "${args[@]}"
|
||||
}
|
||||
|
||||
cmd_ssh_health() {
|
||||
local args=(
|
||||
bash tools/cockpit/ssh_healthcheck.sh --json
|
||||
)
|
||||
run_and_log "SSH operator health-check" "${args[@]}"
|
||||
}
|
||||
|
||||
cmd_mascarade_health() {
|
||||
local args=(
|
||||
bash tools/cockpit/mascarade_runtime_health.sh --json
|
||||
)
|
||||
run_and_log "Mascarade/Ollama runtime health-check" "${args[@]}"
|
||||
}
|
||||
|
||||
cmd_mascarade_logs() {
|
||||
local logs_action="${1:-summary}"
|
||||
local args=(
|
||||
bash tools/cockpit/full_operator_lane.sh logs --json --logs-action "${logs_action}"
|
||||
)
|
||||
run_and_log "Mascarade/Ollama operator lane logs (${logs_action})" "${args[@]}"
|
||||
}
|
||||
|
||||
cmd_mascarade_incidents() {
|
||||
local incidents_action="${1:-summary}"
|
||||
local args=(
|
||||
bash tools/cockpit/mascarade_incidents_tui.sh --action "${incidents_action}" --lines 18
|
||||
)
|
||||
run_and_log "Mascarade incidents view (${incidents_action})" "${args[@]}"
|
||||
}
|
||||
|
||||
cmd_lot_chain() {
|
||||
local args=(bash tools/cockpit/lot_chain.sh all)
|
||||
if [[ "$YES" -eq 1 ]]; then
|
||||
args+=(--yes)
|
||||
fi
|
||||
run_and_log "Autonomous lot chain + plans" "${args[@]}"
|
||||
}
|
||||
|
||||
cmd_yiacad_fusion() {
|
||||
local yiacad_action="prepare"
|
||||
if [[ "${ACTION}" == yiacad-fusion:* ]]; then
|
||||
yiacad_action="${ACTION#yiacad-fusion:}"
|
||||
fi
|
||||
|
||||
if [[ "${ACTION}" == "yiacad-fusion" ]]; then
|
||||
yiacad_action="prepare"
|
||||
fi
|
||||
|
||||
case "${yiacad_action}" in
|
||||
prepare|smoke|status|logs|clean-logs)
|
||||
;;
|
||||
*)
|
||||
echo "Action YiACAD inconnue: ${yiacad_action}" >&2
|
||||
echo "Actions valides: prepare|smoke|status|logs|clean-logs" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
local args=(bash tools/cad/yiacad_fusion_lot.sh --action "${yiacad_action}")
|
||||
if [[ "${yiacad_action}" == "clean-logs" ]]; then
|
||||
args+=(--days "${DAYS_KEEP}")
|
||||
fi
|
||||
if [[ "$YES" -eq 1 ]]; then
|
||||
args+=(--yes)
|
||||
fi
|
||||
run_and_log "YiACAD lot (${yiacad_action})" "${args[@]}"
|
||||
}
|
||||
|
||||
cmd_weekly_summary() {
|
||||
local args=(bash tools/cockpit/render_weekly_refonte_summary.sh)
|
||||
run_and_log "Weekly refonte summary" "${args[@]}"
|
||||
}
|
||||
|
||||
cmd_mcp_check() {
|
||||
local args=(
|
||||
bash tools/run_autonomous_next_lots.sh status
|
||||
)
|
||||
run_and_log "Autonomous lot status" "${args[@]}"
|
||||
}
|
||||
|
||||
cmd_daily_align() {
|
||||
local args=(bash tools/cockpit/run_alignment_daily.sh --json --mesh-load-profile "${MESH_LOAD_PROFILE}")
|
||||
run_and_log "Daily alignment + repo refresh" "${args[@]}"
|
||||
}
|
||||
|
||||
cmd_validate() {
|
||||
local args=(
|
||||
bash tools/test_python.sh --suite stable
|
||||
)
|
||||
run_and_log "Python stable suite" "${args[@]}"
|
||||
}
|
||||
|
||||
cmd_log_ops() {
|
||||
local args=(
|
||||
bash tools/cockpit/log_ops.sh --action summary --json
|
||||
)
|
||||
run_and_log "Log ops summary" "${args[@]}"
|
||||
}
|
||||
|
||||
cmd_mesh_health() {
|
||||
local args=(
|
||||
bash tools/cockpit/mesh_health_check.sh --json --load-profile "${MESH_LOAD_PROFILE}"
|
||||
)
|
||||
run_and_log "Mesh health reconcile" "${args[@]}"
|
||||
}
|
||||
|
||||
cmd_intelligence_program() {
|
||||
local intelligence_action="status"
|
||||
|
||||
if [[ "${ACTION}" == intelligence:* ]]; then
|
||||
intelligence_action="${ACTION#intelligence:}"
|
||||
fi
|
||||
|
||||
local args=(
|
||||
bash tools/cockpit/intelligence_tui.sh --action "${intelligence_action}"
|
||||
)
|
||||
run_and_log "Intelligence program (${intelligence_action})" "${args[@]}"
|
||||
}
|
||||
|
||||
list_logs() {
|
||||
echo "=== Logs disponibles (refonte_tui) ==="
|
||||
if ! ls -1 "$LOG_DIR"/*.log 2>/dev/null | head -n 20; then
|
||||
echo "Aucun fichier log trouvé dans $LOG_DIR"
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
collect_refonte_logs_by_mtime_desc() {
|
||||
local path
|
||||
local epoch
|
||||
while IFS= read -r -d '' path; do
|
||||
if epoch="$(stat -c '%Y' "$path" 2>/dev/null)"; then
|
||||
: # GNU coreutils
|
||||
elif epoch="$(stat -f '%m' "$path" 2>/dev/null)"; then
|
||||
: # BSD/macOS
|
||||
else
|
||||
epoch="0"
|
||||
fi
|
||||
printf '%s %s\n' "$epoch" "$path"
|
||||
done < <(find "$LOG_DIR" -maxdepth 1 -type f -name "*.log" -print0)
|
||||
}
|
||||
|
||||
analyze_logs() {
|
||||
echo "=== Analyse rapide des 20 logs les plus récents ==="
|
||||
list_logs
|
||||
echo
|
||||
collect_refonte_logs_by_mtime_desc | sort -nr | head -n 20 | awk '{print $2}' | while IFS= read -r file; do
|
||||
[[ -z "$file" ]] && continue
|
||||
local_fail="$(grep -cE "FAIL|ERROR|KO|blocked|syntax fail|timeout" "$file" 2>/dev/null || true)"
|
||||
local_lines="$(wc -l < "$file")"
|
||||
local_name="$(basename "$file")"
|
||||
printf '%s | lines=%s | fails=%s\n' "$local_name" "$local_lines" "$local_fail"
|
||||
done
|
||||
echo
|
||||
echo "Dernier log (tail 30):"
|
||||
local last_file
|
||||
last_file="$(collect_refonte_logs_by_mtime_desc | sort -nr | head -n 1 | awk '{print $2}')"
|
||||
if [[ -n "$last_file" ]]; then
|
||||
tail -n 30 "$last_file"
|
||||
else
|
||||
echo "Aucun log trouvé."
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_logs_report() {
|
||||
list_logs
|
||||
echo
|
||||
analyze_logs
|
||||
cmd_log_ops
|
||||
}
|
||||
|
||||
clean_logs() {
|
||||
local cutoff_days="${1:-$DAYS_KEEP}"
|
||||
ensure_dirs
|
||||
local target=$((cutoff_days))
|
||||
if [[ "$target" -lt 1 ]]; then
|
||||
echo "days doit être >= 1."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local count=0
|
||||
while IFS= read -r file; do
|
||||
rm -f "$file"
|
||||
((count += 1))
|
||||
done < <(find "$LOG_DIR" -maxdepth 1 -type f -name "*.log" -mtime +"$target")
|
||||
|
||||
log_event "INFO" "Logs anciens supprimés (>${target}j): $count"
|
||||
echo "Logs supprimés: $count"
|
||||
cmd_log_ops
|
||||
}
|
||||
|
||||
show_status() {
|
||||
echo "=== Statuts et repères rapides ==="
|
||||
echo "- Contrat mesh: docs/TRI_REPO_MESH_CONTRACT_2026-03-20.md"
|
||||
echo "- Plan principal: specs/03_plan.md (section AUTO LOT-CHAIN PLAN)"
|
||||
echo "- Tâches: specs/04_tasks.md"
|
||||
echo "- Plan d'agents: docs/plans/12_plan_gestion_des_agents.md"
|
||||
echo "- Lot chain: tools/autonomous_next_lots.py, tools/cockpit/lot_chain.sh"
|
||||
echo "- Continuité kill_life JSON: artifacts/cockpit/kill_life_memory/latest.json"
|
||||
echo "- Continuité kill_life Markdown: artifacts/cockpit/kill_life_memory/latest.md"
|
||||
echo "- Handoff quotidien: artifacts/cockpit/daily_operator_summary_latest.md"
|
||||
echo "- Handoff produit JSON: artifacts/cockpit/product_contract_handoff/latest.json"
|
||||
echo "- Handoff produit Markdown: artifacts/cockpit/product_contract_handoff/latest.md"
|
||||
echo "- Lot YiACAD: tools/cad/yiacad_fusion_lot.sh (prepare/smoke/status/logs)"
|
||||
echo "- Préflight mesh: tools/cockpit/mesh_sync_preflight.sh"
|
||||
echo "- Health consolidate: tools/cockpit/mesh_health_check.sh"
|
||||
echo "- Santé SSH: tools/cockpit/ssh_healthcheck.sh"
|
||||
echo "- Santé Mascarade: tools/cockpit/mascarade_runtime_health.sh"
|
||||
echo "- Logs Mascarade: tools/cockpit/mascarade_logs_tui.sh"
|
||||
echo "- Incidents Mascarade: tools/cockpit/mascarade_incidents_tui.sh"
|
||||
echo "- MCP setup: docs/MCP_SETUP.md"
|
||||
echo "- Manifeste: docs/REFACTOR_MANIFEST_2026-03-20.md"
|
||||
echo "- Web research: docs/WEB_RESEARCH_OPEN_SOURCE_2026-03-20.md"
|
||||
echo "- Intelligence spec: specs/agentic_intelligence_integration_spec.md"
|
||||
echo "- Intelligence TUI: tools/cockpit/intelligence_tui.sh"
|
||||
echo "- Log ops: tools/cockpit/log_ops.sh"
|
||||
echo "- Cad fusion logs: artifacts/cad-fusion"
|
||||
echo "- Weekly summary: artifacts/cockpit/weekly_refonte_summary.md"
|
||||
}
|
||||
|
||||
run_all() {
|
||||
cmd_mesh_preflight
|
||||
cmd_ssh_health
|
||||
cmd_mascarade_health
|
||||
cmd_mascarade_logs
|
||||
cmd_readme_audit
|
||||
cmd_lot_chain
|
||||
cmd_mcp_check
|
||||
cmd_validate
|
||||
cmd_log_ops
|
||||
cmd_weekly_summary
|
||||
}
|
||||
|
||||
menu() {
|
||||
while true; do
|
||||
echo
|
||||
echo "== Refonte TUI Kill_LIFE =="
|
||||
echo "1) Préflight mesh tri-repo"
|
||||
echo "2) Santé SSH opérateur"
|
||||
echo "3) Santé Mascarade/Ollama"
|
||||
echo "4) Audit README + plans"
|
||||
echo "5) Executer chain autonome (lot_chain)"
|
||||
echo "6) Status lot détectés (autonomous)"
|
||||
echo "7) Tests stables (python suite)"
|
||||
echo "8) Vérification quotidienne (alignment)"
|
||||
echo "9) Voir logs récents"
|
||||
echo "10) Analyser logs (échecs + tail)"
|
||||
echo "11) Statut repère"
|
||||
echo "12) Résumé LogOps"
|
||||
echo "13) Vérification santé consolidée (mesh/readme/logs)"
|
||||
echo "14) Purger logs anciens (${DAYS_KEEP}j)"
|
||||
echo "15) Lot YiACAD (prepare/smoke/status/logs/clean-logs)"
|
||||
echo "16) Synthèse hebdomadaire refonte"
|
||||
echo "17) Logs Mascarade/Ollama (summary)"
|
||||
echo "18) Logs Mascarade/Ollama (latest)"
|
||||
echo "19) Logs Mascarade/Ollama (list)"
|
||||
echo "20) Logs Mascarade/Ollama (purge)"
|
||||
echo "21) Incidents Mascarade (summary)"
|
||||
echo "22) Incidents Mascarade (watch)"
|
||||
echo "23) Incidents Mascarade (brief)"
|
||||
echo "24) Incidents Mascarade (registry)"
|
||||
echo "25) Incidents Mascarade (queue)"
|
||||
echo "26) Incidents Mascarade (daily)"
|
||||
echo "27) Intelligence program (status)"
|
||||
echo "28) Intelligence program (plan)"
|
||||
echo "29) Intelligence program (todo)"
|
||||
echo "30) Intelligence program (research)"
|
||||
echo "0) Quitter"
|
||||
echo -n "Choix: "
|
||||
read -r choice
|
||||
|
||||
case "${choice}" in
|
||||
1)
|
||||
cmd_mesh_preflight
|
||||
;;
|
||||
2)
|
||||
cmd_ssh_health
|
||||
;;
|
||||
3)
|
||||
cmd_mascarade_health
|
||||
;;
|
||||
4)
|
||||
cmd_readme_audit
|
||||
;;
|
||||
5)
|
||||
cmd_lot_chain
|
||||
;;
|
||||
6)
|
||||
cmd_mcp_check
|
||||
;;
|
||||
7)
|
||||
cmd_validate
|
||||
;;
|
||||
8)
|
||||
cmd_daily_align
|
||||
;;
|
||||
9)
|
||||
list_logs
|
||||
;;
|
||||
10)
|
||||
cmd_logs_report
|
||||
;;
|
||||
11)
|
||||
show_status
|
||||
;;
|
||||
12)
|
||||
cmd_log_ops
|
||||
;;
|
||||
13)
|
||||
cmd_mesh_health
|
||||
;;
|
||||
14)
|
||||
if [[ "$AUTO_PURGE" -eq 1 ]]; then
|
||||
clean_logs "$DAYS_KEEP"
|
||||
else
|
||||
echo "Confirmer la suppression des logs > ${DAYS_KEEP} jours ? [y/N]"
|
||||
read -r confirm
|
||||
if [[ "${confirm}" == "y" || "${confirm}" == "Y" ]]; then
|
||||
clean_logs "$DAYS_KEEP"
|
||||
else
|
||||
echo "Annulé."
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
15)
|
||||
cmd_yiacad_fusion
|
||||
;;
|
||||
16)
|
||||
cmd_weekly_summary
|
||||
;;
|
||||
17)
|
||||
cmd_mascarade_logs summary
|
||||
;;
|
||||
18)
|
||||
cmd_mascarade_logs latest
|
||||
;;
|
||||
19)
|
||||
cmd_mascarade_logs list
|
||||
;;
|
||||
20)
|
||||
cmd_mascarade_logs purge
|
||||
;;
|
||||
21)
|
||||
cmd_mascarade_incidents summary
|
||||
;;
|
||||
22)
|
||||
cmd_mascarade_incidents watch
|
||||
;;
|
||||
23)
|
||||
cmd_mascarade_incidents brief
|
||||
;;
|
||||
24)
|
||||
cmd_mascarade_incidents registry
|
||||
;;
|
||||
25)
|
||||
cmd_mascarade_incidents queue
|
||||
;;
|
||||
26)
|
||||
cmd_mascarade_incidents daily
|
||||
;;
|
||||
27)
|
||||
ACTION="intelligence"
|
||||
cmd_intelligence_program
|
||||
;;
|
||||
28)
|
||||
ACTION="intelligence:plan"
|
||||
cmd_intelligence_program
|
||||
;;
|
||||
29)
|
||||
ACTION="intelligence:todo"
|
||||
cmd_intelligence_program
|
||||
;;
|
||||
30)
|
||||
ACTION="intelligence:research"
|
||||
cmd_intelligence_program
|
||||
;;
|
||||
0)
|
||||
echo "Sortie."
|
||||
break
|
||||
;;
|
||||
*)
|
||||
echo "Choix invalide: ${choice}"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
main() {
|
||||
ensure_dirs
|
||||
log_event "INFO" "refonte_tui start action=${ACTION:-interactive} yes=${YES} verbose=${VERBOSE}"
|
||||
|
||||
case "${ACTION}" in
|
||||
readme)
|
||||
cmd_readme_audit
|
||||
;;
|
||||
"mesh-preflight")
|
||||
cmd_mesh_preflight
|
||||
;;
|
||||
"ssh-health")
|
||||
cmd_ssh_health
|
||||
;;
|
||||
"mascarade-health")
|
||||
cmd_mascarade_health
|
||||
;;
|
||||
"mascarade-logs")
|
||||
cmd_mascarade_logs summary
|
||||
;;
|
||||
"mascarade-logs-summary")
|
||||
cmd_mascarade_logs summary
|
||||
;;
|
||||
"mascarade-logs-latest")
|
||||
cmd_mascarade_logs latest
|
||||
;;
|
||||
"mascarade-logs-list")
|
||||
cmd_mascarade_logs list
|
||||
;;
|
||||
"mascarade-logs-purge")
|
||||
cmd_mascarade_logs purge
|
||||
;;
|
||||
"mascarade-incidents")
|
||||
cmd_mascarade_incidents summary
|
||||
;;
|
||||
"mascarade-incidents-watch")
|
||||
cmd_mascarade_incidents watch
|
||||
;;
|
||||
"mascarade-incidents-brief")
|
||||
cmd_mascarade_incidents brief
|
||||
;;
|
||||
"mascarade-incidents-registry")
|
||||
cmd_mascarade_incidents registry
|
||||
;;
|
||||
"mascarade-incidents-queue")
|
||||
cmd_mascarade_incidents queue
|
||||
;;
|
||||
"mascarade-incidents-daily")
|
||||
cmd_mascarade_incidents daily
|
||||
;;
|
||||
"lot-chain")
|
||||
cmd_lot_chain
|
||||
;;
|
||||
"mcp-check")
|
||||
cmd_mcp_check
|
||||
;;
|
||||
daily-align)
|
||||
cmd_daily_align
|
||||
;;
|
||||
validate)
|
||||
cmd_validate
|
||||
;;
|
||||
logs)
|
||||
cmd_logs_report
|
||||
;;
|
||||
"clean-logs")
|
||||
if [[ "$AUTO_PURGE" -ne 1 ]]; then
|
||||
echo "Refus de purger sans --yes-auto pour l'action non interactive clean-logs." >&2
|
||||
echo "Relancer avec: bash tools/cockpit/refonte_tui.sh --action clean-logs --days ${DAYS_KEEP} --yes-auto" >&2
|
||||
exit 2
|
||||
fi
|
||||
clean_logs "$DAYS_KEEP"
|
||||
;;
|
||||
"log-ops")
|
||||
cmd_log_ops
|
||||
;;
|
||||
yiacad-fusion|yiacad-fusion:*)
|
||||
cmd_yiacad_fusion
|
||||
;;
|
||||
intelligence|intelligence:*)
|
||||
cmd_intelligence_program
|
||||
;;
|
||||
"weekly-summary")
|
||||
cmd_weekly_summary
|
||||
;;
|
||||
"mesh-health")
|
||||
cmd_mesh_health
|
||||
;;
|
||||
status)
|
||||
show_status
|
||||
;;
|
||||
all)
|
||||
run_all
|
||||
;;
|
||||
"")
|
||||
menu
|
||||
;;
|
||||
*)
|
||||
echo "Action inconnue: ${ACTION}"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
log_event "INFO" "refonte_tui end"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "--action nécessite une valeur." >&2
|
||||
exit 1
|
||||
fi
|
||||
ACTION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--days)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "--days nécessite une valeur." >&2
|
||||
exit 1
|
||||
fi
|
||||
DAYS_KEEP="$2"
|
||||
shift 2
|
||||
;;
|
||||
--mesh-load-profile)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "--mesh-load-profile nécessite une valeur." >&2
|
||||
exit 1
|
||||
fi
|
||||
MESH_LOAD_PROFILE="$2"
|
||||
if [[ ! "${MESH_LOAD_PROFILE}" =~ ^(tower-first|photon-safe)$ ]]; then
|
||||
echo "Profil invalide: ${MESH_LOAD_PROFILE}. Valeurs: tower-first|photon-safe" >&2
|
||||
exit 2
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
--yes)
|
||||
YES=1
|
||||
shift
|
||||
;;
|
||||
--yes-auto)
|
||||
YES=1
|
||||
AUTO_PURGE=1
|
||||
shift
|
||||
;;
|
||||
--verbose)
|
||||
VERBOSE=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Option inconnue: $1" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
COCKPIT_DIR="$ROOT_DIR/artifacts/cockpit"
|
||||
OUTPUT_MODE="text"
|
||||
DAILY_LOG=""
|
||||
BRIEF_MARKDOWN=""
|
||||
REGISTRY_MARKDOWN=""
|
||||
QUEUE_MARKDOWN=""
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
bash tools/cockpit/render_daily_operator_summary.sh [--daily-log FILE] [--brief-markdown FILE] [--registry-markdown FILE] [--queue-markdown FILE] [--json]
|
||||
|
||||
Options:
|
||||
--daily-log FILE Daily alignment log to summarize
|
||||
--brief-markdown FILE Mascarade brief markdown source
|
||||
--registry-markdown FILE Mascarade registry markdown source
|
||||
--queue-markdown FILE Mascarade queue markdown source
|
||||
--json Emit cockpit-v1 JSON to stdout
|
||||
-h,--help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--daily-log)
|
||||
DAILY_LOG="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--brief-markdown)
|
||||
BRIEF_MARKDOWN="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--registry-markdown)
|
||||
REGISTRY_MARKDOWN="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--queue-markdown)
|
||||
QUEUE_MARKDOWN="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
OUTPUT_MODE="json"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir -p "$COCKPIT_DIR"
|
||||
|
||||
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
OUT_MD="$COCKPIT_DIR/daily_operator_summary_${RUN_ID}.md"
|
||||
OUT_JSON="$COCKPIT_DIR/daily_operator_summary_${RUN_ID}.json"
|
||||
|
||||
python3 - "$COCKPIT_DIR" "$DAILY_LOG" "$BRIEF_MARKDOWN" "$REGISTRY_MARKDOWN" "$QUEUE_MARKDOWN" "$OUT_MD" "$OUT_JSON" <<'PY'
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
cockpit_dir = Path(sys.argv[1])
|
||||
daily_log_arg = sys.argv[2]
|
||||
brief_arg = sys.argv[3]
|
||||
registry_arg = sys.argv[4]
|
||||
queue_arg = sys.argv[5]
|
||||
out_md = Path(sys.argv[6])
|
||||
out_json = Path(sys.argv[7])
|
||||
|
||||
|
||||
def latest_matching(pattern: str):
|
||||
files = sorted(cockpit_dir.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return files[0] if files else None
|
||||
|
||||
|
||||
daily_log = Path(daily_log_arg) if daily_log_arg else latest_matching("machine_alignment_daily_*.log")
|
||||
brief_md = Path(brief_arg) if brief_arg else cockpit_dir / "mascarade_incident_brief_latest.md"
|
||||
registry_md = Path(registry_arg) if registry_arg else cockpit_dir / "mascarade_incident_registry_latest.md"
|
||||
queue_md = Path(queue_arg) if queue_arg else cockpit_dir / "mascarade_incident_queue_latest.md"
|
||||
registry_json = cockpit_dir / "mascarade_incident_registry_latest.json"
|
||||
memory_json = cockpit_dir / "kill_life_memory" / "latest.json"
|
||||
memory_md = cockpit_dir / "kill_life_memory" / "latest.md"
|
||||
|
||||
|
||||
def read_lines(path: Path):
|
||||
try:
|
||||
return path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def load_json(path: Path):
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
summary_lines = [line for line in read_lines(daily_log) if line.startswith("[summary]")] if daily_log and daily_log.exists() else []
|
||||
brief_tail = read_lines(brief_md)[-16:] if brief_md.exists() else ["Brief indisponible."]
|
||||
registry_tail = read_lines(registry_md)[-16:] if registry_md.exists() else ["Registre indisponible."]
|
||||
queue_tail = read_lines(queue_md)[-16:] if queue_md.exists() else ["Queue indisponible."]
|
||||
registry_payload = load_json(registry_json) if registry_json.exists() else {}
|
||||
memory_payload = load_json(memory_json) if memory_json.exists() else {}
|
||||
priority_counts = registry_payload.get("priority_counts", {}) if isinstance(registry_payload.get("priority_counts"), dict) else {}
|
||||
severity_counts = registry_payload.get("severity_counts", {}) if isinstance(registry_payload.get("severity_counts"), dict) else {}
|
||||
memory_entry = memory_payload.get("entry", {}) if isinstance(memory_payload.get("entry"), dict) else {}
|
||||
routing = memory_entry.get("routing", {}) if isinstance(memory_entry.get("routing"), dict) else {}
|
||||
resume_ref = memory_payload.get("resume_ref") or memory_entry.get("resume_ref", "")
|
||||
trust_level = memory_payload.get("trust_level") or memory_entry.get("trust_level", "inferred")
|
||||
memory_tail = read_lines(memory_md)[-12:] if memory_md.exists() else ["Kill life memory indisponible."]
|
||||
|
||||
overall = "ok"
|
||||
reasons = []
|
||||
for line in summary_lines:
|
||||
if "result=degraded" in line or "status=degraded" in line:
|
||||
overall = "degraded"
|
||||
if "result=ko" in line or "status=failed" in line or "status=ko" in line:
|
||||
overall = "degraded"
|
||||
reasons.append(line)
|
||||
|
||||
for label, path in (("brief", brief_md), ("registry", registry_md), ("queue", queue_md)):
|
||||
if not path.exists():
|
||||
overall = "degraded"
|
||||
reasons.append(f"missing-{label}-markdown")
|
||||
if not memory_json.exists():
|
||||
overall = "degraded"
|
||||
reasons.append("missing-kill-life-memory")
|
||||
|
||||
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
lines = [
|
||||
"# Daily operator summary",
|
||||
"",
|
||||
f"- generated_at: {generated_at}",
|
||||
f"- daily_log: {daily_log if daily_log else 'none'}",
|
||||
f"- mascarade_brief: {brief_md if brief_md.exists() else 'missing'}",
|
||||
f"- mascarade_registry: {registry_md if registry_md.exists() else 'missing'}",
|
||||
f"- mascarade_queue: {queue_md if queue_md.exists() else 'missing'}",
|
||||
"",
|
||||
"## Incident priority rollup",
|
||||
"",
|
||||
f"- priority P1/P2/P3: {priority_counts.get('P1', 0)}/{priority_counts.get('P2', 0)}/{priority_counts.get('P3', 0)}",
|
||||
f"- severity high/medium/low: {severity_counts.get('high', 0)}/{severity_counts.get('medium', 0)}/{severity_counts.get('low', 0)}",
|
||||
"",
|
||||
"## Execution continuity",
|
||||
"",
|
||||
f"- trust_level: {trust_level}",
|
||||
f"- resume_ref: {resume_ref or 'missing'}",
|
||||
f"- owner: {memory_entry.get('owner', 'unknown')}",
|
||||
f"- selected_target: {routing.get('selected_target', 'unknown')}",
|
||||
f"- selected_host: {routing.get('selected_host', 'unknown')}",
|
||||
f"- kill_life_memory: {memory_md if memory_md.exists() else 'missing'}",
|
||||
"",
|
||||
"## Daily alignment highlights",
|
||||
"",
|
||||
]
|
||||
|
||||
if summary_lines:
|
||||
lines.extend([f"- {line}" for line in summary_lines])
|
||||
else:
|
||||
lines.append("- no summary line found")
|
||||
|
||||
lines.extend(["", "## Mascarade incident brief", "", "```text"])
|
||||
lines.extend(brief_tail)
|
||||
lines.extend(["```", "", "## Mascarade incident registry", "", "```text"])
|
||||
lines.extend(registry_tail)
|
||||
lines.extend(["```", "", "## Mascarade incident queue", "", "```text"])
|
||||
lines.extend(queue_tail)
|
||||
lines.extend(["```", "", "## Kill life execution memory", "", "```text"])
|
||||
lines.extend(memory_tail)
|
||||
lines.extend(["```"])
|
||||
|
||||
out_md.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
shutil.copyfile(out_md, out_md.with_name("daily_operator_summary_latest.md"))
|
||||
|
||||
payload = {
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "daily-operator-summary",
|
||||
"action": "render",
|
||||
"status": overall,
|
||||
"contract_status": overall,
|
||||
"generated_at": generated_at,
|
||||
"daily_log": str(daily_log) if daily_log else "",
|
||||
"brief_markdown": str(brief_md) if brief_md.exists() else "",
|
||||
"registry_markdown": str(registry_md) if registry_md.exists() else "",
|
||||
"queue_markdown": str(queue_md) if queue_md.exists() else "",
|
||||
"owner": memory_entry.get("owner", "SyncOps"),
|
||||
"resume_ref": resume_ref,
|
||||
"trust_level": trust_level,
|
||||
"routing": routing,
|
||||
"memory_entry": memory_entry,
|
||||
"memory_markdown": str(memory_md) if memory_md.exists() else "",
|
||||
"priority_counts": priority_counts,
|
||||
"severity_counts": severity_counts,
|
||||
"markdown_file": str(out_md),
|
||||
"latest_markdown_file": str(out_md.with_name("daily_operator_summary_latest.md")),
|
||||
"artifacts": [
|
||||
str(out_md),
|
||||
str(out_md.with_name("daily_operator_summary_latest.md")),
|
||||
] + ([str(daily_log)] if daily_log else []) + ([str(memory_json)] if memory_json.exists() else []),
|
||||
"degraded_reasons": reasons[:5],
|
||||
"next_steps": ["Review the latest Mascarade brief, registry and queue before operator handoff."],
|
||||
}
|
||||
|
||||
out_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
shutil.copyfile(out_json, out_json.with_name("daily_operator_summary_latest.json"))
|
||||
PY
|
||||
|
||||
if [[ "$OUTPUT_MODE" == "json" ]]; then
|
||||
cat "$OUT_JSON"
|
||||
else
|
||||
cat <<EOF
|
||||
Daily operator summary
|
||||
markdown: $OUT_MD
|
||||
json: $OUT_JSON
|
||||
EOF
|
||||
fi
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
COCKPIT_DIR="$ROOT_DIR/artifacts/cockpit"
|
||||
RUNTIME_DIR="$ROOT_DIR/artifacts/ops/mascarade_runtime_health"
|
||||
OPERATOR_DIR="$ROOT_DIR/artifacts/operator_lane"
|
||||
OUTPUT_MODE="text"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
bash tools/cockpit/render_mascarade_incident_brief.sh [--json]
|
||||
|
||||
Options:
|
||||
--json Emit cockpit-v1 JSON to stdout
|
||||
-h,--help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--json)
|
||||
OUTPUT_MODE="json"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir -p "$COCKPIT_DIR" "$RUNTIME_DIR" "$OPERATOR_DIR"
|
||||
|
||||
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
OUT_MD="$COCKPIT_DIR/mascarade_incident_brief_${RUN_ID}.md"
|
||||
OUT_JSON="$COCKPIT_DIR/mascarade_incident_brief_${RUN_ID}.json"
|
||||
|
||||
python3 - "$RUNTIME_DIR" "$OPERATOR_DIR" "$OUT_MD" "$OUT_JSON" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
runtime_dir = Path(sys.argv[1])
|
||||
operator_dir = Path(sys.argv[2])
|
||||
out_md = Path(sys.argv[3])
|
||||
out_json = Path(sys.argv[4])
|
||||
|
||||
|
||||
def read_json(path: Path):
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def latest_matching(directory: Path, prefix: str, suffix: str = ".json", exclude_contains=None):
|
||||
exclude_contains = exclude_contains or []
|
||||
candidates = []
|
||||
if directory.exists():
|
||||
for path in directory.iterdir():
|
||||
if not path.is_file():
|
||||
continue
|
||||
if not path.name.startswith(prefix) or not path.name.endswith(suffix):
|
||||
continue
|
||||
if any(token in path.name for token in exclude_contains):
|
||||
continue
|
||||
candidates.append(path)
|
||||
candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
|
||||
runtime_latest = runtime_dir / "latest.json"
|
||||
runtime_data = read_json(runtime_latest)
|
||||
|
||||
operator_summary_path = latest_matching(operator_dir, "full_operator_lane_", ".json", exclude_contains=["mascarade_health", "mascarade_logs"])
|
||||
operator_summary = read_json(operator_summary_path) if operator_summary_path else {}
|
||||
|
||||
operator_logs_path = latest_matching(operator_dir, "full_operator_lane_mascarade_logs_", ".json")
|
||||
operator_logs = read_json(operator_logs_path) if operator_logs_path else {}
|
||||
memory_json = cockpit_dir / "kill_life_memory" / "latest.json"
|
||||
memory_md = cockpit_dir / "kill_life_memory" / "latest.md"
|
||||
memory_payload = read_json(memory_json) if memory_json.exists() else {}
|
||||
memory_entry = memory_payload.get("entry", {}) if isinstance(memory_payload.get("entry"), dict) else {}
|
||||
routing = memory_entry.get("routing", {}) if isinstance(memory_entry.get("routing"), dict) else {}
|
||||
resume_ref = memory_payload.get("resume_ref") or memory_entry.get("resume_ref", "")
|
||||
trust_level = memory_payload.get("trust_level") or memory_entry.get("trust_level", "inferred")
|
||||
|
||||
runtime_status = runtime_data.get("status", "missing")
|
||||
runtime_provider = runtime_data.get("provider", "unknown")
|
||||
runtime_model = runtime_data.get("model", "unknown")
|
||||
runtime_checked_at = runtime_data.get("checked_at", "")
|
||||
runtime_next_steps = runtime_data.get("next_steps", []) if isinstance(runtime_data.get("next_steps"), list) else []
|
||||
runtime_reasons = runtime_data.get("degraded_reasons", []) if isinstance(runtime_data.get("degraded_reasons"), list) else []
|
||||
|
||||
lane_status = operator_summary.get("status", "missing")
|
||||
lane_error = operator_summary.get("error", "")
|
||||
lane_hint = operator_summary.get("hint", "")
|
||||
lane_command = operator_summary.get("suggested_command", "")
|
||||
lane_url = operator_summary.get("url", "")
|
||||
|
||||
logs_status = operator_logs.get("status", "missing")
|
||||
logs_action = operator_logs.get("logs_action", operator_logs.get("action", "summary"))
|
||||
logs_details = operator_logs.get("details", {}) if isinstance(operator_logs.get("details"), dict) else operator_logs
|
||||
latest_runtime = logs_details.get("latest_runtime", {}) if isinstance(logs_details.get("latest_runtime"), dict) else {}
|
||||
latest_tail = logs_details.get("latest_log_tail", []) if isinstance(logs_details.get("latest_log_tail"), list) else []
|
||||
stale_count = logs_details.get("stale_candidate_count", logs_details.get("candidate_count", 0))
|
||||
|
||||
overall = "ok"
|
||||
reasons = []
|
||||
if runtime_status not in {"ok", "ready", "success", "done"}:
|
||||
overall = "degraded"
|
||||
reasons.append(f"runtime-{runtime_status}")
|
||||
if lane_status not in {"ok", "ready", "success", "done", ""}:
|
||||
overall = "degraded"
|
||||
reasons.append(f"lane-{lane_status}")
|
||||
if logs_status not in {"ok", "ready", "success", "done"}:
|
||||
overall = "degraded"
|
||||
reasons.append(f"logs-{logs_status}")
|
||||
|
||||
next_steps = []
|
||||
for item in runtime_next_steps:
|
||||
if item and item not in next_steps:
|
||||
next_steps.append(item)
|
||||
for item in [lane_hint, lane_command]:
|
||||
if item and item not in next_steps:
|
||||
next_steps.append(item)
|
||||
|
||||
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
lines = [
|
||||
"# Mascarade incident brief",
|
||||
"",
|
||||
f"- generated_at: {generated_at}",
|
||||
f"- overall_status: {overall}",
|
||||
"",
|
||||
"## Runtime",
|
||||
"",
|
||||
f"- status: {runtime_status}",
|
||||
f"- provider/model: {runtime_provider} / {runtime_model}",
|
||||
f"- checked_at: {runtime_checked_at or 'unknown'}",
|
||||
]
|
||||
|
||||
if runtime_reasons:
|
||||
lines.extend(["", "### Runtime degraded reasons", ""])
|
||||
lines.extend([f"- {item}" for item in runtime_reasons])
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Execution continuity",
|
||||
"",
|
||||
f"- trust_level: {trust_level}",
|
||||
f"- resume_ref: {resume_ref or 'missing'}",
|
||||
f"- owner: {memory_entry.get('owner', 'unknown')}",
|
||||
f"- selected_target: {routing.get('selected_target', 'unknown')}",
|
||||
f"- selected_host: {routing.get('selected_host', 'unknown')}",
|
||||
f"- kill_life_memory: {memory_md if memory_md.exists() else 'missing'}",
|
||||
"",
|
||||
"## Operator lane",
|
||||
"",
|
||||
f"- status: {lane_status or 'unknown'}",
|
||||
f"- error: {lane_error or 'none'}",
|
||||
f"- url: {lane_url or 'n/a'}",
|
||||
])
|
||||
|
||||
if lane_hint or lane_command:
|
||||
lines.extend(["", "### Operator hints", ""])
|
||||
if lane_hint:
|
||||
lines.append(f"- {lane_hint}")
|
||||
if lane_command:
|
||||
lines.append(f"- suggested_command: `{lane_command}`")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Logs",
|
||||
"",
|
||||
f"- status: {logs_status}",
|
||||
f"- action: {logs_action}",
|
||||
f"- stale_candidates: {stale_count}",
|
||||
])
|
||||
|
||||
runtime_path = latest_runtime.get("path", "")
|
||||
if runtime_path:
|
||||
lines.append(f"- latest_runtime_path: `{runtime_path}`")
|
||||
|
||||
if latest_tail:
|
||||
lines.extend(["", "### Latest log tail", ""])
|
||||
lines.extend([f"- `{item}`" for item in latest_tail[:8]])
|
||||
|
||||
if next_steps:
|
||||
lines.extend(["", "## Next steps", ""])
|
||||
lines.extend([f"- {item}" for item in next_steps])
|
||||
|
||||
out_md.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
shutil.copyfile(out_md, out_md.with_name("mascarade_incident_brief_latest.md"))
|
||||
|
||||
payload = {
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "mascarade-incident-brief",
|
||||
"action": "render",
|
||||
"status": overall,
|
||||
"contract_status": overall,
|
||||
"generated_at": generated_at,
|
||||
"owner": memory_entry.get("owner", "Runtime-Companion"),
|
||||
"resume_ref": resume_ref,
|
||||
"trust_level": trust_level,
|
||||
"routing": routing,
|
||||
"memory_entry": memory_entry,
|
||||
"memory_markdown": str(memory_md) if memory_md.exists() else "",
|
||||
"markdown_file": str(out_md),
|
||||
"latest_markdown_file": str(out_md.with_name("mascarade_incident_brief_latest.md")),
|
||||
"runtime_file": str(runtime_latest),
|
||||
"operator_summary_file": str(operator_summary_path) if operator_summary_path else "",
|
||||
"operator_logs_file": str(operator_logs_path) if operator_logs_path else "",
|
||||
"artifacts": [
|
||||
str(out_md),
|
||||
str(out_md.with_name("mascarade_incident_brief_latest.md")),
|
||||
str(runtime_latest),
|
||||
] + ([str(operator_summary_path)] if operator_summary_path else []) + ([str(operator_logs_path)] if operator_logs_path else []) + ([str(memory_json)] if memory_json.exists() else []),
|
||||
"degraded_reasons": reasons,
|
||||
"next_steps": next_steps,
|
||||
}
|
||||
|
||||
out_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
shutil.copyfile(out_json, out_json.with_name("mascarade_incident_brief_latest.json"))
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
PY
|
||||
|
||||
if [[ "$OUTPUT_MODE" == "json" ]]; then
|
||||
cat "$OUT_JSON"
|
||||
else
|
||||
cat <<EOF
|
||||
Mascarade incident brief
|
||||
markdown: $OUT_MD
|
||||
json: $OUT_JSON
|
||||
EOF
|
||||
fi
|
||||
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
COCKPIT_DIR="$ROOT_DIR/artifacts/cockpit"
|
||||
OUTPUT_MODE="text"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
bash tools/cockpit/render_mascarade_incident_queue.sh [--json]
|
||||
|
||||
Options:
|
||||
--json Emit cockpit-v1 JSON to stdout
|
||||
-h,--help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--json)
|
||||
OUTPUT_MODE="json"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir -p "$COCKPIT_DIR"
|
||||
|
||||
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
OUT_MD="$COCKPIT_DIR/mascarade_incident_queue_${RUN_ID}.md"
|
||||
OUT_JSON="$COCKPIT_DIR/mascarade_incident_queue_${RUN_ID}.json"
|
||||
|
||||
python3 - "$COCKPIT_DIR" "$OUT_MD" "$OUT_JSON" <<'PY'
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
cockpit_dir = Path(sys.argv[1])
|
||||
out_md = Path(sys.argv[2])
|
||||
out_json = Path(sys.argv[3])
|
||||
|
||||
registry_file = cockpit_dir / "mascarade_incident_registry_latest.json"
|
||||
memory_json = cockpit_dir / "kill_life_memory" / "latest.json"
|
||||
memory_md = cockpit_dir / "kill_life_memory" / "latest.md"
|
||||
|
||||
|
||||
def load_json(path: Path):
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def parse_timestamp(value: str) -> float:
|
||||
if not value:
|
||||
return 0.0
|
||||
candidates = (
|
||||
value,
|
||||
value.replace(" UTC", "+00:00"),
|
||||
value.replace("Z", "+00:00"),
|
||||
)
|
||||
for candidate in candidates:
|
||||
try:
|
||||
return datetime.fromisoformat(candidate).timestamp()
|
||||
except ValueError:
|
||||
continue
|
||||
for pattern in ("%Y-%m-%d %H:%M:%S UTC", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d %H:%M:%S%z"):
|
||||
try:
|
||||
return datetime.strptime(value, pattern).timestamp()
|
||||
except ValueError:
|
||||
continue
|
||||
return 0.0
|
||||
|
||||
|
||||
registry = load_json(registry_file)
|
||||
memory_payload = load_json(memory_json) if memory_json.exists() else {}
|
||||
memory_entry = memory_payload.get("entry", {}) if isinstance(memory_payload.get("entry"), dict) else {}
|
||||
routing = memory_entry.get("routing", {}) if isinstance(memory_entry.get("routing"), dict) else {}
|
||||
resume_ref = memory_payload.get("resume_ref") or memory_entry.get("resume_ref", "")
|
||||
trust_level = memory_payload.get("trust_level") or memory_entry.get("trust_level", "inferred")
|
||||
entries = registry.get("entries", [])
|
||||
if not isinstance(entries, list):
|
||||
entries = []
|
||||
|
||||
priority_rank = {"P1": 0, "P2": 1, "P3": 2}
|
||||
severity_rank = {"high": 0, "medium": 1, "low": 2}
|
||||
|
||||
queue_entries = sorted(
|
||||
entries,
|
||||
key=lambda item: (
|
||||
priority_rank.get(item.get("priority"), 9),
|
||||
severity_rank.get(item.get("severity"), 9),
|
||||
-parse_timestamp(item.get("ts", "")),
|
||||
),
|
||||
)
|
||||
|
||||
priority_counts = {"P1": 0, "P2": 0, "P3": 0}
|
||||
severity_counts = {"high": 0, "medium": 0, "low": 0}
|
||||
for entry in queue_entries:
|
||||
priority = entry.get("priority", "P3")
|
||||
severity = entry.get("severity", "low")
|
||||
priority_counts[priority] = priority_counts.get(priority, 0) + 1
|
||||
severity_counts[severity] = severity_counts.get(severity, 0) + 1
|
||||
|
||||
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
status = "ok"
|
||||
degraded_reasons = []
|
||||
if not registry_file.exists():
|
||||
status = "degraded"
|
||||
degraded_reasons.append("registry-missing")
|
||||
elif queue_entries:
|
||||
status = "degraded"
|
||||
degraded_reasons.extend(
|
||||
[
|
||||
f"{entry.get('priority', 'P3')}:{entry.get('source', 'unknown')}:{entry.get('status', 'unknown')}"
|
||||
for entry in queue_entries[:5]
|
||||
]
|
||||
)
|
||||
|
||||
next_steps = []
|
||||
for entry in queue_entries:
|
||||
for step in entry.get("next_steps", []):
|
||||
if step and step not in next_steps:
|
||||
next_steps.append(step)
|
||||
if not next_steps:
|
||||
next_steps.append("Review the latest Mascarade incident registry before operator handoff.")
|
||||
|
||||
lines = [
|
||||
"# Mascarade incident queue",
|
||||
"",
|
||||
f"- generated_at: {generated_at}",
|
||||
f"- registry_file: {registry_file if registry_file.exists() else 'missing'}",
|
||||
f"- trust_level: {trust_level}",
|
||||
f"- resume_ref: {resume_ref or 'missing'}",
|
||||
f"- selected_target: {routing.get('selected_target', 'unknown')}",
|
||||
f"- kill_life_memory: {memory_md if memory_md.exists() else 'missing'}",
|
||||
f"- entry_count: {len(queue_entries)}",
|
||||
"- queue_order: priority -> severity -> recency",
|
||||
"",
|
||||
"## Queue summary",
|
||||
"",
|
||||
"| Priority | Count | Severity | Count |",
|
||||
"| --- | --- | --- | --- |",
|
||||
f"| P1 | {priority_counts['P1']} | high | {severity_counts['high']} |",
|
||||
f"| P2 | {priority_counts['P2']} | medium | {severity_counts['medium']} |",
|
||||
f"| P3 | {priority_counts['P3']} | low | {severity_counts['low']} |",
|
||||
"",
|
||||
"## Incident queue",
|
||||
"",
|
||||
"| Rank | Priority | Severity | Status | Source | Timestamp | Path | Reasons |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
|
||||
if queue_entries:
|
||||
for index, entry in enumerate(queue_entries, start=1):
|
||||
reasons = ", ".join([reason for reason in entry.get("reasons", []) if reason]) or "none"
|
||||
lines.append(
|
||||
f"| {index} | {entry.get('priority', 'P3')} | {entry.get('severity', 'low')} | "
|
||||
f"{entry.get('status', 'unknown')} | {entry.get('source', 'unknown')} | "
|
||||
f"{entry.get('ts') or 'n/a'} | `{entry.get('path') or 'n/a'}` | {reasons} |"
|
||||
)
|
||||
else:
|
||||
lines.append("| 0 | P3 | low | ok | queue | n/a | `n/a` | none |")
|
||||
|
||||
lines.extend(["", "## Next steps", ""])
|
||||
for step in next_steps[:10]:
|
||||
lines.append(f"- {step}")
|
||||
|
||||
out_md.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
shutil.copyfile(out_md, out_md.with_name("mascarade_incident_queue_latest.md"))
|
||||
|
||||
payload = {
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "mascarade-incident-queue",
|
||||
"action": "render",
|
||||
"status": status,
|
||||
"contract_status": status,
|
||||
"generated_at": generated_at,
|
||||
"owner": memory_entry.get("owner", "SyncOps"),
|
||||
"resume_ref": resume_ref,
|
||||
"trust_level": trust_level,
|
||||
"routing": routing,
|
||||
"memory_entry": memory_entry,
|
||||
"memory_markdown": str(memory_md) if memory_md.exists() else "",
|
||||
"registry_file": str(registry_file) if registry_file.exists() else "",
|
||||
"entry_count": len(queue_entries),
|
||||
"queue_order": "priority,severity,recency",
|
||||
"priority_counts": priority_counts,
|
||||
"severity_counts": severity_counts,
|
||||
"entries": queue_entries,
|
||||
"markdown_file": str(out_md),
|
||||
"latest_markdown_file": str(out_md.with_name("mascarade_incident_queue_latest.md")),
|
||||
"artifacts": [
|
||||
str(out_md),
|
||||
str(out_md.with_name("mascarade_incident_queue_latest.md")),
|
||||
str(out_json),
|
||||
str(out_json.with_name("mascarade_incident_queue_latest.json")),
|
||||
] + ([str(registry_file)] if registry_file.exists() else []) + ([str(memory_json)] if memory_json.exists() else []),
|
||||
"degraded_reasons": degraded_reasons,
|
||||
"next_steps": next_steps[:10],
|
||||
}
|
||||
|
||||
out_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
shutil.copyfile(out_json, out_json.with_name("mascarade_incident_queue_latest.json"))
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
PY
|
||||
|
||||
if [[ "$OUTPUT_MODE" == "json" ]]; then
|
||||
cat "$OUT_JSON"
|
||||
else
|
||||
cat <<EOF
|
||||
Mascarade incident queue
|
||||
markdown: $OUT_MD
|
||||
json: $OUT_JSON
|
||||
EOF
|
||||
fi
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
COCKPIT_DIR="$ROOT_DIR/artifacts/cockpit"
|
||||
OUTPUT_MODE="text"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
bash tools/cockpit/render_mascarade_incident_watch.sh [--json]
|
||||
|
||||
Options:
|
||||
--json Emit cockpit-v1 JSON to stdout
|
||||
-h,--help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--json)
|
||||
OUTPUT_MODE="json"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir -p "$COCKPIT_DIR"
|
||||
|
||||
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
OUT_MD="$COCKPIT_DIR/mascarade_incident_watch_${RUN_ID}.md"
|
||||
OUT_JSON="$COCKPIT_DIR/mascarade_incident_watch_${RUN_ID}.json"
|
||||
|
||||
python3 - "$COCKPIT_DIR" "$OUT_MD" "$OUT_JSON" <<'PY'
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
cockpit_dir = Path(sys.argv[1])
|
||||
out_md = Path(sys.argv[2])
|
||||
out_json = Path(sys.argv[3])
|
||||
|
||||
registry_file = cockpit_dir / "mascarade_incident_registry_latest.json"
|
||||
queue_file = cockpit_dir / "mascarade_incident_queue_latest.json"
|
||||
daily_file = cockpit_dir / "daily_operator_summary_latest.json"
|
||||
memory_json = cockpit_dir / "kill_life_memory" / "latest.json"
|
||||
memory_md = cockpit_dir / "kill_life_memory" / "latest.md"
|
||||
|
||||
|
||||
def load_json(path: Path):
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
registry = load_json(registry_file)
|
||||
queue = load_json(queue_file)
|
||||
daily = load_json(daily_file)
|
||||
memory_payload = load_json(memory_json) if memory_json.exists() else {}
|
||||
memory_entry = memory_payload.get("entry", {}) if isinstance(memory_payload.get("entry"), dict) else {}
|
||||
routing = memory_entry.get("routing", {}) if isinstance(memory_entry.get("routing"), dict) else {}
|
||||
resume_ref = memory_payload.get("resume_ref") or memory_entry.get("resume_ref", "")
|
||||
trust_level = memory_payload.get("trust_level") or memory_entry.get("trust_level", "inferred")
|
||||
|
||||
priority_counts = registry.get("priority_counts", {}) if isinstance(registry.get("priority_counts"), dict) else {}
|
||||
severity_counts = registry.get("severity_counts", {}) if isinstance(registry.get("severity_counts"), dict) else {}
|
||||
entries = queue.get("entries", []) if isinstance(queue.get("entries"), list) else []
|
||||
top_entries = entries[:5]
|
||||
|
||||
next_steps = []
|
||||
for candidate in (queue.get("next_steps", []), registry.get("next_steps", []), daily.get("next_steps", [])):
|
||||
if isinstance(candidate, list):
|
||||
for step in candidate:
|
||||
if step and step not in next_steps:
|
||||
next_steps.append(step)
|
||||
if not next_steps:
|
||||
next_steps.append("Refresh the latest queue and registry artifacts before the next operator handoff.")
|
||||
|
||||
status = "ok" if registry_file.exists() or queue_file.exists() else "degraded"
|
||||
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
lines = [
|
||||
"# Mascarade incident watch",
|
||||
"",
|
||||
f"- generated_at: {generated_at}",
|
||||
f"- registry_file: {registry_file if registry_file.exists() else 'missing'}",
|
||||
f"- queue_file: {queue_file if queue_file.exists() else 'missing'}",
|
||||
f"- daily_file: {daily_file if daily_file.exists() else 'missing'}",
|
||||
f"- trust_level: {trust_level}",
|
||||
f"- resume_ref: {resume_ref or 'missing'}",
|
||||
f"- selected_target: {routing.get('selected_target', 'unknown')}",
|
||||
f"- kill_life_memory: {memory_md if memory_md.exists() else 'missing'}",
|
||||
"",
|
||||
"## Rollup",
|
||||
"",
|
||||
f"- priority P1/P2/P3: {priority_counts.get('P1', 0)}/{priority_counts.get('P2', 0)}/{priority_counts.get('P3', 0)}",
|
||||
f"- severity high/medium/low: {severity_counts.get('high', 0)}/{severity_counts.get('medium', 0)}/{severity_counts.get('low', 0)}",
|
||||
"",
|
||||
"## Top queue",
|
||||
"",
|
||||
]
|
||||
|
||||
if top_entries:
|
||||
lines.extend([
|
||||
"| Priority | Severity | Status | Source | Timestamp | Reasons |",
|
||||
"| --- | --- | --- | --- | --- | --- |",
|
||||
])
|
||||
for entry in top_entries:
|
||||
reasons = ", ".join([reason for reason in entry.get("reasons", []) if reason]) or "none"
|
||||
lines.append(
|
||||
f"| {entry.get('priority', 'P3')} | {entry.get('severity', 'low')} | "
|
||||
f"{entry.get('status', 'unknown')} | {entry.get('source', 'unknown')} | "
|
||||
f"{entry.get('ts') or 'n/a'} | {reasons} |"
|
||||
)
|
||||
else:
|
||||
lines.append("- no queued incident")
|
||||
|
||||
lines.extend(["", "## Next steps", ""])
|
||||
for step in next_steps[:5]:
|
||||
lines.append(f"- {step}")
|
||||
|
||||
out_md.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
shutil.copyfile(out_md, out_md.with_name("mascarade_incident_watch_latest.md"))
|
||||
|
||||
payload = {
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "mascarade-incident-watch",
|
||||
"action": "render",
|
||||
"status": status,
|
||||
"contract_status": status,
|
||||
"generated_at": generated_at,
|
||||
"owner": memory_entry.get("owner", "SyncOps"),
|
||||
"resume_ref": resume_ref,
|
||||
"trust_level": trust_level,
|
||||
"routing": routing,
|
||||
"memory_entry": memory_entry,
|
||||
"memory_markdown": str(memory_md) if memory_md.exists() else "",
|
||||
"priority_counts": priority_counts,
|
||||
"severity_counts": severity_counts,
|
||||
"top_entries": top_entries,
|
||||
"registry_file": str(registry_file) if registry_file.exists() else "",
|
||||
"queue_file": str(queue_file) if queue_file.exists() else "",
|
||||
"daily_file": str(daily_file) if daily_file.exists() else "",
|
||||
"markdown_file": str(out_md),
|
||||
"latest_markdown_file": str(out_md.with_name("mascarade_incident_watch_latest.md")),
|
||||
"artifacts": [
|
||||
str(out_md),
|
||||
str(out_md.with_name("mascarade_incident_watch_latest.md")),
|
||||
str(out_json),
|
||||
str(out_json.with_name("mascarade_incident_watch_latest.json")),
|
||||
] + [str(path) for path in (registry_file, queue_file, daily_file) if path.exists()] + ([str(memory_json)] if memory_json.exists() else []),
|
||||
"degraded_reasons": [] if status == "ok" else ["incident-watch-missing-artifacts"],
|
||||
"next_steps": next_steps[:5],
|
||||
}
|
||||
|
||||
out_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
shutil.copyfile(out_json, out_json.with_name("mascarade_incident_watch_latest.json"))
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
PY
|
||||
|
||||
if [[ "$OUTPUT_MODE" == "json" ]]; then
|
||||
cat "$OUT_JSON"
|
||||
else
|
||||
cat <<EOF
|
||||
Mascarade incident watch
|
||||
markdown: $OUT_MD
|
||||
json: $OUT_JSON
|
||||
EOF
|
||||
fi
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
COCKPIT_DIR="$ROOT_DIR/artifacts/cockpit"
|
||||
OUTPUT_MODE="text"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
bash tools/cockpit/render_mascarade_watch_history.sh [--json]
|
||||
|
||||
Options:
|
||||
--json Emit cockpit-v1 JSON to stdout
|
||||
-h,--help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--json)
|
||||
OUTPUT_MODE="json"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir -p "$COCKPIT_DIR"
|
||||
|
||||
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
OUT_MD="$COCKPIT_DIR/mascarade_watch_history_${RUN_ID}.md"
|
||||
OUT_JSON="$COCKPIT_DIR/mascarade_watch_history_${RUN_ID}.json"
|
||||
|
||||
python3 - "$COCKPIT_DIR" "$OUT_MD" "$OUT_JSON" <<'PY'
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
cockpit_dir = Path(sys.argv[1])
|
||||
out_md = Path(sys.argv[2])
|
||||
out_json = Path(sys.argv[3])
|
||||
|
||||
|
||||
def load_json(path: Path):
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
watch_files = sorted(
|
||||
[
|
||||
path
|
||||
for path in cockpit_dir.glob("mascarade_incident_watch_*.json")
|
||||
if "latest" not in path.name
|
||||
],
|
||||
key=lambda path: path.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
memory_json = cockpit_dir / "kill_life_memory" / "latest.json"
|
||||
memory_md = cockpit_dir / "kill_life_memory" / "latest.md"
|
||||
memory_payload = load_json(memory_json) if memory_json.exists() else {}
|
||||
memory_entry = memory_payload.get("entry", {}) if isinstance(memory_payload.get("entry"), dict) else {}
|
||||
routing = memory_entry.get("routing", {}) if isinstance(memory_entry.get("routing"), dict) else {}
|
||||
resume_ref = memory_payload.get("resume_ref") or memory_entry.get("resume_ref", "")
|
||||
trust_level = memory_payload.get("trust_level") or memory_entry.get("trust_level", "inferred")
|
||||
|
||||
rows = []
|
||||
for path in watch_files[:30]:
|
||||
payload = load_json(path)
|
||||
priority = payload.get("priority_counts", {}) if isinstance(payload.get("priority_counts"), dict) else {}
|
||||
severity = payload.get("severity_counts", {}) if isinstance(payload.get("severity_counts"), dict) else {}
|
||||
rows.append(
|
||||
{
|
||||
"generated_at": payload.get("generated_at", ""),
|
||||
"status": payload.get("status", "unknown"),
|
||||
"file": str(path),
|
||||
"priority_counts": {
|
||||
"P1": priority.get("P1", 0),
|
||||
"P2": priority.get("P2", 0),
|
||||
"P3": priority.get("P3", 0),
|
||||
},
|
||||
"severity_counts": {
|
||||
"high": severity.get("high", 0),
|
||||
"medium": severity.get("medium", 0),
|
||||
"low": severity.get("low", 0),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
status = "ok" if rows else "degraded"
|
||||
|
||||
lines = [
|
||||
"# Mascarade watch history",
|
||||
"",
|
||||
f"- generated_at: {generated_at}",
|
||||
f"- entry_count: {len(rows)}",
|
||||
f"- trust_level: {trust_level}",
|
||||
f"- resume_ref: {resume_ref or 'missing'}",
|
||||
f"- selected_target: {routing.get('selected_target', 'unknown')}",
|
||||
f"- kill_life_memory: {memory_md if memory_md.exists() else 'missing'}",
|
||||
"",
|
||||
"## History",
|
||||
"",
|
||||
"| Generated at | Status | P1 | P2 | P3 | High | Medium | Low | Source |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
|
||||
if rows:
|
||||
for row in rows:
|
||||
lines.append(
|
||||
f"| {row['generated_at'] or 'n/a'} | {row['status']} | "
|
||||
f"{row['priority_counts']['P1']} | {row['priority_counts']['P2']} | {row['priority_counts']['P3']} | "
|
||||
f"{row['severity_counts']['high']} | {row['severity_counts']['medium']} | {row['severity_counts']['low']} | "
|
||||
f"`{row['file']}` |"
|
||||
)
|
||||
else:
|
||||
lines.append("| n/a | degraded | 0 | 0 | 0 | 0 | 0 | 0 | `n/a` |")
|
||||
|
||||
out_md.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
shutil.copyfile(out_md, out_md.with_name("mascarade_watch_history_latest.md"))
|
||||
|
||||
payload = {
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "mascarade-watch-history",
|
||||
"action": "render",
|
||||
"status": status,
|
||||
"contract_status": status,
|
||||
"generated_at": generated_at,
|
||||
"owner": memory_entry.get("owner", "SyncOps"),
|
||||
"resume_ref": resume_ref,
|
||||
"trust_level": trust_level,
|
||||
"routing": routing,
|
||||
"memory_entry": memory_entry,
|
||||
"memory_markdown": str(memory_md) if memory_md.exists() else "",
|
||||
"entry_count": len(rows),
|
||||
"entries": rows,
|
||||
"markdown_file": str(out_md),
|
||||
"latest_markdown_file": str(out_md.with_name("mascarade_watch_history_latest.md")),
|
||||
"artifacts": [
|
||||
str(out_md),
|
||||
str(out_md.with_name("mascarade_watch_history_latest.md")),
|
||||
str(out_json),
|
||||
str(out_json.with_name("mascarade_watch_history_latest.json")),
|
||||
] + [str(row["file"]) for row in rows] + ([str(memory_json)] if memory_json.exists() else []),
|
||||
"degraded_reasons": [] if rows else ["watch-history-empty"],
|
||||
"next_steps": [] if rows else ["Generate at least one incident-watch artifact before reviewing history."],
|
||||
}
|
||||
|
||||
out_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
shutil.copyfile(out_json, out_json.with_name("mascarade_watch_history_latest.json"))
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
PY
|
||||
|
||||
if [[ "$OUTPUT_MODE" == "json" ]]; then
|
||||
cat "$OUT_JSON"
|
||||
else
|
||||
cat <<EOF
|
||||
Mascarade watch history
|
||||
markdown: $OUT_MD
|
||||
json: $OUT_JSON
|
||||
EOF
|
||||
fi
|
||||
@@ -0,0 +1,299 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
AUDIT_JSON="${ROOT_DIR}/artifacts/cockpit/product_contract_audit/latest.json"
|
||||
KILL_LIFE_JSON="${ROOT_DIR}/artifacts/cockpit/kill_life_memory/latest.json"
|
||||
KILL_LIFE_MD="${ROOT_DIR}/artifacts/cockpit/kill_life_memory/latest.md"
|
||||
DAILY_MD="${ROOT_DIR}/artifacts/cockpit/daily_operator_summary_latest.md"
|
||||
OUTPUT_DIR="${ROOT_DIR}/artifacts/cockpit/product_contract_handoff"
|
||||
|
||||
JSON_ONLY=0
|
||||
MARKDOWN_ONLY=0
|
||||
NO_REFRESH=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
bash tools/cockpit/render_product_contract_handoff.sh [--json|--markdown|--no-refresh]
|
||||
|
||||
Description:
|
||||
Génère un handoff produit minimal entre ops, Mascarade et kill_life.
|
||||
Le handoff agrège l'audit du contrat, la mémoire latest kill_life et
|
||||
la synthèse quotidienne pour fournir un seul point de reprise.
|
||||
|
||||
Options:
|
||||
--json Affiche seulement le JSON latest.
|
||||
--markdown Affiche seulement le Markdown latest.
|
||||
--no-refresh Garde un mode strict lecture seule sans régénérer les prérequis légers.
|
||||
-h, --help Affiche cette aide.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--json)
|
||||
JSON_ONLY=1
|
||||
shift
|
||||
;;
|
||||
--markdown)
|
||||
MARKDOWN_ONLY=1
|
||||
shift
|
||||
;;
|
||||
--no-refresh)
|
||||
NO_REFRESH=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "${JSON_ONLY}" == "1" && "${MARKDOWN_ONLY}" == "1" ]]; then
|
||||
printf 'Use only one of --json or --markdown.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p "${OUTPUT_DIR}"
|
||||
|
||||
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
|
||||
JSON_FILE="${OUTPUT_DIR}/product_contract_handoff_${TIMESTAMP}.json"
|
||||
MARKDOWN_FILE="${OUTPUT_DIR}/product_contract_handoff_${TIMESTAMP}.md"
|
||||
LATEST_JSON="${OUTPUT_DIR}/latest.json"
|
||||
LATEST_MARKDOWN="${OUTPUT_DIR}/latest.md"
|
||||
|
||||
python3 - "${ROOT_DIR}" "${AUDIT_JSON}" "${KILL_LIFE_JSON}" "${DAILY_MD}" "${JSON_FILE}" "${MARKDOWN_FILE}" "${NO_REFRESH}" "${TIMESTAMP}" <<'PY'
|
||||
import datetime as dt
|
||||
import json
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
root_dir = pathlib.Path(sys.argv[1])
|
||||
audit_path = pathlib.Path(sys.argv[2])
|
||||
kill_life_path = pathlib.Path(sys.argv[3])
|
||||
daily_path = pathlib.Path(sys.argv[4])
|
||||
json_out = pathlib.Path(sys.argv[5])
|
||||
md_out = pathlib.Path(sys.argv[6])
|
||||
no_refresh = sys.argv[7] == "1"
|
||||
timestamp = sys.argv[8]
|
||||
|
||||
|
||||
def load_json(path: pathlib.Path):
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def read_markdown_snippet(path: pathlib.Path, max_lines: int = 12):
|
||||
if not path.exists():
|
||||
return []
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
return [line for line in lines[:max_lines] if line.strip()]
|
||||
|
||||
|
||||
def run_json_command(command):
|
||||
result = subprocess.run(command, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
return {"status": "failed", "stderr": result.stderr.strip(), "stdout": result.stdout.strip()}
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return {"status": "failed", "stderr": result.stderr.strip(), "stdout": result.stdout.strip(), "error": "invalid-json"}
|
||||
|
||||
|
||||
audit = load_json(audit_path)
|
||||
audit_status = audit.get("status", "missing")
|
||||
degraded_reasons = []
|
||||
prereqs_refreshed = []
|
||||
kill_life_refresh_status = "skipped"
|
||||
daily_refresh_status = "skipped"
|
||||
|
||||
if not no_refresh and not kill_life_path.exists():
|
||||
kill_life_refresh = run_json_command([
|
||||
"bash",
|
||||
str(root_dir / "tools/cockpit/write_kill_life_memory_entry.sh"),
|
||||
"--component", "product_contract_handoff",
|
||||
"--status", "ok" if audit_status == "ok" else "degraded",
|
||||
"--owner", "KillLife-Bridge",
|
||||
"--decision-action", "refresh-product-contract-handoff-prereqs",
|
||||
"--decision-reason", "Refresh lightweight kill_life continuity so the product handoff can render without replaying a heavy lane.",
|
||||
"--next-step", "Review artifacts/cockpit/product_contract_handoff/latest.md",
|
||||
"--resume-ref", f"kill-life:product-contract-handoff:{timestamp}",
|
||||
"--trust-level", "bounded" if audit_status == "ok" else "inferred",
|
||||
"--artifact", "artifacts/cockpit/product_contract_audit/latest.json",
|
||||
"--json",
|
||||
])
|
||||
kill_life_refresh_status = str(kill_life_refresh.get("status", "failed"))
|
||||
prereqs_refreshed.append("kill_life_memory")
|
||||
|
||||
kill_life = load_json(kill_life_path)
|
||||
entry = kill_life.get("entry", {}) if isinstance(kill_life.get("entry"), dict) else {}
|
||||
routing = entry.get("routing", {}) if isinstance(entry.get("routing"), dict) else {}
|
||||
decision = entry.get("decision", {}) if isinstance(entry.get("decision"), dict) else {}
|
||||
memory_entry = entry.get("memory_entry", {}) if isinstance(entry.get("memory_entry"), dict) else {}
|
||||
|
||||
if not no_refresh and not daily_path.exists():
|
||||
daily_refresh = run_json_command([
|
||||
"bash",
|
||||
str(root_dir / "tools/cockpit/render_daily_operator_summary.sh"),
|
||||
"--json",
|
||||
])
|
||||
daily_refresh_status = str(daily_refresh.get("status", "failed"))
|
||||
prereqs_refreshed.append("daily_operator_summary")
|
||||
|
||||
daily_snippet = read_markdown_snippet(daily_path)
|
||||
kill_life_available = kill_life_path.exists() and bool(kill_life)
|
||||
daily_available = daily_path.exists() and bool(daily_snippet)
|
||||
|
||||
trust_level = kill_life.get("trust_level") or entry.get("trust_level", "inferred")
|
||||
resume_ref = kill_life.get("resume_ref") or entry.get("resume_ref", "missing")
|
||||
owner = entry.get("owner", "unknown")
|
||||
selected_target = routing.get("selected_target", "unknown")
|
||||
next_step = kill_life.get("next_step") or entry.get("next_step", "Review latest daily summary")
|
||||
|
||||
if audit_status != "ok":
|
||||
degraded_reasons.append(f"audit-{audit_status}")
|
||||
if kill_life_refresh_status in {"failed", "error", "blocked"}:
|
||||
degraded_reasons.append(f"kill-life-refresh-{kill_life_refresh_status}")
|
||||
if daily_refresh_status in {"failed", "error", "blocked"}:
|
||||
degraded_reasons.append(f"daily-refresh-{daily_refresh_status}")
|
||||
if not kill_life_available:
|
||||
degraded_reasons.append("missing-kill-life-latest")
|
||||
if not daily_available:
|
||||
degraded_reasons.append("missing-daily-summary-latest")
|
||||
|
||||
status = "degraded" if degraded_reasons else "ok"
|
||||
contract_status = status
|
||||
|
||||
next_steps = []
|
||||
if audit_status != "ok":
|
||||
next_steps.append("Refresh product contract audit before relying on the handoff.")
|
||||
if not kill_life_available:
|
||||
next_steps.append("Refresh kill_life continuity memory before using the product handoff as a restart point.")
|
||||
if not daily_available:
|
||||
next_steps.append("Regenerate the daily operator summary so the handoff includes an operator-readable snippet.")
|
||||
if not next_steps:
|
||||
next_steps.append(next_step)
|
||||
|
||||
payload = {
|
||||
"contract_version": "2026-03-21",
|
||||
"component": "product-contract-handoff",
|
||||
"status": status,
|
||||
"contract_status": contract_status,
|
||||
"generated_at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"audit_status": audit_status,
|
||||
"trust_level": trust_level,
|
||||
"resume_ref": resume_ref,
|
||||
"owner": owner,
|
||||
"selected_target": selected_target,
|
||||
"decision": decision,
|
||||
"memory_entry": memory_entry,
|
||||
"next_step": next_step,
|
||||
"next_steps": next_steps,
|
||||
"degraded_reasons": degraded_reasons,
|
||||
"prereqs_refreshed": prereqs_refreshed,
|
||||
"kill_life_refresh_status": kill_life_refresh_status,
|
||||
"daily_refresh_status": daily_refresh_status,
|
||||
"kill_life_available": kill_life_available,
|
||||
"daily_summary_available": daily_available,
|
||||
"daily_summary_snippet": daily_snippet,
|
||||
"json_file": str(json_out),
|
||||
"markdown_file": str(md_out),
|
||||
"latest_json_file": str(json_out.with_name("latest.json")),
|
||||
"latest_markdown_file": str(md_out.with_name("latest.md")),
|
||||
"artifacts": [
|
||||
"artifacts/cockpit/product_contract_audit/latest.json",
|
||||
"artifacts/cockpit/kill_life_memory/latest.json",
|
||||
"artifacts/cockpit/daily_operator_summary_latest.md",
|
||||
"artifacts/cockpit/product_contract_handoff/latest.json",
|
||||
"artifacts/cockpit/product_contract_handoff/latest.md",
|
||||
],
|
||||
}
|
||||
|
||||
json_out.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Product contract handoff",
|
||||
"",
|
||||
f"- generated_at: {payload['generated_at']}",
|
||||
f"- audit_status: {audit_status}",
|
||||
f"- trust_level: {trust_level}",
|
||||
f"- resume_ref: {resume_ref}",
|
||||
f"- owner: {owner}",
|
||||
f"- selected_target: {selected_target}",
|
||||
f"- next_step: {next_step}",
|
||||
f"- degraded_reasons: {', '.join(degraded_reasons) if degraded_reasons else 'none'}",
|
||||
f"- prereqs_refreshed: {', '.join(prereqs_refreshed) if prereqs_refreshed else 'none'}",
|
||||
f"- kill_life_refresh_status: {kill_life_refresh_status}",
|
||||
f"- daily_refresh_status: {daily_refresh_status}",
|
||||
f"- kill_life_available: {kill_life_available}",
|
||||
f"- daily_summary_available: {daily_available}",
|
||||
]
|
||||
|
||||
if decision:
|
||||
lines.extend([
|
||||
"",
|
||||
"## Decision",
|
||||
"",
|
||||
f"- action: {decision.get('action', 'unknown')}",
|
||||
f"- reason: {decision.get('reason', 'unknown')}",
|
||||
])
|
||||
|
||||
if memory_entry:
|
||||
lines.extend([
|
||||
"",
|
||||
"## Continuity",
|
||||
"",
|
||||
f"- intent: {memory_entry.get('intent', 'unknown')}",
|
||||
f"- decision_state: {memory_entry.get('decision_state', 'unknown')}",
|
||||
f"- handoff: {memory_entry.get('handoff', 'unknown')}",
|
||||
])
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Next steps",
|
||||
"",
|
||||
])
|
||||
for step in next_steps:
|
||||
lines.append(f"- {step}")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Daily snippet",
|
||||
"",
|
||||
])
|
||||
if daily_snippet:
|
||||
for line in daily_snippet:
|
||||
lines.append(f"- {line}")
|
||||
else:
|
||||
lines.append("- daily summary unavailable")
|
||||
|
||||
md_out.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
PY
|
||||
|
||||
cp "${JSON_FILE}" "${LATEST_JSON}"
|
||||
cp "${MARKDOWN_FILE}" "${LATEST_MARKDOWN}"
|
||||
|
||||
if [[ "${JSON_ONLY}" == "1" ]]; then
|
||||
cat "${LATEST_JSON}"
|
||||
elif [[ "${MARKDOWN_ONLY}" == "1" ]]; then
|
||||
cat "${LATEST_MARKDOWN}"
|
||||
else
|
||||
printf 'product_contract_handoff status=%s json=%s markdown=%s\n' \
|
||||
"$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["status"])' "${LATEST_JSON}")" \
|
||||
"${LATEST_JSON#${ROOT_DIR}/}" \
|
||||
"${LATEST_MARKDOWN#${ROOT_DIR}/}"
|
||||
fi
|
||||
+393
@@ -0,0 +1,393 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
OUTPUT_FILE="${ROOT_DIR}/artifacts/cockpit/weekly_refonte_summary.md"
|
||||
WINDOW_DAYS=7
|
||||
TASKS_FILE="${ROOT_DIR}/specs/04_tasks.md"
|
||||
MACHINE_SYNC_FILE="${ROOT_DIR}/docs/MACHINE_SYNC_STATUS_2026-03-20.md"
|
||||
REFONTE_LOG_DIR="${ROOT_DIR}/artifacts/refonte_tui"
|
||||
CAD_LOG_DIR="${ROOT_DIR}/artifacts/cad-fusion"
|
||||
COCKPIT_DIR="${ROOT_DIR}/artifacts/cockpit"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF_USAGE'
|
||||
Usage: bash tools/cockpit/render_weekly_refonte_summary.sh [options]
|
||||
|
||||
Génère une synthèse hebdomadaire exploitable pour l’opérateur et les agents.
|
||||
|
||||
Options:
|
||||
--output FILE Fichier markdown cible. Défaut: artifacts/cockpit/weekly_refonte_summary.md
|
||||
--days N Fenêtre d’analyse de logs. Défaut: 7
|
||||
-h, --help Aide
|
||||
EOF_USAGE
|
||||
}
|
||||
|
||||
ensure_dirs() {
|
||||
mkdir -p "$(dirname "$OUTPUT_FILE")"
|
||||
}
|
||||
|
||||
collect_files_by_mtime_desc() {
|
||||
local dir="$1"
|
||||
local pattern="$2"
|
||||
local path
|
||||
local epoch
|
||||
|
||||
[[ -d "$dir" ]] || return 0
|
||||
while IFS= read -r -d '' path; do
|
||||
if epoch="$(stat -c '%Y' "$path" 2>/dev/null)"; then
|
||||
:
|
||||
elif epoch="$(stat -f '%m' "$path" 2>/dev/null)"; then
|
||||
:
|
||||
else
|
||||
epoch="0"
|
||||
fi
|
||||
printf '%s %s\n' "$epoch" "$path"
|
||||
done < <(find "$dir" -maxdepth 1 -type f -name "$pattern" -print0)
|
||||
}
|
||||
|
||||
latest_file() {
|
||||
local dir="$1"
|
||||
local pattern="$2"
|
||||
|
||||
collect_files_by_mtime_desc "$dir" "$pattern" | sort -nr | head -n 1 | cut -d' ' -f2-
|
||||
}
|
||||
|
||||
recent_count() {
|
||||
local dir="$1"
|
||||
local pattern="$2"
|
||||
|
||||
if [[ ! -d "$dir" ]]; then
|
||||
printf '0'
|
||||
return 0
|
||||
fi
|
||||
find "$dir" -maxdepth 1 -type f -name "$pattern" -mtime "-${WINDOW_DAYS}" | wc -l | tr -d ' '
|
||||
}
|
||||
|
||||
render_open_tasks() {
|
||||
if [[ ! -f "$TASKS_FILE" ]]; then
|
||||
printf -- '- tasks file missing: %s\n' "$TASKS_FILE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! rg -n "^[[:space:]]*-[[:space:]]*\\[[[:space:]]\\]" "$TASKS_FILE" | head -n 10 | while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
printf -- '- %s\n' "$line"
|
||||
done; then
|
||||
printf -- '- no open task found in %s\n' "$TASKS_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
render_machine_sync() {
|
||||
if [[ ! -f "$MACHINE_SYNC_FILE" ]]; then
|
||||
printf -- '- machine sync file missing: %s\n' "$MACHINE_SYNC_FILE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! rg -n "ready|degraded|blocked|Priorit|incident|sync|delta|status" "$MACHINE_SYNC_FILE" | head -n 8 | while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
printf -- '- %s\n' "$line"
|
||||
done; then
|
||||
printf -- '- no machine-sync highlight extracted\n'
|
||||
fi
|
||||
}
|
||||
|
||||
render_registry_severity() {
|
||||
local file="$1"
|
||||
|
||||
if [[ -z "$file" || ! -f "$file" ]]; then
|
||||
printf -- '- severity snapshot unavailable\n'
|
||||
return 0
|
||||
fi
|
||||
|
||||
python3 - "$file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
print("- severity snapshot unavailable")
|
||||
raise SystemExit(0)
|
||||
|
||||
severity = data.get("severity_counts", {}) if isinstance(data.get("severity_counts"), dict) else {}
|
||||
priority = data.get("priority_counts", {}) if isinstance(data.get("priority_counts"), dict) else {}
|
||||
print(f"- severity high/medium/low: {severity.get('high', 0)}/{severity.get('medium', 0)}/{severity.get('low', 0)}")
|
||||
print(f"- priority P1/P2/P3: {priority.get('P1', 0)}/{priority.get('P2', 0)}/{priority.get('P3', 0)}")
|
||||
PY
|
||||
}
|
||||
|
||||
render_log_tail() {
|
||||
local file="$1"
|
||||
if [[ -z "$file" || ! -f "$file" ]]; then
|
||||
printf 'No log available.\n'
|
||||
return 0
|
||||
fi
|
||||
tail -n 12 "$file"
|
||||
}
|
||||
|
||||
render_kill_life_meta() {
|
||||
local file="$1"
|
||||
|
||||
if [[ -z "$file" || ! -f "$file" ]]; then
|
||||
printf -- '- trust_level: missing\n- resume_ref: missing\n- owner: unknown\n- selected_target: unknown\n- memory_entry: missing\n'
|
||||
return 0
|
||||
fi
|
||||
|
||||
python3 - "$file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
print("- trust_level: invalid")
|
||||
print("- resume_ref: invalid")
|
||||
print("- owner: unknown")
|
||||
print("- selected_target: unknown")
|
||||
print("- memory_entry: invalid")
|
||||
raise SystemExit(0)
|
||||
|
||||
entry = data.get("entry", {}) if isinstance(data.get("entry"), dict) else {}
|
||||
routing = entry.get("routing", {}) if isinstance(entry.get("routing"), dict) else {}
|
||||
memory_entry = entry.get("memory_entry", {}) if isinstance(entry.get("memory_entry"), dict) else {}
|
||||
handoff = memory_entry.get("handoff") if isinstance(memory_entry, dict) else None
|
||||
print(f"- trust_level: {data.get('trust_level') or entry.get('trust_level', 'inferred')}")
|
||||
print(f"- resume_ref: {data.get('resume_ref') or entry.get('resume_ref', 'missing')}")
|
||||
print(f"- owner: {entry.get('owner', 'unknown')}")
|
||||
print(f"- selected_target: {routing.get('selected_target', 'unknown')}")
|
||||
print(f"- memory_entry: {handoff or memory_entry or 'missing'}")
|
||||
PY
|
||||
}
|
||||
|
||||
cad_lane_status() {
|
||||
local file="$1"
|
||||
|
||||
if [[ -z "$file" || ! -f "$file" ]]; then
|
||||
printf 'unknown'
|
||||
return 0
|
||||
fi
|
||||
|
||||
if rg -q "host entrypoint missing" "$file"; then
|
||||
printf 'blocked:kicad-host-entrypoint'
|
||||
return 0
|
||||
fi
|
||||
|
||||
if rg -q "FAIL KiCad MCP host smoke" "$file"; then
|
||||
printf 'blocked:kicad-host-smoke'
|
||||
return 0
|
||||
fi
|
||||
|
||||
if rg -q "FAIL " "$file"; then
|
||||
printf 'degraded'
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf 'ready'
|
||||
}
|
||||
|
||||
route_next_blocking_lot() {
|
||||
local cad_status="$1"
|
||||
|
||||
case "$cad_status" in
|
||||
blocked:*|degraded)
|
||||
printf 'yiacad-fusion'
|
||||
;;
|
||||
*)
|
||||
printf 'mesh-governance'
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
render_routing() {
|
||||
local cad_status="$1"
|
||||
|
||||
case "$cad_status" in
|
||||
blocked:kicad-host-entrypoint)
|
||||
cat <<'EOF_ROUTING'
|
||||
- Exec lot prioritaire: `yiacad-fusion` tant que l’entrypoint hôte KiCad manque dans `mascarade-main`.
|
||||
- Lane parallèle: `mesh-governance` reste en maintien documentaire/TUI sans propagation risquée.
|
||||
- Revue attendue: matérialiser `finetune/kicad_mcp_server/dist/index.js` ou décider explicitement que le fallback conteneur est un état supporté.
|
||||
- Etat repo-local: scripts, TUI, snapshots et rollback sont alignés; le blocage restant n'est pas dans `Kill_LIFE`.
|
||||
EOF_ROUTING
|
||||
;;
|
||||
blocked:kicad-host-smoke)
|
||||
cat <<'EOF_ROUTING'
|
||||
- Exec lot prioritaire: `yiacad-fusion` tant que `KiCad MCP host smoke` clôt sa sortie avant handshake.
|
||||
- Lane parallèle: `mesh-governance` reste en maintien documentaire/TUI sans propagation risquée.
|
||||
- Revue attendue: confirmer la lane `mascarade-main` du launcher KiCad puis rejouer `yiacad-fusion --action smoke`.
|
||||
EOF_ROUTING
|
||||
;;
|
||||
degraded)
|
||||
cat <<'EOF_ROUTING'
|
||||
- Exec lot prioritaire: `yiacad-fusion` pour fermer les checks CAD encore dégradés.
|
||||
- Lane parallèle: `mesh-governance` en maintien et synthèse.
|
||||
- Revue attendue: relire le dernier log CAD et purger les artefacts périmés si besoin.
|
||||
EOF_ROUTING
|
||||
;;
|
||||
ready)
|
||||
cat <<'EOF_ROUTING'
|
||||
- Exec lot prioritaire: `mesh-governance` une fois la lane CAD stable.
|
||||
- Lane parallèle: `yiacad-uiux-apple-native` pour la montée P1 palette/review center.
|
||||
- Revue attendue: publier la preuve opératoire YiACAD dans les trackers hebdomadaires.
|
||||
EOF_ROUTING
|
||||
;;
|
||||
*)
|
||||
cat <<'EOF_ROUTING'
|
||||
- Exec lot prioritaire: `mesh-governance`.
|
||||
- Lane parallèle: `yiacad-fusion`.
|
||||
- Revue attendue: compléter les preuves avant propagation.
|
||||
EOF_ROUTING
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
write_summary() {
|
||||
local latest_refonte_log
|
||||
local latest_cad_log
|
||||
local latest_mascarade_brief
|
||||
local latest_mascarade_registry
|
||||
local latest_mascarade_registry_json
|
||||
local latest_mascarade_queue
|
||||
local latest_mascarade_watch
|
||||
local latest_mascarade_watch_history
|
||||
local latest_kill_life_memory_json
|
||||
local latest_kill_life_memory_md
|
||||
local cad_status
|
||||
local next_blocking_lot
|
||||
|
||||
bash "${ROOT_DIR}/tools/cockpit/render_mascarade_incident_brief.sh" >/dev/null 2>&1 || true
|
||||
bash "${ROOT_DIR}/tools/cockpit/mascarade_incident_registry.sh" >/dev/null 2>&1 || true
|
||||
bash "${ROOT_DIR}/tools/cockpit/render_mascarade_incident_queue.sh" >/dev/null 2>&1 || true
|
||||
bash "${ROOT_DIR}/tools/cockpit/render_mascarade_incident_watch.sh" >/dev/null 2>&1 || true
|
||||
bash "${ROOT_DIR}/tools/cockpit/render_mascarade_watch_history.sh" >/dev/null 2>&1 || true
|
||||
|
||||
latest_refonte_log="$(latest_file "$REFONTE_LOG_DIR" "*.log")"
|
||||
latest_cad_log="$(latest_file "$CAD_LOG_DIR" "*.log")"
|
||||
latest_mascarade_brief="$(latest_file "$COCKPIT_DIR" "mascarade_incident_brief_*.md")"
|
||||
latest_mascarade_registry="$(latest_file "$COCKPIT_DIR" "mascarade_incident_registry_*.md")"
|
||||
latest_mascarade_registry_json="$(latest_file "$COCKPIT_DIR" "mascarade_incident_registry_*.json")"
|
||||
latest_mascarade_queue="$(latest_file "$COCKPIT_DIR" "mascarade_incident_queue_*.md")"
|
||||
latest_mascarade_watch="$(latest_file "$COCKPIT_DIR" "mascarade_incident_watch_*.md")"
|
||||
latest_mascarade_watch_history="$(latest_file "$COCKPIT_DIR" "mascarade_watch_history_*.md")"
|
||||
latest_kill_life_memory_json="${COCKPIT_DIR}/kill_life_memory/latest.json"
|
||||
latest_kill_life_memory_md="${COCKPIT_DIR}/kill_life_memory/latest.md"
|
||||
cad_status="$(cad_lane_status "$latest_cad_log")"
|
||||
next_blocking_lot="$(route_next_blocking_lot "$cad_status")"
|
||||
|
||||
cat >"$OUTPUT_FILE" <<EOF
|
||||
# Weekly Refonte Summary
|
||||
|
||||
- generated_at: $(date '+%Y-%m-%d %H:%M:%S %z')
|
||||
- window_days: ${WINDOW_DAYS}
|
||||
- next_blocking_lot: ${next_blocking_lot}
|
||||
- parallel_design_lane: yiacad-uiux-apple-native
|
||||
- refonte_logs_recent: $(recent_count "$REFONTE_LOG_DIR" "*.log")
|
||||
- cad_logs_recent: $(recent_count "$CAD_LOG_DIR" "*.log")
|
||||
- latest_refonte_log: ${latest_refonte_log:-none}
|
||||
- latest_cad_log: ${latest_cad_log:-none}
|
||||
- latest_cad_status: ${cad_status}
|
||||
- latest_mascarade_brief: ${latest_mascarade_brief:-none}
|
||||
- latest_mascarade_registry: ${latest_mascarade_registry:-none}
|
||||
- latest_mascarade_queue: ${latest_mascarade_queue:-none}
|
||||
- latest_mascarade_watch: ${latest_mascarade_watch:-none}
|
||||
- latest_mascarade_watch_history: ${latest_mascarade_watch_history:-none}
|
||||
- latest_kill_life_memory: ${latest_kill_life_memory_md:-none}
|
||||
|
||||
## Open tasks
|
||||
|
||||
$(render_open_tasks)
|
||||
|
||||
## Machine sync highlights
|
||||
|
||||
$(render_machine_sync)
|
||||
|
||||
## Latest refonte log tail
|
||||
|
||||
\`\`\`text
|
||||
$(render_log_tail "$latest_refonte_log")
|
||||
\`\`\`
|
||||
|
||||
## Latest YiACAD log tail
|
||||
|
||||
\`\`\`text
|
||||
$(render_log_tail "$latest_cad_log")
|
||||
\`\`\`
|
||||
|
||||
## Latest Mascarade incident brief
|
||||
|
||||
\`\`\`text
|
||||
$(render_log_tail "$latest_mascarade_brief")
|
||||
\`\`\`
|
||||
|
||||
## Mascarade incident registry
|
||||
|
||||
$(render_registry_severity "$latest_mascarade_registry_json")
|
||||
|
||||
\`\`\`text
|
||||
$(render_log_tail "$latest_mascarade_registry")
|
||||
\`\`\`
|
||||
|
||||
## Mascarade incident queue
|
||||
|
||||
\`\`\`text
|
||||
$(render_log_tail "$latest_mascarade_queue")
|
||||
\`\`\`
|
||||
|
||||
## Mascarade incident watch
|
||||
|
||||
\`\`\`text
|
||||
$(render_log_tail "$latest_mascarade_watch")
|
||||
\`\`\`
|
||||
|
||||
## Mascarade watch history
|
||||
|
||||
\`\`\`text
|
||||
$(render_log_tail "$latest_mascarade_watch_history")
|
||||
\`\`\`
|
||||
|
||||
## Kill life execution continuity
|
||||
|
||||
$(render_kill_life_meta "$latest_kill_life_memory_json")
|
||||
|
||||
\`\`\`text
|
||||
$(render_log_tail "$latest_kill_life_memory_md")
|
||||
\`\`\`
|
||||
|
||||
## Routing
|
||||
|
||||
$(render_routing "$cad_status")
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--output)
|
||||
OUTPUT_FILE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--days)
|
||||
WINDOW_DAYS="${2:-7}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Option inconnue: $1" >&2
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
ensure_dirs
|
||||
write_summary
|
||||
cat "$OUTPUT_FILE"
|
||||
@@ -0,0 +1,794 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="${ROOT_DIR:-${SCRIPT_DIR%/tools/cockpit}}"
|
||||
source "${ROOT_DIR}/tools/cockpit/json_contract.sh"
|
||||
LOG_DIR="$ROOT_DIR/artifacts/cockpit"
|
||||
PERSIST_DAYS=14
|
||||
REGISTRY_FILE="${ROOT_DIR}/specs/contracts/machine_registry.mesh.json"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
bash tools/cockpit/run_alignment_daily.sh [options]
|
||||
|
||||
Runbook de vérification quotidienne de l'alignement SSH + état repo.
|
||||
|
||||
Options:
|
||||
--json Affiche un résumé JSON (en plus du log console)
|
||||
--skip-healthcheck Exécute uniquement le rafraîchissement repo (utile hors machine de pilotage).
|
||||
--skip-mesh Exécute uniquement health-check + refresh repo sans préflight mesh.
|
||||
--skip-mascarade-health
|
||||
Ignore volontairement le health-check live Mascarade/Ollama.
|
||||
--mesh-load-profile <tower-first|photon-safe>
|
||||
Active la stratégie de charge pour le préflight mesh.
|
||||
- tower-first (défaut): clems -> kxkm -> cils -> local -> root (réserve)
|
||||
- photon-safe: idem, avec non-essentiel désactivé sur CILS et pas de précheck applicatif CILS.
|
||||
Le plan P2P priorise systématiquement Tower puis KXKM avant CILS (quota) puis local.
|
||||
--purge-days N Conserve les logs de ce script sur N jours (défaut: 14)
|
||||
--no-purge Désactive la purge automatique des logs
|
||||
--skip-log-ops Ignore volontairement le résumé purgatif log_ops (JSON).
|
||||
-h, --help Affiche cette aide
|
||||
USAGE
|
||||
}
|
||||
|
||||
json_output=false
|
||||
skip_healthcheck=false
|
||||
skip_mesh=false
|
||||
do_purge=true
|
||||
MESH_LOAD_PROFILE="tower-first"
|
||||
SKIP_MASCARADE_HEALTH=false
|
||||
MASCARADE_HEALTH_FILE=""
|
||||
MASCARADE_HEALTH_STATUS="n/a"
|
||||
MASCARADE_RUNTIME_STATUS="unknown"
|
||||
MASCARADE_PROVIDER="unknown"
|
||||
MASCARADE_MODEL="unknown"
|
||||
MASCARADE_LOGS_FILE=""
|
||||
MASCARADE_LOGS_STATUS="n/a"
|
||||
MASCARADE_BRIEF_FILE=""
|
||||
MASCARADE_BRIEF_STATUS="n/a"
|
||||
MASCARADE_BRIEF_MARKDOWN=""
|
||||
MASCARADE_REGISTRY_FILE=""
|
||||
MASCARADE_REGISTRY_STATUS="n/a"
|
||||
MASCARADE_REGISTRY_MARKDOWN=""
|
||||
MASCARADE_QUEUE_FILE=""
|
||||
MASCARADE_QUEUE_STATUS="n/a"
|
||||
MASCARADE_QUEUE_MARKDOWN=""
|
||||
MASCARADE_WATCH_FILE=""
|
||||
MASCARADE_WATCH_STATUS="n/a"
|
||||
MASCARADE_WATCH_MARKDOWN=""
|
||||
MASCARADE_WATCH_HISTORY_FILE=""
|
||||
MASCARADE_WATCH_HISTORY_STATUS="n/a"
|
||||
MASCARADE_WATCH_HISTORY_MARKDOWN=""
|
||||
MASCARADE_ROUTING_FILE=""
|
||||
MASCARADE_ROUTING_STATUS="n/a"
|
||||
DAILY_OPERATOR_SUMMARY_FILE=""
|
||||
DAILY_OPERATOR_SUMMARY_STATUS="n/a"
|
||||
DAILY_OPERATOR_SUMMARY_MARKDOWN=""
|
||||
KILL_LIFE_MEMORY_FILE=""
|
||||
KILL_LIFE_MEMORY_STATUS="n/a"
|
||||
KILL_LIFE_MEMORY_MARKDOWN=""
|
||||
TRUST_LEVEL="inferred"
|
||||
RESUME_REF=""
|
||||
SKIP_LOG_OPS=false
|
||||
LOG_OPS_SUMMARY_FILE=""
|
||||
LOG_OPS_PURGE_FILE=""
|
||||
LOG_OPS_SUMMARY_STATUS="n/a"
|
||||
LOG_OPS_PURGE_STATUS="n/a"
|
||||
LOG_OPS_STALE="0"
|
||||
LOG_OPS_PURGED="0"
|
||||
REGISTRY_SUMMARY_FILE=""
|
||||
REGISTRY_SUMMARY_STATUS="n/a"
|
||||
REGISTRY_TARGET_COUNT="0"
|
||||
REGISTRY_DEFAULT_PROFILE=""
|
||||
OVERALL_STATUS="ok"
|
||||
|
||||
json_field() {
|
||||
local file="$1"
|
||||
local field="$2"
|
||||
local raw=""
|
||||
|
||||
raw="$(sed -n "s/.*\"${field}\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" "${file}" | head -n1 || true)"
|
||||
if [[ -z "${raw}" ]]; then
|
||||
raw="$(sed -n "s/.*\"${field}\"[[:space:]]*:[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p" "${file}" | head -n1 || true)"
|
||||
fi
|
||||
printf '%s' "${raw}"
|
||||
}
|
||||
|
||||
json_inline_object() {
|
||||
local file="$1"
|
||||
local field="${2:-}"
|
||||
python3 - "$file" "$field" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
field = sys.argv[2]
|
||||
payload = {}
|
||||
if path.exists():
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
payload = {"status": "invalid-json", "file": str(path)}
|
||||
if field:
|
||||
value = payload.get(field, {}) if isinstance(payload, dict) else {}
|
||||
else:
|
||||
value = payload
|
||||
print(json.dumps(value if isinstance(value, (dict, list)) else {}, ensure_ascii=True))
|
||||
PY
|
||||
}
|
||||
|
||||
json_decision_object() {
|
||||
local action="$1"
|
||||
local reason="$2"
|
||||
python3 - "$action" "$reason" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
print(json.dumps({"action": sys.argv[1], "reason": sys.argv[2]}, ensure_ascii=True))
|
||||
PY
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--json) json_output=true; shift ;;
|
||||
--skip-healthcheck) skip_healthcheck=true; shift ;;
|
||||
--skip-mesh) skip_mesh=true; shift ;;
|
||||
--skip-mascarade-health)
|
||||
SKIP_MASCARADE_HEALTH=true
|
||||
shift
|
||||
;;
|
||||
--mesh-load-profile)
|
||||
MESH_LOAD_PROFILE="${2:-}"
|
||||
if [[ -z "${MESH_LOAD_PROFILE}" || ! "${MESH_LOAD_PROFILE}" =~ ^(tower-first|photon-safe)$ ]]; then
|
||||
echo "[error] --mesh-load-profile requires tower-first|photon-safe" >&2
|
||||
exit 1
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
--purge-days)
|
||||
PERSIST_DAYS="${2:-}"
|
||||
if ! [[ "$PERSIST_DAYS" =~ ^[0-9]+$ ]]; then
|
||||
echo "[error] --purge-days requires an integer" >&2
|
||||
exit 1
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
--no-purge) do_purge=false; shift ;;
|
||||
--skip-log-ops)
|
||||
SKIP_LOG_OPS=true
|
||||
shift
|
||||
;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "[error] option inconnue: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
timestamp="$(date '+%Y%m%d_%H%M%S')"
|
||||
log_file="$LOG_DIR/machine_alignment_daily_${timestamp}.log"
|
||||
summary_file="${LOG_DIR}/machine_alignment_daily_latest.log"
|
||||
start_ts="$(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||
|
||||
log() {
|
||||
printf '%s\n' "$*" | tee -a "$log_file"
|
||||
}
|
||||
|
||||
run_step() {
|
||||
local label="$1"
|
||||
shift
|
||||
log "[run] $label"
|
||||
if "$@" >>"$log_file" 2>&1; then
|
||||
log "[ok] $label"
|
||||
return 0
|
||||
else
|
||||
local code=$?
|
||||
log "[ko] $label (exit=$code)"
|
||||
return $code
|
||||
fi
|
||||
}
|
||||
|
||||
run_step_capture() {
|
||||
local label="$1"
|
||||
local capture_file="$2"
|
||||
shift 2
|
||||
log "[run] $label"
|
||||
if "$@" >"${capture_file}" 2>&1; then
|
||||
log "[ok] $label"
|
||||
return 0
|
||||
else
|
||||
local code=$?
|
||||
log "[ko] $label (exit=$code)"
|
||||
return $code
|
||||
fi
|
||||
}
|
||||
|
||||
status=0
|
||||
: >"$log_file"
|
||||
(
|
||||
echo "generated_at=${start_ts}"
|
||||
echo "runner=run_alignment_daily"
|
||||
echo "root_dir=$ROOT_DIR"
|
||||
) >>"$log_file"
|
||||
|
||||
if $skip_healthcheck; then
|
||||
log "[skip] healthcheck_json"
|
||||
else
|
||||
run_step "healthcheck_json" bash "$ROOT_DIR/tools/cockpit/ssh_healthcheck.sh" --json || status=1
|
||||
fi
|
||||
|
||||
REGISTRY_SUMMARY_FILE="$LOG_DIR/machine_registry_summary_${timestamp}.json"
|
||||
if run_step_capture "machine_registry_summary_json" "${REGISTRY_SUMMARY_FILE}" \
|
||||
bash "$ROOT_DIR/tools/cockpit/machine_registry.sh" --action summary --json; then
|
||||
REGISTRY_SUMMARY_STATUS="$(json_field "${REGISTRY_SUMMARY_FILE}" status)"
|
||||
REGISTRY_TARGET_COUNT="$(json_field "${REGISTRY_SUMMARY_FILE}" target_count)"
|
||||
REGISTRY_DEFAULT_PROFILE="$(json_field "${REGISTRY_SUMMARY_FILE}" default_profile)"
|
||||
else
|
||||
status=1
|
||||
REGISTRY_SUMMARY_STATUS="failed"
|
||||
REGISTRY_TARGET_COUNT="0"
|
||||
REGISTRY_DEFAULT_PROFILE=""
|
||||
fi
|
||||
|
||||
if [[ -z "${REGISTRY_TARGET_COUNT}" ]]; then
|
||||
REGISTRY_TARGET_COUNT="0"
|
||||
fi
|
||||
if [[ -z "${REGISTRY_DEFAULT_PROFILE}" ]]; then
|
||||
REGISTRY_DEFAULT_PROFILE="unknown"
|
||||
fi
|
||||
|
||||
run_step "repo_refresh_header" bash "$ROOT_DIR/tools/repo_state/repo_refresh.sh" --header-only || status=1
|
||||
|
||||
if $SKIP_MASCARADE_HEALTH; then
|
||||
log "[skip] mascarade_runtime_health_json"
|
||||
MASCARADE_HEALTH_STATUS="skipped"
|
||||
MASCARADE_RUNTIME_STATUS="skipped"
|
||||
MASCARADE_LOGS_STATUS="skipped"
|
||||
MASCARADE_BRIEF_STATUS="skipped"
|
||||
MASCARADE_REGISTRY_STATUS="skipped"
|
||||
MASCARADE_QUEUE_STATUS="skipped"
|
||||
MASCARADE_WATCH_STATUS="skipped"
|
||||
MASCARADE_WATCH_HISTORY_STATUS="skipped"
|
||||
else
|
||||
MASCARADE_HEALTH_FILE="$LOG_DIR/mascarade_runtime_health_${timestamp}.json"
|
||||
if run_step_capture "mascarade_runtime_health_json" "${MASCARADE_HEALTH_FILE}" \
|
||||
bash "$ROOT_DIR/tools/cockpit/mascarade_runtime_health.sh" --json; then
|
||||
MASCARADE_HEALTH_STATUS="$(json_field "${MASCARADE_HEALTH_FILE}" status)"
|
||||
MASCARADE_RUNTIME_STATUS="$(json_field "${MASCARADE_HEALTH_FILE}" runtime_status)"
|
||||
MASCARADE_PROVIDER="$(json_field "${MASCARADE_HEALTH_FILE}" provider)"
|
||||
MASCARADE_MODEL="$(json_field "${MASCARADE_HEALTH_FILE}" model)"
|
||||
else
|
||||
MASCARADE_HEALTH_STATUS="failed"
|
||||
MASCARADE_RUNTIME_STATUS="failed"
|
||||
status=1
|
||||
fi
|
||||
|
||||
MASCARADE_LOGS_FILE="$LOG_DIR/mascarade_logs_latest_${timestamp}.json"
|
||||
if run_step_capture "mascarade_logs_latest_json" "${MASCARADE_LOGS_FILE}" \
|
||||
bash "$ROOT_DIR/tools/cockpit/mascarade_logs_tui.sh" --action latest --json; then
|
||||
MASCARADE_LOGS_STATUS="$(json_field "${MASCARADE_LOGS_FILE}" status)"
|
||||
else
|
||||
MASCARADE_LOGS_STATUS="failed"
|
||||
fi
|
||||
|
||||
MASCARADE_BRIEF_FILE="$LOG_DIR/mascarade_incident_brief_${timestamp}.json"
|
||||
if run_step_capture "mascarade_incident_brief_json" "${MASCARADE_BRIEF_FILE}" \
|
||||
bash "$ROOT_DIR/tools/cockpit/render_mascarade_incident_brief.sh" --json; then
|
||||
MASCARADE_BRIEF_STATUS="$(json_field "${MASCARADE_BRIEF_FILE}" status)"
|
||||
MASCARADE_BRIEF_MARKDOWN="$(json_field "${MASCARADE_BRIEF_FILE}" markdown_file)"
|
||||
else
|
||||
MASCARADE_BRIEF_STATUS="failed"
|
||||
fi
|
||||
|
||||
MASCARADE_REGISTRY_FILE="$LOG_DIR/mascarade_incident_registry_${timestamp}.json"
|
||||
if run_step_capture "mascarade_incident_registry_json" "${MASCARADE_REGISTRY_FILE}" \
|
||||
bash "$ROOT_DIR/tools/cockpit/mascarade_incident_registry.sh" --json; then
|
||||
MASCARADE_REGISTRY_STATUS="$(json_field "${MASCARADE_REGISTRY_FILE}" status)"
|
||||
MASCARADE_REGISTRY_MARKDOWN="$(json_field "${MASCARADE_REGISTRY_FILE}" markdown_file)"
|
||||
else
|
||||
MASCARADE_REGISTRY_STATUS="failed"
|
||||
fi
|
||||
|
||||
MASCARADE_QUEUE_FILE="$LOG_DIR/mascarade_incident_queue_${timestamp}.json"
|
||||
if run_step_capture "mascarade_incident_queue_json" "${MASCARADE_QUEUE_FILE}" \
|
||||
bash "$ROOT_DIR/tools/cockpit/render_mascarade_incident_queue.sh" --json; then
|
||||
MASCARADE_QUEUE_STATUS="$(json_field "${MASCARADE_QUEUE_FILE}" status)"
|
||||
MASCARADE_QUEUE_MARKDOWN="$(json_field "${MASCARADE_QUEUE_FILE}" markdown_file)"
|
||||
else
|
||||
MASCARADE_QUEUE_STATUS="failed"
|
||||
fi
|
||||
fi
|
||||
|
||||
mesh_status="skipped"
|
||||
mesh_json_file=""
|
||||
if $skip_mesh; then
|
||||
log "[skip] mesh_sync_preflight_json"
|
||||
else
|
||||
mesh_json_file="$LOG_DIR/machine_alignment_mesh_preflight_${timestamp}.json"
|
||||
if run_step_capture "mesh_sync_preflight_json" "${mesh_json_file}" \
|
||||
bash "$ROOT_DIR/tools/cockpit/mesh_sync_preflight.sh" --json --load-profile "${MESH_LOAD_PROFILE}"; then
|
||||
mesh_status="$(sed -n 's/.*"mesh_status"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$mesh_json_file" | head -n 1)"
|
||||
else
|
||||
status=1
|
||||
mesh_status="failed"
|
||||
fi
|
||||
fi
|
||||
|
||||
if $SKIP_LOG_OPS; then
|
||||
log "[skip] log_ops_summary_json"
|
||||
LOG_OPS_SUMMARY_STATUS="skipped"
|
||||
LOG_OPS_PURGE_STATUS="skipped"
|
||||
else
|
||||
LOG_OPS_SUMMARY_FILE="$LOG_DIR/machine_alignment_log_ops_summary_${timestamp}.json"
|
||||
if run_step_capture "log_ops_summary_json" "${LOG_OPS_SUMMARY_FILE}" \
|
||||
bash "$ROOT_DIR/tools/cockpit/log_ops.sh" --action summary --json --retention-days "$PERSIST_DAYS"; then
|
||||
LOG_OPS_SUMMARY_STATUS="$(json_field "${LOG_OPS_SUMMARY_FILE}" status)"
|
||||
LOG_OPS_STALE="$(json_field "${LOG_OPS_SUMMARY_FILE}" stale)"
|
||||
else
|
||||
status=1
|
||||
LOG_OPS_SUMMARY_STATUS="failed"
|
||||
LOG_OPS_STALE="0"
|
||||
fi
|
||||
|
||||
if [[ -z "${LOG_OPS_STALE}" ]]; then
|
||||
LOG_OPS_STALE="0"
|
||||
fi
|
||||
|
||||
LOG_OPS_PURGE_FILE="$LOG_DIR/machine_alignment_log_ops_purge_${timestamp}.json"
|
||||
if $do_purge; then
|
||||
if run_step_capture "log_ops_purge_json" "${LOG_OPS_PURGE_FILE}" \
|
||||
bash "$ROOT_DIR/tools/cockpit/log_ops.sh" --action purge --apply --retention-days "$PERSIST_DAYS" --json; then
|
||||
LOG_OPS_PURGE_STATUS="$(json_field "${LOG_OPS_PURGE_FILE}" status)"
|
||||
LOG_OPS_PURGED="$(json_field "${LOG_OPS_PURGE_FILE}" purged_count)"
|
||||
else
|
||||
LOG_OPS_PURGE_STATUS="failed"
|
||||
LOG_OPS_PURGED="0"
|
||||
status=1
|
||||
fi
|
||||
else
|
||||
if run_step_capture "log_ops_purge_dry_json" "${LOG_OPS_PURGE_FILE}" \
|
||||
bash "$ROOT_DIR/tools/cockpit/log_ops.sh" --action purge --retention-days "$PERSIST_DAYS" --json; then
|
||||
LOG_OPS_PURGE_STATUS="$(json_field "${LOG_OPS_PURGE_FILE}" status)"
|
||||
LOG_OPS_PURGED="$(json_field "${LOG_OPS_PURGE_FILE}" purged_count)"
|
||||
else
|
||||
LOG_OPS_PURGE_STATUS="failed"
|
||||
LOG_OPS_PURGED="0"
|
||||
status=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$mesh_status" ]]; then
|
||||
mesh_status="unknown"
|
||||
fi
|
||||
if [[ -z "$LOG_OPS_SUMMARY_STATUS" ]]; then
|
||||
LOG_OPS_SUMMARY_STATUS="unknown"
|
||||
fi
|
||||
if [[ -z "$LOG_OPS_PURGE_STATUS" ]]; then
|
||||
LOG_OPS_PURGE_STATUS="unknown"
|
||||
fi
|
||||
if [[ -z "$MASCARADE_HEALTH_STATUS" ]]; then
|
||||
MASCARADE_HEALTH_STATUS="unknown"
|
||||
fi
|
||||
if [[ -z "$MASCARADE_RUNTIME_STATUS" ]]; then
|
||||
MASCARADE_RUNTIME_STATUS="unknown"
|
||||
fi
|
||||
if [[ -z "$MASCARADE_PROVIDER" ]]; then
|
||||
MASCARADE_PROVIDER="unknown"
|
||||
fi
|
||||
if [[ -z "$MASCARADE_MODEL" ]]; then
|
||||
MASCARADE_MODEL="unknown"
|
||||
fi
|
||||
if [[ -z "$MASCARADE_LOGS_STATUS" ]]; then
|
||||
MASCARADE_LOGS_STATUS="unknown"
|
||||
fi
|
||||
if [[ -z "$MASCARADE_BRIEF_STATUS" ]]; then
|
||||
MASCARADE_BRIEF_STATUS="unknown"
|
||||
fi
|
||||
if [[ -z "$MASCARADE_REGISTRY_STATUS" ]]; then
|
||||
MASCARADE_REGISTRY_STATUS="unknown"
|
||||
fi
|
||||
if [[ -z "$MASCARADE_QUEUE_STATUS" ]]; then
|
||||
MASCARADE_QUEUE_STATUS="unknown"
|
||||
fi
|
||||
if [[ -z "$MASCARADE_WATCH_STATUS" ]]; then
|
||||
MASCARADE_WATCH_STATUS="unknown"
|
||||
fi
|
||||
if [[ -z "$MASCARADE_WATCH_HISTORY_STATUS" ]]; then
|
||||
MASCARADE_WATCH_HISTORY_STATUS="unknown"
|
||||
fi
|
||||
|
||||
if (( status == 0 )); then
|
||||
result="ok"
|
||||
else
|
||||
result="ko"
|
||||
fi
|
||||
if [[ "${mesh_status}" == "degraded" || "${LOG_OPS_SUMMARY_STATUS}" == "degraded" || "${LOG_OPS_PURGE_STATUS}" == "degraded" || "${MASCARADE_HEALTH_STATUS}" == "degraded" || "${MASCARADE_LOGS_STATUS}" == "degraded" || "${MASCARADE_BRIEF_STATUS}" == "degraded" || "${MASCARADE_REGISTRY_STATUS}" == "degraded" || "${MASCARADE_QUEUE_STATUS}" == "degraded" || "${DAILY_OPERATOR_SUMMARY_STATUS}" == "degraded" ]]; then
|
||||
OVERALL_STATUS="degraded"
|
||||
elif [[ "${mesh_status}" == "blocked" || "${LOG_OPS_SUMMARY_STATUS}" == "blocked" || "${LOG_OPS_PURGE_STATUS}" == "blocked" || "${MASCARADE_HEALTH_STATUS}" == "blocked" || "${MASCARADE_HEALTH_STATUS}" == "failed" || "${MASCARADE_LOGS_STATUS}" == "blocked" || "${MASCARADE_LOGS_STATUS}" == "failed" || "${MASCARADE_BRIEF_STATUS}" == "blocked" || "${MASCARADE_BRIEF_STATUS}" == "failed" || "${MASCARADE_REGISTRY_STATUS}" == "blocked" || "${MASCARADE_REGISTRY_STATUS}" == "failed" || "${MASCARADE_QUEUE_STATUS}" == "blocked" || "${MASCARADE_QUEUE_STATUS}" == "failed" || "${DAILY_OPERATOR_SUMMARY_STATUS}" == "blocked" || "${DAILY_OPERATOR_SUMMARY_STATUS}" == "failed" || "${result}" == "ko" ]]; then
|
||||
OVERALL_STATUS="ko"
|
||||
fi
|
||||
if [[ "${result}" == "ko" ]]; then
|
||||
OVERALL_STATUS="ko"
|
||||
fi
|
||||
if [[ "${OVERALL_STATUS}" == "ok" ]]; then
|
||||
result="ok"
|
||||
elif [[ "${OVERALL_STATUS}" == "degraded" ]]; then
|
||||
result="degraded"
|
||||
else
|
||||
result="ko"
|
||||
fi
|
||||
|
||||
artifacts=("${log_file}" "${summary_file}")
|
||||
degraded_reasons=()
|
||||
next_steps=()
|
||||
if [[ -n "${REGISTRY_SUMMARY_FILE}" ]]; then
|
||||
artifacts+=("${REGISTRY_SUMMARY_FILE}")
|
||||
fi
|
||||
if [[ -n "${MASCARADE_HEALTH_FILE}" ]]; then
|
||||
artifacts+=("${MASCARADE_HEALTH_FILE}")
|
||||
fi
|
||||
if [[ -n "${MASCARADE_LOGS_FILE}" ]]; then
|
||||
artifacts+=("${MASCARADE_LOGS_FILE}")
|
||||
fi
|
||||
if [[ -n "${MASCARADE_BRIEF_FILE}" ]]; then
|
||||
artifacts+=("${MASCARADE_BRIEF_FILE}")
|
||||
fi
|
||||
if [[ -n "${MASCARADE_REGISTRY_FILE}" ]]; then
|
||||
artifacts+=("${MASCARADE_REGISTRY_FILE}")
|
||||
fi
|
||||
if [[ -n "${MASCARADE_QUEUE_FILE}" ]]; then
|
||||
artifacts+=("${MASCARADE_QUEUE_FILE}")
|
||||
fi
|
||||
if [[ -n "${DAILY_OPERATOR_SUMMARY_FILE}" ]]; then
|
||||
artifacts+=("${DAILY_OPERATOR_SUMMARY_FILE}")
|
||||
fi
|
||||
if [[ -n "${mesh_json_file}" ]]; then
|
||||
artifacts+=("${mesh_json_file}")
|
||||
fi
|
||||
if [[ -n "${LOG_OPS_SUMMARY_FILE}" ]]; then
|
||||
artifacts+=("${LOG_OPS_SUMMARY_FILE}")
|
||||
fi
|
||||
if [[ -n "${LOG_OPS_PURGE_FILE}" ]]; then
|
||||
artifacts+=("${LOG_OPS_PURGE_FILE}")
|
||||
fi
|
||||
if [[ "${mesh_status}" == "degraded" || "${mesh_status}" == "failed" || "${mesh_status}" == "blocked" ]]; then
|
||||
degraded_reasons+=("mesh-${mesh_status}")
|
||||
next_steps+=("bash tools/cockpit/mesh_health_check.sh --json --load-profile ${MESH_LOAD_PROFILE}")
|
||||
fi
|
||||
if [[ "${REGISTRY_SUMMARY_STATUS}" == "degraded" || "${REGISTRY_SUMMARY_STATUS}" == "failed" || "${REGISTRY_SUMMARY_STATUS}" == "blocked" ]]; then
|
||||
degraded_reasons+=("machine-registry-${REGISTRY_SUMMARY_STATUS}")
|
||||
next_steps+=("bash tools/cockpit/machine_registry.sh --action summary --json")
|
||||
fi
|
||||
if [[ "${MASCARADE_HEALTH_STATUS}" == "degraded" || "${MASCARADE_HEALTH_STATUS}" == "failed" || "${MASCARADE_HEALTH_STATUS}" == "blocked" ]]; then
|
||||
degraded_reasons+=("mascarade-runtime-${MASCARADE_HEALTH_STATUS}")
|
||||
next_steps+=("bash tools/cockpit/mascarade_runtime_health.sh --json")
|
||||
fi
|
||||
if [[ "${MASCARADE_LOGS_STATUS}" == "degraded" || "${MASCARADE_LOGS_STATUS}" == "failed" || "${MASCARADE_LOGS_STATUS}" == "blocked" ]]; then
|
||||
degraded_reasons+=("mascarade-logs-${MASCARADE_LOGS_STATUS}")
|
||||
next_steps+=("bash tools/cockpit/mascarade_logs_tui.sh --action latest --json")
|
||||
fi
|
||||
if [[ "${MASCARADE_BRIEF_STATUS}" == "degraded" || "${MASCARADE_BRIEF_STATUS}" == "failed" || "${MASCARADE_BRIEF_STATUS}" == "blocked" ]]; then
|
||||
degraded_reasons+=("mascarade-brief-${MASCARADE_BRIEF_STATUS}")
|
||||
next_steps+=("bash tools/cockpit/render_mascarade_incident_brief.sh --json")
|
||||
fi
|
||||
if [[ "${MASCARADE_REGISTRY_STATUS}" == "degraded" || "${MASCARADE_REGISTRY_STATUS}" == "failed" || "${MASCARADE_REGISTRY_STATUS}" == "blocked" ]]; then
|
||||
degraded_reasons+=("mascarade-registry-${MASCARADE_REGISTRY_STATUS}")
|
||||
next_steps+=("bash tools/cockpit/mascarade_incident_registry.sh --json")
|
||||
fi
|
||||
if [[ "${MASCARADE_QUEUE_STATUS}" == "degraded" || "${MASCARADE_QUEUE_STATUS}" == "failed" || "${MASCARADE_QUEUE_STATUS}" == "blocked" ]]; then
|
||||
degraded_reasons+=("mascarade-queue-${MASCARADE_QUEUE_STATUS}")
|
||||
next_steps+=("bash tools/cockpit/render_mascarade_incident_queue.sh --json")
|
||||
fi
|
||||
if [[ "${DAILY_OPERATOR_SUMMARY_STATUS}" == "degraded" || "${DAILY_OPERATOR_SUMMARY_STATUS}" == "failed" || "${DAILY_OPERATOR_SUMMARY_STATUS}" == "blocked" ]]; then
|
||||
degraded_reasons+=("daily-operator-summary-${DAILY_OPERATOR_SUMMARY_STATUS}")
|
||||
next_steps+=("bash tools/cockpit/render_daily_operator_summary.sh --json")
|
||||
fi
|
||||
if [[ "${LOG_OPS_SUMMARY_STATUS}" == "degraded" || "${LOG_OPS_SUMMARY_STATUS}" == "failed" || "${LOG_OPS_SUMMARY_STATUS}" == "blocked" ]]; then
|
||||
degraded_reasons+=("log-ops-summary-${LOG_OPS_SUMMARY_STATUS}")
|
||||
next_steps+=("bash tools/cockpit/log_ops.sh --action summary --retention-days ${PERSIST_DAYS} --json")
|
||||
fi
|
||||
if [[ "${LOG_OPS_PURGE_STATUS}" == "degraded" || "${LOG_OPS_PURGE_STATUS}" == "failed" || "${LOG_OPS_PURGE_STATUS}" == "blocked" ]]; then
|
||||
degraded_reasons+=("log-ops-purge-${LOG_OPS_PURGE_STATUS}")
|
||||
next_steps+=("bash tools/cockpit/log_ops.sh --action purge --retention-days ${PERSIST_DAYS} --apply --json")
|
||||
fi
|
||||
if [[ "${result}" == "ko" ]]; then
|
||||
degraded_reasons+=("alignment-run-failed")
|
||||
next_steps+=("Inspect ${log_file} for the failing step and rerun the degraded-safe checks.")
|
||||
fi
|
||||
|
||||
if $do_purge; then
|
||||
find "$LOG_DIR" -type f -name 'machine_alignment_daily_*.log' -mtime +"$PERSIST_DAYS" -delete || true
|
||||
fi
|
||||
|
||||
cp "$log_file" "$summary_file"
|
||||
|
||||
{
|
||||
echo "[summary] result=$result"
|
||||
echo "[summary] mesh_status=$mesh_status"
|
||||
echo "[summary] mesh_load_profile=$MESH_LOAD_PROFILE"
|
||||
if [[ -n "$mesh_json_file" ]]; then
|
||||
echo "[summary] mesh_json_file=$mesh_json_file"
|
||||
fi
|
||||
echo "[summary] registry_summary_file=$REGISTRY_SUMMARY_FILE"
|
||||
echo "[summary] registry_summary_status=$REGISTRY_SUMMARY_STATUS"
|
||||
echo "[summary] registry_target_count=$REGISTRY_TARGET_COUNT"
|
||||
echo "[summary] registry_default_profile=$REGISTRY_DEFAULT_PROFILE"
|
||||
echo "[summary] mascarade_health_status=$MASCARADE_HEALTH_STATUS"
|
||||
echo "[summary] mascarade_runtime_status=$MASCARADE_RUNTIME_STATUS"
|
||||
echo "[summary] mascarade_provider=$MASCARADE_PROVIDER"
|
||||
echo "[summary] mascarade_model=$MASCARADE_MODEL"
|
||||
echo "[summary] mascarade_health_file=$MASCARADE_HEALTH_FILE"
|
||||
echo "[summary] mascarade_logs_status=$MASCARADE_LOGS_STATUS"
|
||||
echo "[summary] mascarade_logs_file=$MASCARADE_LOGS_FILE"
|
||||
echo "[summary] mascarade_brief_status=$MASCARADE_BRIEF_STATUS"
|
||||
echo "[summary] mascarade_brief_file=$MASCARADE_BRIEF_FILE"
|
||||
echo "[summary] mascarade_brief_markdown=$MASCARADE_BRIEF_MARKDOWN"
|
||||
echo "[summary] mascarade_registry_status=$MASCARADE_REGISTRY_STATUS"
|
||||
echo "[summary] mascarade_registry_file=$MASCARADE_REGISTRY_FILE"
|
||||
echo "[summary] mascarade_registry_markdown=$MASCARADE_REGISTRY_MARKDOWN"
|
||||
echo "[summary] mascarade_queue_status=$MASCARADE_QUEUE_STATUS"
|
||||
echo "[summary] mascarade_queue_file=$MASCARADE_QUEUE_FILE"
|
||||
echo "[summary] mascarade_queue_markdown=$MASCARADE_QUEUE_MARKDOWN"
|
||||
echo "[summary] log_ops_summary_status=$LOG_OPS_SUMMARY_STATUS"
|
||||
echo "[summary] log_ops_stale=$LOG_OPS_STALE"
|
||||
echo "[summary] log_ops_purge_status=$LOG_OPS_PURGE_STATUS"
|
||||
echo "[summary] log_ops_purged=$LOG_OPS_PURGED"
|
||||
echo "[summary] log_ops_summary_file=$LOG_OPS_SUMMARY_FILE"
|
||||
echo "[summary] log_ops_purge_file=$LOG_OPS_PURGE_FILE"
|
||||
echo "[summary] log_file=$log_file"
|
||||
echo "[summary] latest_log=$summary_file"
|
||||
echo "[summary] timestamp=$timestamp"
|
||||
} | tee -a "$log_file"
|
||||
|
||||
DAILY_OPERATOR_SUMMARY_FILE="$LOG_DIR/daily_operator_summary_${timestamp}.json"
|
||||
if bash "$ROOT_DIR/tools/cockpit/render_daily_operator_summary.sh" \
|
||||
--daily-log "$log_file" \
|
||||
--brief-markdown "$MASCARADE_BRIEF_MARKDOWN" \
|
||||
--registry-markdown "$MASCARADE_REGISTRY_MARKDOWN" \
|
||||
--queue-markdown "$MASCARADE_QUEUE_MARKDOWN" \
|
||||
--json >"$DAILY_OPERATOR_SUMMARY_FILE" 2>>"$log_file"; then
|
||||
DAILY_OPERATOR_SUMMARY_STATUS="$(json_field "${DAILY_OPERATOR_SUMMARY_FILE}" status)"
|
||||
DAILY_OPERATOR_SUMMARY_MARKDOWN="$(json_field "${DAILY_OPERATOR_SUMMARY_FILE}" markdown_file)"
|
||||
else
|
||||
DAILY_OPERATOR_SUMMARY_STATUS="failed"
|
||||
fi
|
||||
|
||||
MASCARADE_WATCH_FILE="$LOG_DIR/mascarade_incident_watch_${timestamp}.json"
|
||||
if bash "$ROOT_DIR/tools/cockpit/render_mascarade_incident_watch.sh" --json >"$MASCARADE_WATCH_FILE" 2>>"$log_file"; then
|
||||
MASCARADE_WATCH_STATUS="$(json_field "${MASCARADE_WATCH_FILE}" status)"
|
||||
MASCARADE_WATCH_MARKDOWN="$(json_field "${MASCARADE_WATCH_FILE}" markdown_file)"
|
||||
else
|
||||
MASCARADE_WATCH_STATUS="failed"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "[summary] daily_operator_summary_status=$DAILY_OPERATOR_SUMMARY_STATUS"
|
||||
echo "[summary] daily_operator_summary_file=$DAILY_OPERATOR_SUMMARY_FILE"
|
||||
echo "[summary] daily_operator_summary_markdown=$DAILY_OPERATOR_SUMMARY_MARKDOWN"
|
||||
echo "[summary] mascarade_watch_status=$MASCARADE_WATCH_STATUS"
|
||||
echo "[summary] mascarade_watch_file=$MASCARADE_WATCH_FILE"
|
||||
echo "[summary] mascarade_watch_markdown=$MASCARADE_WATCH_MARKDOWN"
|
||||
} | tee -a "$log_file"
|
||||
|
||||
MASCARADE_WATCH_HISTORY_FILE="$LOG_DIR/mascarade_watch_history_${timestamp}.json"
|
||||
if bash "$ROOT_DIR/tools/cockpit/render_mascarade_watch_history.sh" --json >"$MASCARADE_WATCH_HISTORY_FILE" 2>>"$log_file"; then
|
||||
MASCARADE_WATCH_HISTORY_STATUS="$(json_field "${MASCARADE_WATCH_HISTORY_FILE}" status)"
|
||||
MASCARADE_WATCH_HISTORY_MARKDOWN="$(json_field "${MASCARADE_WATCH_HISTORY_FILE}" markdown_file)"
|
||||
else
|
||||
MASCARADE_WATCH_HISTORY_STATUS="failed"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "[summary] mascarade_watch_history_status=$MASCARADE_WATCH_HISTORY_STATUS"
|
||||
echo "[summary] mascarade_watch_history_file=$MASCARADE_WATCH_HISTORY_FILE"
|
||||
echo "[summary] mascarade_watch_history_markdown=$MASCARADE_WATCH_HISTORY_MARKDOWN"
|
||||
} | tee -a "$log_file"
|
||||
|
||||
if [[ "${DAILY_OPERATOR_SUMMARY_STATUS}" == "degraded" && "${OVERALL_STATUS}" == "ok" ]]; then
|
||||
OVERALL_STATUS="degraded"
|
||||
fi
|
||||
if [[ "${DAILY_OPERATOR_SUMMARY_STATUS}" == "blocked" || "${DAILY_OPERATOR_SUMMARY_STATUS}" == "failed" ]]; then
|
||||
OVERALL_STATUS="ko"
|
||||
fi
|
||||
if [[ -n "${DAILY_OPERATOR_SUMMARY_FILE}" ]]; then
|
||||
artifacts+=("${DAILY_OPERATOR_SUMMARY_FILE}")
|
||||
fi
|
||||
if [[ "${DAILY_OPERATOR_SUMMARY_STATUS}" == "degraded" || "${DAILY_OPERATOR_SUMMARY_STATUS}" == "failed" || "${DAILY_OPERATOR_SUMMARY_STATUS}" == "blocked" ]]; then
|
||||
degraded_reasons+=("daily-operator-summary-${DAILY_OPERATOR_SUMMARY_STATUS}")
|
||||
next_steps+=("bash tools/cockpit/render_daily_operator_summary.sh --json")
|
||||
fi
|
||||
if [[ "${MASCARADE_WATCH_STATUS}" == "degraded" && "${OVERALL_STATUS}" == "ok" ]]; then
|
||||
OVERALL_STATUS="degraded"
|
||||
fi
|
||||
if [[ "${MASCARADE_WATCH_STATUS}" == "blocked" || "${MASCARADE_WATCH_STATUS}" == "failed" ]]; then
|
||||
OVERALL_STATUS="ko"
|
||||
fi
|
||||
if [[ -n "${MASCARADE_WATCH_FILE}" ]]; then
|
||||
artifacts+=("${MASCARADE_WATCH_FILE}")
|
||||
fi
|
||||
if [[ "${MASCARADE_WATCH_STATUS}" == "degraded" || "${MASCARADE_WATCH_STATUS}" == "failed" || "${MASCARADE_WATCH_STATUS}" == "blocked" ]]; then
|
||||
degraded_reasons+=("mascarade-watch-${MASCARADE_WATCH_STATUS}")
|
||||
next_steps+=("bash tools/cockpit/render_mascarade_incident_watch.sh --json")
|
||||
fi
|
||||
if [[ "${MASCARADE_WATCH_HISTORY_STATUS}" == "degraded" && "${OVERALL_STATUS}" == "ok" ]]; then
|
||||
OVERALL_STATUS="degraded"
|
||||
fi
|
||||
if [[ "${MASCARADE_WATCH_HISTORY_STATUS}" == "blocked" || "${MASCARADE_WATCH_HISTORY_STATUS}" == "failed" ]]; then
|
||||
OVERALL_STATUS="ko"
|
||||
fi
|
||||
if [[ -n "${MASCARADE_WATCH_HISTORY_FILE}" ]]; then
|
||||
artifacts+=("${MASCARADE_WATCH_HISTORY_FILE}")
|
||||
fi
|
||||
if [[ "${MASCARADE_WATCH_HISTORY_STATUS}" == "degraded" || "${MASCARADE_WATCH_HISTORY_STATUS}" == "failed" || "${MASCARADE_WATCH_HISTORY_STATUS}" == "blocked" ]]; then
|
||||
degraded_reasons+=("mascarade-watch-history-${MASCARADE_WATCH_HISTORY_STATUS}")
|
||||
next_steps+=("bash tools/cockpit/render_mascarade_watch_history.sh --json")
|
||||
fi
|
||||
if [[ "${OVERALL_STATUS}" == "ok" ]]; then
|
||||
result="ok"
|
||||
elif [[ "${OVERALL_STATUS}" == "degraded" ]]; then
|
||||
result="degraded"
|
||||
else
|
||||
result="ko"
|
||||
fi
|
||||
|
||||
MASCARADE_ROUTING_FILE="$LOG_DIR/mascarade_dispatch_route_${timestamp}.json"
|
||||
if bash "$ROOT_DIR/tools/cockpit/mascarade_dispatch_mesh.sh" --action route --profile "kxkm-mesh-syncops" --json >"$MASCARADE_ROUTING_FILE" 2>>"$log_file"; then
|
||||
MASCARADE_ROUTING_STATUS="$(json_field "${MASCARADE_ROUTING_FILE}" status)"
|
||||
else
|
||||
MASCARADE_ROUTING_STATUS="failed"
|
||||
fi
|
||||
if [[ -n "${MASCARADE_ROUTING_FILE}" ]]; then
|
||||
artifacts+=("${MASCARADE_ROUTING_FILE}")
|
||||
fi
|
||||
if [[ "${MASCARADE_ROUTING_STATUS}" == "degraded" || "${MASCARADE_ROUTING_STATUS}" == "failed" || "${MASCARADE_ROUTING_STATUS}" == "blocked" ]]; then
|
||||
degraded_reasons+=("mascarade-routing-${MASCARADE_ROUTING_STATUS}")
|
||||
next_steps+=("bash tools/cockpit/mascarade_dispatch_mesh.sh --action route --profile kxkm-mesh-syncops --json")
|
||||
if [[ "${result}" == "ok" ]]; then
|
||||
result="degraded"
|
||||
fi
|
||||
fi
|
||||
|
||||
contract_status="$(json_contract_map_status "${result}")"
|
||||
if [[ "${contract_status}" == "ok" && "${MASCARADE_ROUTING_STATUS}" == "ok" ]]; then
|
||||
TRUST_LEVEL="verified"
|
||||
elif [[ "${MASCARADE_ROUTING_STATUS}" == "ok" ]]; then
|
||||
TRUST_LEVEL="bounded"
|
||||
else
|
||||
TRUST_LEVEL="inferred"
|
||||
fi
|
||||
RESUME_REF="kill-life:run-alignment-daily:${timestamp}:${MESH_LOAD_PROFILE}"
|
||||
KILL_LIFE_MEMORY_FILE="$LOG_DIR/kill_life_memory_run_alignment_daily_${timestamp}.json"
|
||||
memory_next_step="bash tools/cockpit/run_alignment_daily.sh --json --mesh-load-profile ${MESH_LOAD_PROFILE}"
|
||||
if [[ ${#next_steps[@]} -gt 0 ]]; then
|
||||
memory_next_step="${next_steps[0]}"
|
||||
fi
|
||||
if bash "$ROOT_DIR/tools/cockpit/write_kill_life_memory_entry.sh" \
|
||||
--component "run_alignment_daily" \
|
||||
--status "${contract_status}" \
|
||||
--owner "SyncOps" \
|
||||
--decision-action "run-alignment-daily" \
|
||||
--decision-reason "Daily cockpit alignment is persisted in kill_life with explicit Tower/KXKM routing for continuity." \
|
||||
--next-step "${memory_next_step}" \
|
||||
--resume-ref "${RESUME_REF}" \
|
||||
--trust-level "${TRUST_LEVEL}" \
|
||||
--routing-file "${MASCARADE_ROUTING_FILE}" \
|
||||
--artifact "${log_file}" \
|
||||
--artifact "${summary_file}" \
|
||||
--artifact "${DAILY_OPERATOR_SUMMARY_FILE}" \
|
||||
--artifact "${MASCARADE_WATCH_FILE}" \
|
||||
--json >"$KILL_LIFE_MEMORY_FILE" 2>>"$log_file"; then
|
||||
KILL_LIFE_MEMORY_STATUS="$(json_field "${KILL_LIFE_MEMORY_FILE}" status)"
|
||||
KILL_LIFE_MEMORY_MARKDOWN="$(json_field "${KILL_LIFE_MEMORY_FILE}" markdown_file)"
|
||||
else
|
||||
KILL_LIFE_MEMORY_STATUS="failed"
|
||||
fi
|
||||
if [[ -n "${KILL_LIFE_MEMORY_FILE}" ]]; then
|
||||
artifacts+=("${KILL_LIFE_MEMORY_FILE}")
|
||||
fi
|
||||
if [[ "${KILL_LIFE_MEMORY_STATUS}" == "degraded" || "${KILL_LIFE_MEMORY_STATUS}" == "failed" || "${KILL_LIFE_MEMORY_STATUS}" == "blocked" ]]; then
|
||||
degraded_reasons+=("kill-life-memory-${KILL_LIFE_MEMORY_STATUS}")
|
||||
next_steps+=("bash tools/cockpit/write_kill_life_memory_entry.sh --component run_alignment_daily --json")
|
||||
if [[ "${result}" == "ok" ]]; then
|
||||
result="degraded"
|
||||
fi
|
||||
fi
|
||||
|
||||
PRODUCT_CONTRACT_HANDOFF_FILE="$LOG_DIR/product_contract_handoff_${timestamp}.json"
|
||||
if bash "$ROOT_DIR/tools/cockpit/render_product_contract_handoff.sh" --no-refresh --json >"$PRODUCT_CONTRACT_HANDOFF_FILE" 2>>"$log_file"; then
|
||||
PRODUCT_CONTRACT_HANDOFF_STATUS="$(json_field "${PRODUCT_CONTRACT_HANDOFF_FILE}" status)"
|
||||
PRODUCT_CONTRACT_HANDOFF_MARKDOWN="$(json_field "${PRODUCT_CONTRACT_HANDOFF_FILE}" markdown_file)"
|
||||
else
|
||||
PRODUCT_CONTRACT_HANDOFF_STATUS="failed"
|
||||
fi
|
||||
if [[ -n "${PRODUCT_CONTRACT_HANDOFF_FILE}" ]]; then
|
||||
artifacts+=("${PRODUCT_CONTRACT_HANDOFF_FILE}")
|
||||
fi
|
||||
if [[ "${PRODUCT_CONTRACT_HANDOFF_STATUS}" == "degraded" || "${PRODUCT_CONTRACT_HANDOFF_STATUS}" == "failed" || "${PRODUCT_CONTRACT_HANDOFF_STATUS}" == "blocked" ]]; then
|
||||
degraded_reasons+=("product-contract-handoff-${PRODUCT_CONTRACT_HANDOFF_STATUS}")
|
||||
next_steps+=("bash tools/cockpit/render_product_contract_handoff.sh --json")
|
||||
if [[ "${result}" == "ok" ]]; then
|
||||
result="degraded"
|
||||
fi
|
||||
fi
|
||||
|
||||
contract_status="$(json_contract_map_status "${result}")"
|
||||
{
|
||||
echo "[summary] final_result=$result"
|
||||
echo "[summary] final_contract_status=$contract_status"
|
||||
echo "[summary] mascarade_routing_status=$MASCARADE_ROUTING_STATUS"
|
||||
echo "[summary] mascarade_routing_file=$MASCARADE_ROUTING_FILE"
|
||||
echo "[summary] kill_life_memory_status=$KILL_LIFE_MEMORY_STATUS"
|
||||
echo "[summary] kill_life_memory_file=$KILL_LIFE_MEMORY_FILE"
|
||||
echo "[summary] kill_life_memory_markdown=$KILL_LIFE_MEMORY_MARKDOWN"
|
||||
echo "[summary] product_contract_handoff_status=$PRODUCT_CONTRACT_HANDOFF_STATUS"
|
||||
echo "[summary] product_contract_handoff_file=$PRODUCT_CONTRACT_HANDOFF_FILE"
|
||||
echo "[summary] product_contract_handoff_markdown=$PRODUCT_CONTRACT_HANDOFF_MARKDOWN"
|
||||
echo "[summary] trust_level=$TRUST_LEVEL"
|
||||
echo "[summary] resume_ref=$RESUME_REF"
|
||||
} | tee -a "$log_file"
|
||||
|
||||
cp "$log_file" "$summary_file"
|
||||
|
||||
if $json_output; then
|
||||
decision_json="$(json_decision_object "run-alignment-daily" "Daily cockpit alignment is persisted in kill_life with explicit Tower/KXKM routing for continuity.")"
|
||||
routing_json="$(json_inline_object "${MASCARADE_ROUTING_FILE}")"
|
||||
memory_entry_json="$(json_inline_object "${KILL_LIFE_MEMORY_FILE}" entry)"
|
||||
printf '{"contract_version":"cockpit-v1","component":"run_alignment_daily","action":"summary","status":"%s","contract_status":"%s","generated_at":"%s","registry_file":"%s","result":"%s","mesh_status":"%s","mesh_load_profile":"%s","log_file":"%s","latest_log":"%s","log_dir":"%s","artifacts":%s,"degraded_reasons":%s,"next_steps":%s' \
|
||||
"$contract_status" "$contract_status" "$start_ts" "$REGISTRY_FILE" "$result" "$mesh_status" "$MESH_LOAD_PROFILE" "$log_file" "$summary_file" "$LOG_DIR" "$(json_contract_array_from_args "${artifacts[@]}")" "$(json_contract_array_from_args "${degraded_reasons[@]}")" "$(json_contract_array_from_args "${next_steps[@]}")"
|
||||
printf ',"owner":"%s","trust_level":"%s","resume_ref":"%s","routing_status":"%s","routing_artifact":"%s","memory_entry_status":"%s","memory_entry_artifact":"%s","decision":%s,"routing":%s,"memory_entry":%s' \
|
||||
"SyncOps" "$TRUST_LEVEL" "$RESUME_REF" "$MASCARADE_ROUTING_STATUS" "$MASCARADE_ROUTING_FILE" "$KILL_LIFE_MEMORY_STATUS" "$KILL_LIFE_MEMORY_FILE" "$decision_json" "$routing_json" "$memory_entry_json"
|
||||
printf ',"registry_summary_file":"%s","registry_summary_status":"%s","registry_target_count":"%s","registry_default_profile":"%s"' \
|
||||
"$REGISTRY_SUMMARY_FILE" "$REGISTRY_SUMMARY_STATUS" "$REGISTRY_TARGET_COUNT" "$REGISTRY_DEFAULT_PROFILE"
|
||||
printf ',"mascarade_health_status":"%s","mascarade_runtime_status":"%s","mascarade_provider":"%s","mascarade_model":"%s"' \
|
||||
"$MASCARADE_HEALTH_STATUS" "$MASCARADE_RUNTIME_STATUS" "$MASCARADE_PROVIDER" "$MASCARADE_MODEL"
|
||||
if [[ -n "$MASCARADE_HEALTH_FILE" ]]; then
|
||||
printf ',"mascarade_health_artifact":"%s"' "$MASCARADE_HEALTH_FILE"
|
||||
fi
|
||||
if [[ -n "$MASCARADE_LOGS_FILE" ]]; then
|
||||
printf ',"mascarade_logs_status":"%s","mascarade_logs_artifact":"%s"' "$MASCARADE_LOGS_STATUS" "$MASCARADE_LOGS_FILE"
|
||||
fi
|
||||
if [[ -n "$MASCARADE_BRIEF_FILE" ]]; then
|
||||
printf ',"mascarade_brief_status":"%s","mascarade_brief_artifact":"%s","mascarade_brief_markdown":"%s"' "$MASCARADE_BRIEF_STATUS" "$MASCARADE_BRIEF_FILE" "$MASCARADE_BRIEF_MARKDOWN"
|
||||
fi
|
||||
if [[ -n "$MASCARADE_REGISTRY_FILE" ]]; then
|
||||
printf ',"mascarade_registry_status":"%s","mascarade_registry_artifact":"%s","mascarade_registry_markdown":"%s"' "$MASCARADE_REGISTRY_STATUS" "$MASCARADE_REGISTRY_FILE" "$MASCARADE_REGISTRY_MARKDOWN"
|
||||
fi
|
||||
if [[ -n "$MASCARADE_QUEUE_FILE" ]]; then
|
||||
printf ',"mascarade_queue_status":"%s","mascarade_queue_artifact":"%s","mascarade_queue_markdown":"%s"' "$MASCARADE_QUEUE_STATUS" "$MASCARADE_QUEUE_FILE" "$MASCARADE_QUEUE_MARKDOWN"
|
||||
fi
|
||||
if [[ -n "$MASCARADE_WATCH_FILE" ]]; then
|
||||
printf ',"mascarade_watch_status":"%s","mascarade_watch_artifact":"%s","mascarade_watch_markdown":"%s"' "$MASCARADE_WATCH_STATUS" "$MASCARADE_WATCH_FILE" "$MASCARADE_WATCH_MARKDOWN"
|
||||
fi
|
||||
if [[ -n "$MASCARADE_WATCH_HISTORY_FILE" ]]; then
|
||||
printf ',"mascarade_watch_history_status":"%s","mascarade_watch_history_artifact":"%s","mascarade_watch_history_markdown":"%s"' "$MASCARADE_WATCH_HISTORY_STATUS" "$MASCARADE_WATCH_HISTORY_FILE" "$MASCARADE_WATCH_HISTORY_MARKDOWN"
|
||||
fi
|
||||
if [[ -n "$DAILY_OPERATOR_SUMMARY_FILE" ]]; then
|
||||
printf ',"daily_operator_summary_status":"%s","daily_operator_summary_artifact":"%s","daily_operator_summary_markdown":"%s"' "$DAILY_OPERATOR_SUMMARY_STATUS" "$DAILY_OPERATOR_SUMMARY_FILE" "$DAILY_OPERATOR_SUMMARY_MARKDOWN"
|
||||
fi
|
||||
if [[ -n "$KILL_LIFE_MEMORY_MARKDOWN" ]]; then
|
||||
printf ',"memory_entry_markdown":"%s"' "$KILL_LIFE_MEMORY_MARKDOWN"
|
||||
fi
|
||||
if [[ -n "$PRODUCT_CONTRACT_HANDOFF_FILE" ]]; then
|
||||
printf ',"product_contract_handoff_status":"%s","product_contract_handoff_artifact":"%s","product_contract_handoff_markdown":"%s"' "$PRODUCT_CONTRACT_HANDOFF_STATUS" "$PRODUCT_CONTRACT_HANDOFF_FILE" "$PRODUCT_CONTRACT_HANDOFF_MARKDOWN"
|
||||
fi
|
||||
if [[ -n "$mesh_json_file" ]]; then
|
||||
printf ',"mesh_json_file":"%s"' "$mesh_json_file"
|
||||
fi
|
||||
if [[ -n "$LOG_OPS_SUMMARY_FILE" ]]; then
|
||||
printf ',"log_ops_summary_file":"%s","log_ops_summary_status":"%s","log_ops_stale":"%s"' \
|
||||
"$LOG_OPS_SUMMARY_FILE" "$LOG_OPS_SUMMARY_STATUS" "$LOG_OPS_STALE"
|
||||
fi
|
||||
if [[ -n "$LOG_OPS_PURGE_FILE" ]]; then
|
||||
printf ',"log_ops_purge_file":"%s","log_ops_purge_status":"%s","log_ops_purged":"%s"' \
|
||||
"$LOG_OPS_PURGE_FILE" "$LOG_OPS_PURGE_STATUS" "$LOG_OPS_PURGED"
|
||||
fi
|
||||
printf '}\n'
|
||||
fi
|
||||
|
||||
if [[ "$result" == ko ]]; then
|
||||
echo "[error] alignment run failed; consult log: $log_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
Executable
+850
@@ -0,0 +1,850 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source "${ROOT_DIR}/tools/cockpit/json_contract.sh"
|
||||
|
||||
COMPONENT="runtime-mcp-ia-gateway"
|
||||
ARTIFACT_DIR="${ROOT_DIR}/artifacts/cockpit/runtime_ai_gateway"
|
||||
mkdir -p "${ARTIFACT_DIR}"
|
||||
|
||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
RUN_LOG="${ARTIFACT_DIR}/runtime_ai_gateway-${STAMP}.log"
|
||||
|
||||
ACTION="status"
|
||||
JSON=0
|
||||
REFRESH=0
|
||||
LOAD_PROFILE="tower-first"
|
||||
INTELLIGENCE_REPORT="${ROOT_DIR}/artifacts/cockpit/intelligence_program/latest.json"
|
||||
MASCARADE_REPORT="${ROOT_DIR}/artifacts/ops/mascarade_runtime_health/latest.json"
|
||||
MESH_REPORT=""
|
||||
INTELLIGENCE_SCRIPT="${RUNTIME_GATEWAY_INTELLIGENCE_SCRIPT:-${ROOT_DIR}/tools/cockpit/intelligence_tui.sh}"
|
||||
MESH_SCRIPT="${RUNTIME_GATEWAY_MESH_SCRIPT:-${ROOT_DIR}/tools/cockpit/mesh_health_check.sh}"
|
||||
MASCARADE_SCRIPT="${RUNTIME_GATEWAY_MASCARADE_SCRIPT:-${ROOT_DIR}/tools/cockpit/mascarade_runtime_health.sh}"
|
||||
INTELLIGENCE_TIMEOUT_SEC="${RUNTIME_GATEWAY_INTELLIGENCE_TIMEOUT_SEC:-15}"
|
||||
MESH_TIMEOUT_SEC="${RUNTIME_GATEWAY_MESH_TIMEOUT_SEC:-15}"
|
||||
MASCARADE_TIMEOUT_SEC="${RUNTIME_GATEWAY_MASCARADE_TIMEOUT_SEC:-15}"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: bash tools/cockpit/runtime_ai_gateway.sh [options]
|
||||
|
||||
Options:
|
||||
--action <status|sources>
|
||||
--json
|
||||
--refresh
|
||||
--load-profile <tower-first|photon-safe>
|
||||
--intelligence-report <path>
|
||||
--mesh-report <path>
|
||||
--mascarade-report <path>
|
||||
-h, --help
|
||||
EOF
|
||||
}
|
||||
|
||||
log_line() {
|
||||
local level="$1"
|
||||
shift
|
||||
printf '[%s] [%s] %s\n' "$(date '+%H:%M:%S')" "${level}" "$*" | tee -a "${RUN_LOG}" >&2
|
||||
}
|
||||
|
||||
latest_mesh_report() {
|
||||
find "${ROOT_DIR}/artifacts/cockpit/health_reports" -type f -name 'mesh_health_check_mesh_*.json' 2>/dev/null | sort | tail -n 1
|
||||
}
|
||||
|
||||
run_refresh_probe() {
|
||||
local label="$1"
|
||||
local timeout_sec="$2"
|
||||
local output_file="$3"
|
||||
local load_profile="$4"
|
||||
shift 4
|
||||
|
||||
python3 - "${label}" "${timeout_sec}" "${output_file}" "${RUN_LOG}" "${load_profile}" "$@" <<'PY'
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
label = sys.argv[1]
|
||||
timeout_sec = float(sys.argv[2])
|
||||
output_file = Path(sys.argv[3])
|
||||
run_log = Path(sys.argv[4])
|
||||
load_profile = sys.argv[5]
|
||||
command = sys.argv[6:]
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now().astimezone().isoformat()
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
run_log.parent.mkdir(parents=True, exist_ok=True)
|
||||
with run_log.open("a", encoding="utf-8") as handle:
|
||||
handle.write(message.rstrip() + "\n")
|
||||
|
||||
|
||||
def fallback(reason: str, detail: str) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"generated_at": now_iso(),
|
||||
"status": "degraded",
|
||||
"contract_status": "degraded",
|
||||
"refresh_timeout": reason == "refresh-timeout",
|
||||
"degraded_reasons": [f"{label}-{reason}"],
|
||||
"next_steps": [detail],
|
||||
}
|
||||
if label == "intelligence":
|
||||
payload.update(
|
||||
{
|
||||
"contract_version": "summary-short/v1",
|
||||
"summary_short": "Intelligence refresh degraded during gateway refresh.",
|
||||
"open_task_count": 0,
|
||||
}
|
||||
)
|
||||
elif label == "mesh":
|
||||
payload.update(
|
||||
{
|
||||
"mesh_status": "degraded",
|
||||
"load_profile": load_profile,
|
||||
"host_order": [],
|
||||
}
|
||||
)
|
||||
elif label == "mascarade":
|
||||
payload.update(
|
||||
{
|
||||
"provider": "unknown",
|
||||
"model": "unknown",
|
||||
"host": "unknown",
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def write_payload(payload: dict[str, object]) -> None:
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout_sec, check=False)
|
||||
if result.stdout.strip():
|
||||
output_file.write_text(result.stdout, encoding="utf-8")
|
||||
else:
|
||||
write_payload(
|
||||
fallback(
|
||||
"refresh-empty",
|
||||
f"Refresh {label} produced no JSON payload; inspect the source command.",
|
||||
)
|
||||
)
|
||||
if result.stderr.strip():
|
||||
log(f"[refresh:{label}:stderr]\n{result.stderr.rstrip()}")
|
||||
log(f"[refresh:{label}] returncode={result.returncode} timeout={timeout_sec}s")
|
||||
if result.returncode != 0:
|
||||
write_payload(
|
||||
fallback(
|
||||
"refresh-failed",
|
||||
f"Refresh {label} failed; inspect the source command and its artifacts.",
|
||||
)
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
stdout = exc.stdout or ""
|
||||
stderr = exc.stderr or ""
|
||||
if stdout.strip():
|
||||
log(f"[refresh:{label}:partial-stdout]\n{stdout.rstrip()}")
|
||||
if stderr.strip():
|
||||
log(f"[refresh:{label}:partial-stderr]\n{stderr.rstrip()}")
|
||||
log(f"[refresh:{label}] timeout after {timeout_sec}s")
|
||||
write_payload(
|
||||
fallback(
|
||||
"refresh-timeout",
|
||||
f"Refresh {label} timed out after {timeout_sec}s; reduce probe cost or inspect the upstream source.",
|
||||
)
|
||||
)
|
||||
PY
|
||||
}
|
||||
|
||||
refresh_sources() {
|
||||
local intelligence_out="${ARTIFACT_DIR}/intelligence-${STAMP}.json"
|
||||
local mesh_out="${ARTIFACT_DIR}/mesh-${STAMP}.json"
|
||||
local mascarade_out="${ARTIFACT_DIR}/mascarade-${STAMP}.json"
|
||||
|
||||
log_line "INFO" "refreshing intelligence memory"
|
||||
run_refresh_probe intelligence "${INTELLIGENCE_TIMEOUT_SEC}" "${intelligence_out}" "${LOAD_PROFILE}" \
|
||||
bash "${INTELLIGENCE_SCRIPT}" --action memory --json
|
||||
INTELLIGENCE_REPORT="${intelligence_out}"
|
||||
|
||||
log_line "INFO" "refreshing mesh health"
|
||||
run_refresh_probe mesh "${MESH_TIMEOUT_SEC}" "${mesh_out}" "${LOAD_PROFILE}" \
|
||||
bash "${MESH_SCRIPT}" --json --load-profile "${LOAD_PROFILE}"
|
||||
MESH_REPORT="${mesh_out}"
|
||||
|
||||
log_line "INFO" "refreshing Mascarade runtime health"
|
||||
run_refresh_probe mascarade "${MASCARADE_TIMEOUT_SEC}" "${mascarade_out}" "${LOAD_PROFILE}" \
|
||||
bash "${MASCARADE_SCRIPT}" --json
|
||||
MASCARADE_REPORT="${mascarade_out}"
|
||||
}
|
||||
|
||||
emit_status_json() {
|
||||
python3 - "${ROOT_DIR}" "${INTELLIGENCE_REPORT}" "${MESH_REPORT}" "${MASCARADE_REPORT}" "${RUN_LOG}" <<'PY'
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
intelligence_path = Path(sys.argv[2]) if sys.argv[2] else None
|
||||
mesh_path = Path(sys.argv[3]) if sys.argv[3] else None
|
||||
mascarade_path = Path(sys.argv[4]) if sys.argv[4] else None
|
||||
run_log = Path(sys.argv[5])
|
||||
|
||||
|
||||
def read_json(path: Path | None) -> dict[str, object] | None:
|
||||
if path is None or not path.exists():
|
||||
return None
|
||||
text = path.read_text(encoding="utf-8")
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
decoder = json.JSONDecoder()
|
||||
lines = text.splitlines()
|
||||
for index, line in enumerate(lines):
|
||||
if line.lstrip().startswith("{"):
|
||||
try:
|
||||
candidate = "\n".join(lines[index:])
|
||||
payload, _ = decoder.raw_decode(candidate)
|
||||
if isinstance(payload, dict):
|
||||
return payload
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def relative_path(path: Path | None) -> str | None:
|
||||
if path is None:
|
||||
return None
|
||||
try:
|
||||
return str(path.resolve().relative_to(root))
|
||||
except Exception:
|
||||
return str(path)
|
||||
|
||||
|
||||
def normalize(value: object) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
if text in {"ok", "ready", "done", "success"}:
|
||||
return "ready"
|
||||
if text in {"degraded", "warn", "warning", "pending", "running", "unknown", "missing"}:
|
||||
return "degraded"
|
||||
if text in {"blocked", "error", "fail", "failed", "unreachable", "invalid"}:
|
||||
return "blocked"
|
||||
return "degraded"
|
||||
|
||||
|
||||
def collect_next_steps(payload: dict[str, object] | None) -> list[str]:
|
||||
if not payload:
|
||||
return []
|
||||
raw = payload.get("next_steps")
|
||||
if isinstance(raw, list):
|
||||
return [str(item) for item in raw if item]
|
||||
return []
|
||||
|
||||
|
||||
def compact(value: object, max_len: int = 220) -> str:
|
||||
text = " ".join(str(value or "").split())
|
||||
if not text:
|
||||
return "none"
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
return text[: max_len - 3].rstrip() + "..."
|
||||
|
||||
|
||||
def listify(value: object) -> list[str]:
|
||||
if isinstance(value, list):
|
||||
return [str(item) for item in value if item]
|
||||
return []
|
||||
|
||||
|
||||
def merge_unique(values: list[str], additions: list[str]) -> list[str]:
|
||||
for item in additions:
|
||||
if item and item not in values:
|
||||
values.append(item)
|
||||
return values
|
||||
|
||||
|
||||
def path_evidence(label: str, path: Path | None) -> list[str]:
|
||||
rel = relative_path(path)
|
||||
if rel:
|
||||
return [rel]
|
||||
return [f"missing:{label}"]
|
||||
|
||||
|
||||
def build_runtime_surface(payload: dict[str, object] | None, path: Path | None) -> tuple[dict[str, object], list[str]]:
|
||||
status = normalize((payload or {}).get("status") or (payload or {}).get("runtime_status") or (payload or {}).get("contract_status"))
|
||||
provider = (payload or {}).get("provider") or (payload or {}).get("mascarade_provider") or "unknown"
|
||||
model = (payload or {}).get("model") or (payload or {}).get("mascarade_model") or "unknown"
|
||||
host = (payload or {}).get("host") or "unknown"
|
||||
degraded = listify((payload or {}).get("degraded_reasons"))
|
||||
if status != "ready" and not degraded:
|
||||
degraded = [f"runtime-{status}"]
|
||||
next_steps = collect_next_steps(payload)
|
||||
if status != "ready" and not next_steps:
|
||||
next_steps = ["Refresh Mascarade runtime health and inspect provider/model availability."]
|
||||
surface = {
|
||||
"status": status,
|
||||
"summary_short": compact(
|
||||
f"Mascarade runtime {status}; provider={provider}; model={model}; host={host}.",
|
||||
220,
|
||||
),
|
||||
"evidence": path_evidence("runtime", path),
|
||||
"degraded_reasons": degraded,
|
||||
"upstreams": ["tools/cockpit/mascarade_runtime_health.sh"],
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"host": host,
|
||||
"path": relative_path(path),
|
||||
}
|
||||
return surface, next_steps
|
||||
|
||||
|
||||
def build_mcp_surface(payload: dict[str, object] | None, path: Path | None) -> tuple[dict[str, object], list[str]]:
|
||||
host_order_raw = (payload or {}).get("host_order") or (payload or {}).get("mesh_host_order") or []
|
||||
if isinstance(host_order_raw, str):
|
||||
try:
|
||||
host_order = json.loads(host_order_raw)
|
||||
except Exception:
|
||||
host_order = [item.strip() for item in host_order_raw.split(",") if item.strip()]
|
||||
else:
|
||||
host_order = [str(item) for item in host_order_raw if item]
|
||||
load_profile = (payload or {}).get("load_profile") or "unknown"
|
||||
registry_status_raw = (payload or {}).get("registry_status")
|
||||
registry_status = normalize(registry_status_raw) if registry_status_raw else "unknown"
|
||||
status = normalize((payload or {}).get("status") or (payload or {}).get("mesh_status") or registry_status_raw)
|
||||
degraded = listify((payload or {}).get("degraded_reasons"))
|
||||
if (payload or {}).get("mesh_status") and normalize((payload or {}).get("mesh_status")) != "ready":
|
||||
degraded = merge_unique(degraded, [f"mesh-{normalize((payload or {}).get('mesh_status'))}"])
|
||||
if status != "ready" and not degraded:
|
||||
degraded = [f"mcp-{status}"]
|
||||
next_steps = collect_next_steps(payload)
|
||||
if status != "ready" and not next_steps:
|
||||
next_steps = ["Refresh mesh health and inspect machine registry / host order."]
|
||||
surface = {
|
||||
"status": status,
|
||||
"summary_short": compact(
|
||||
f"Mesh/MCP {status}; load_profile={load_profile}; host_order={len(host_order)}; registry={registry_status}.",
|
||||
220,
|
||||
),
|
||||
"evidence": path_evidence("mcp", path),
|
||||
"degraded_reasons": degraded,
|
||||
"upstreams": ["tools/cockpit/mesh_health_check.sh", "specs/contracts/machine_registry.mesh.json"],
|
||||
"load_profile": load_profile,
|
||||
"host_order": host_order,
|
||||
"path": relative_path(path),
|
||||
}
|
||||
return surface, next_steps
|
||||
|
||||
|
||||
def build_ia_surface(payload: dict[str, object] | None, path: Path | None) -> tuple[dict[str, object], list[str]]:
|
||||
status = normalize((payload or {}).get("status"))
|
||||
degraded = listify((payload or {}).get("degraded_reasons"))
|
||||
if status != "ready" and not degraded:
|
||||
degraded = [f"ia-{status}"]
|
||||
next_steps = collect_next_steps(payload)
|
||||
if status != "ready" and not next_steps:
|
||||
next_steps = ["Refresh intelligence memory and update the open governance tasks."]
|
||||
summary = (payload or {}).get("summary_short")
|
||||
if not summary:
|
||||
summary = compact(
|
||||
f"Intelligence lane {status}; open_tasks={(payload or {}).get('open_task_count', 0)}; "
|
||||
f"next={(next_steps[0] if next_steps else 'none')}.",
|
||||
220,
|
||||
)
|
||||
evidence = listify((payload or {}).get("evidence"))
|
||||
if evidence:
|
||||
evidence = [str(item) for item in evidence]
|
||||
else:
|
||||
evidence = path_evidence("ia", path)
|
||||
surface = {
|
||||
"status": status,
|
||||
"summary_short": compact(summary, 220),
|
||||
"evidence": evidence,
|
||||
"degraded_reasons": degraded,
|
||||
"upstreams": ["tools/cockpit/intelligence_tui.sh"],
|
||||
"open_task_count": (payload or {}).get("open_task_count", 0),
|
||||
"path": relative_path(path),
|
||||
}
|
||||
return surface, next_steps
|
||||
|
||||
|
||||
def build_firmware_cad_surface(root: Path) -> tuple[dict[str, object], dict[str, object], list[str]]:
|
||||
firmware_main = root / "firmware" / "src" / "main.cpp"
|
||||
firmware_platformio = root / "firmware" / "platformio.ini"
|
||||
firmware_voice = root / "firmware" / "src" / "voice_controller.cpp"
|
||||
firmware_build_dir = root / "firmware" / ".pio" / "build" / "esp32s3_arduino"
|
||||
firmware_bin = firmware_build_dir / "firmware.bin"
|
||||
firmware_elf = firmware_build_dir / "firmware.elf"
|
||||
firmware_build_result = root / "docs" / "evidence" / "esp" / "build.result.json"
|
||||
firmware_build_stderr = root / "docs" / "evidence" / "esp" / "build.stderr.txt"
|
||||
firmware_test_result = root / "docs" / "evidence" / "linux" / "test.result.json"
|
||||
firmware_test_stderr = root / "docs" / "evidence" / "linux" / "test.stderr.txt"
|
||||
cad_proof = root / "artifacts" / "yiacad_backend_proof" / "latest.json"
|
||||
cad_fusion = root / "artifacts" / "cad-fusion" / "yiacad-fusion-last-status.md"
|
||||
|
||||
firmware_exists = firmware_main.exists() and firmware_platformio.exists()
|
||||
firmware_build_ready = firmware_bin.exists() and firmware_elf.exists()
|
||||
firmware_build_payload = read_json(firmware_build_result)
|
||||
firmware_test_payload = read_json(firmware_test_result)
|
||||
firmware_build_rc = (firmware_build_payload or {}).get("returncode")
|
||||
firmware_test_rc = (firmware_test_payload or {}).get("returncode")
|
||||
has_failed_run = firmware_build_rc not in (None, 0) or firmware_test_rc not in (None, 0)
|
||||
if firmware_build_ready:
|
||||
firmware_status = "ready"
|
||||
elif firmware_exists and has_failed_run:
|
||||
firmware_status = "degraded"
|
||||
elif firmware_exists:
|
||||
firmware_status = "degraded"
|
||||
else:
|
||||
firmware_status = "blocked"
|
||||
|
||||
firmware_reasons: list[str] = []
|
||||
firmware_next: list[str] = []
|
||||
if not firmware_exists:
|
||||
firmware_reasons.append("firmware-root-missing")
|
||||
firmware_next.append("Restore the canonical firmware root files before publishing firmware status.")
|
||||
elif has_failed_run:
|
||||
if firmware_build_rc not in (None, 0):
|
||||
firmware_reasons.append("firmware-build-last-run-failed")
|
||||
if firmware_test_rc not in (None, 0):
|
||||
firmware_reasons.append("firmware-test-last-run-failed")
|
||||
firmware_next.append("Resolve the current PlatformIO dependency/network error, then rerun `python3 tools/build_firmware.py esp`.")
|
||||
firmware_next.append("Rerun `python3 tools/test_firmware.py linux` after the PlatformIO dependency issue is cleared.")
|
||||
elif not firmware_build_ready:
|
||||
firmware_reasons.append("firmware-build-evidence-missing")
|
||||
firmware_next.append("Run `python3 tools/build_firmware.py esp` to publish root firmware build evidence.")
|
||||
firmware_next.append("Run `python3 tools/test_firmware.py linux` when native test evidence is needed.")
|
||||
|
||||
cad_payload = read_json(cad_proof)
|
||||
cad_status_raw = (cad_payload or {}).get("status")
|
||||
if cad_status_raw in {"done", "ok", "ready"}:
|
||||
cad_status = "ready"
|
||||
elif cad_status_raw in {"degraded", "warn", "warning"}:
|
||||
cad_status = "degraded"
|
||||
elif cad_status_raw in {"blocked", "error", "fail", "failed"}:
|
||||
cad_status = "blocked"
|
||||
else:
|
||||
cad_status = "degraded"
|
||||
|
||||
cad_reasons = listify((cad_payload or {}).get("degraded_reasons"))
|
||||
cad_next = collect_next_steps(cad_payload)
|
||||
if not cad_payload:
|
||||
cad_reasons = merge_unique(cad_reasons, ["cad-proof-missing"])
|
||||
cad_next = merge_unique(cad_next, ["Run `bash tools/cockpit/yiacad_backend_proof.sh --action run --json` to publish CAD operator proof."])
|
||||
|
||||
combined_statuses = [firmware_status, cad_status]
|
||||
if any(item == "blocked" for item in combined_statuses):
|
||||
overall = "blocked"
|
||||
elif any(item == "degraded" for item in combined_statuses):
|
||||
overall = "degraded"
|
||||
else:
|
||||
overall = "ready"
|
||||
|
||||
evidence: list[str] = []
|
||||
for candidate in (
|
||||
firmware_main,
|
||||
firmware_platformio,
|
||||
firmware_bin,
|
||||
firmware_elf,
|
||||
firmware_build_result,
|
||||
firmware_build_stderr,
|
||||
firmware_test_result,
|
||||
firmware_test_stderr,
|
||||
firmware_voice,
|
||||
cad_proof,
|
||||
cad_fusion,
|
||||
):
|
||||
rel = relative_path(candidate) if candidate.exists() else None
|
||||
if rel and rel not in evidence:
|
||||
evidence.append(rel)
|
||||
|
||||
next_steps = firmware_next[:]
|
||||
for item in cad_next:
|
||||
if item not in next_steps:
|
||||
next_steps.append(item)
|
||||
|
||||
surface = {
|
||||
"status": overall,
|
||||
"summary_short": compact(
|
||||
f"firmware={firmware_status} main={'yes' if firmware_main.exists() else 'no'} build={'yes' if firmware_build_ready else 'no'} "
|
||||
f"build_rc={firmware_build_rc if firmware_build_rc is not None else 'n/a'} test_rc={firmware_test_rc if firmware_test_rc is not None else 'n/a'}; "
|
||||
f"cad={cad_status} proof={'yes' if cad_payload else 'no'}.",
|
||||
220,
|
||||
),
|
||||
"evidence": evidence or ["missing:firmware-cad"],
|
||||
"degraded_reasons": firmware_reasons + [item for item in cad_reasons if item not in firmware_reasons],
|
||||
"upstreams": [
|
||||
"firmware/platformio.ini",
|
||||
"firmware/src/main.cpp",
|
||||
"tools/build_firmware.py",
|
||||
"tools/test_firmware.py",
|
||||
"tools/cockpit/yiacad_backend_proof.sh",
|
||||
],
|
||||
"firmware_status": firmware_status,
|
||||
"cad_status": cad_status,
|
||||
"firmware_entrypoint": relative_path(firmware_main) if firmware_main.exists() else None,
|
||||
"firmware_build_dir": relative_path(firmware_build_dir) if firmware_build_dir.exists() else None,
|
||||
"firmware_build_result": relative_path(firmware_build_result) if firmware_build_result.exists() else None,
|
||||
"firmware_test_result": relative_path(firmware_test_result) if firmware_test_result.exists() else None,
|
||||
"cad_proof_path": relative_path(cad_proof) if cad_proof.exists() else None,
|
||||
}
|
||||
|
||||
summary_payload = {
|
||||
"contract_version": "summary-short/v1",
|
||||
"generated_at": datetime.now().astimezone().isoformat(),
|
||||
"component": "firmware-cad-bridge",
|
||||
"lot_id": "firmware-cad-bridge",
|
||||
"owner_repo": "Kill_LIFE",
|
||||
"owner_agent": "Arch-Mesh",
|
||||
"owner_subagent": "CAD-Bridge",
|
||||
"write_set": [
|
||||
"firmware/platformio.ini",
|
||||
"firmware/src/main.cpp",
|
||||
"firmware/src/voice_controller.cpp",
|
||||
"tools/build_firmware.py",
|
||||
"tools/test_firmware.py",
|
||||
"tools/cockpit/yiacad_backend_proof.sh",
|
||||
],
|
||||
"status": overall,
|
||||
"summary_short": compact(
|
||||
f"Firmware/CAD bridge {overall}; firmware={firmware_status}; cad={cad_status}; "
|
||||
f"next={(next_steps[0] if next_steps else 'none')}.",
|
||||
320,
|
||||
),
|
||||
"evidence": surface["evidence"],
|
||||
"degraded_reasons": surface["degraded_reasons"],
|
||||
"next_steps": next_steps[:5],
|
||||
}
|
||||
return surface, summary_payload, next_steps
|
||||
|
||||
|
||||
intelligence = read_json(intelligence_path)
|
||||
mesh = read_json(mesh_path)
|
||||
mascarade = read_json(mascarade_path)
|
||||
|
||||
runtime_surface, runtime_next = build_runtime_surface(mascarade, mascarade_path)
|
||||
mcp_surface, mcp_next = build_mcp_surface(mesh, mesh_path)
|
||||
ia_surface, ia_next = build_ia_surface(intelligence, intelligence_path)
|
||||
firmware_cad_surface, firmware_cad_summary, firmware_cad_next = build_firmware_cad_surface(root)
|
||||
|
||||
source_states = [runtime_surface["status"], mcp_surface["status"], ia_surface["status"]]
|
||||
if any(state == "blocked" for state in source_states):
|
||||
overall = "blocked"
|
||||
elif any(state == "degraded" for state in source_states):
|
||||
overall = "degraded"
|
||||
else:
|
||||
overall = "ready"
|
||||
|
||||
degraded_reasons: list[str] = []
|
||||
next_steps: list[str] = []
|
||||
evidence = [relative_path(run_log) or str(run_log)]
|
||||
|
||||
for label, path, surface, extra_next in (
|
||||
("runtime", mascarade_path, runtime_surface, runtime_next),
|
||||
("mcp", mesh_path, mcp_surface, mcp_next),
|
||||
("ia", intelligence_path, ia_surface, ia_next),
|
||||
):
|
||||
if path and path.exists():
|
||||
rel = relative_path(path)
|
||||
if rel and rel not in evidence:
|
||||
evidence.append(rel)
|
||||
else:
|
||||
degraded_reasons.append(f"{label}-missing")
|
||||
next_steps.append(f"Provide a {label} report or run the source command.")
|
||||
for reason in listify(surface.get("degraded_reasons")):
|
||||
if reason not in degraded_reasons:
|
||||
degraded_reasons.append(reason)
|
||||
for step in extra_next:
|
||||
if step not in next_steps:
|
||||
next_steps.append(step)
|
||||
|
||||
for reason in listify(firmware_cad_surface.get("degraded_reasons")):
|
||||
if reason not in degraded_reasons:
|
||||
degraded_reasons.append(reason)
|
||||
for step in firmware_cad_next:
|
||||
if step not in next_steps:
|
||||
next_steps.append(step)
|
||||
for item in listify(firmware_cad_surface.get("evidence")):
|
||||
if item not in evidence:
|
||||
evidence.append(item)
|
||||
|
||||
summary_short = compact(
|
||||
f"runtime={runtime_surface['status']} ({runtime_surface['summary_short']}) | "
|
||||
f"mcp={mcp_surface['status']} ({mcp_surface['summary_short']}) | "
|
||||
f"ia={ia_surface['status']} ({ia_surface['summary_short']}) | "
|
||||
f"firmware_cad={firmware_cad_surface['status']} ({firmware_cad_surface['summary_short']})",
|
||||
320,
|
||||
)
|
||||
|
||||
payload = {
|
||||
"contract_version": "runtime-mcp-ia-gateway/v1",
|
||||
"component": "runtime-mcp-ia-gateway",
|
||||
"action": "status",
|
||||
"status": overall,
|
||||
"generated_at": datetime.now().astimezone().isoformat(),
|
||||
"owner_repo": "Kill_LIFE",
|
||||
"owner_agent": "Runtime-Companion",
|
||||
"owner_subagent": "MCP-Health",
|
||||
"write_set": [
|
||||
"tools/cockpit/runtime_ai_gateway.sh",
|
||||
"tools/cockpit/intelligence_tui.sh",
|
||||
"tools/cockpit/mesh_health_check.sh",
|
||||
"tools/cockpit/mascarade_runtime_health.sh",
|
||||
"docs/AI_WORKFLOWS.md",
|
||||
],
|
||||
"summary_short": summary_short,
|
||||
"evidence": evidence,
|
||||
"degraded_reasons": degraded_reasons,
|
||||
"next_steps": next_steps[:5],
|
||||
"goal": "Publier une sante canonique runtime/MCP/IA exploitable par cockpit, docs et extensions.",
|
||||
"state": f"runtime={runtime_surface['status']} mcp={mcp_surface['status']} ia={ia_surface['status']}",
|
||||
"blockers": degraded_reasons,
|
||||
"next": next_steps[:3],
|
||||
"owner": "Runtime-Companion/MCP-Health",
|
||||
"contract_status": "ok" if overall == "ready" else ("error" if overall == "blocked" else "degraded"),
|
||||
"log_file": str(run_log),
|
||||
"artifacts": evidence,
|
||||
"surfaces": {
|
||||
"runtime": runtime_surface,
|
||||
"mcp": mcp_surface,
|
||||
"ia": ia_surface,
|
||||
"firmware_cad": firmware_cad_surface,
|
||||
},
|
||||
"sources": {
|
||||
"intelligence": {
|
||||
"path": relative_path(intelligence_path),
|
||||
"status": ia_surface["status"],
|
||||
"open_task_count": (intelligence or {}).get("open_task_count", 0),
|
||||
"refresh_timeout": bool((intelligence or {}).get("refresh_timeout")),
|
||||
},
|
||||
"mesh": {
|
||||
"path": relative_path(mesh_path),
|
||||
"status": mcp_surface["status"],
|
||||
"load_profile": (mesh or {}).get("load_profile", "unknown"),
|
||||
"host_order": mcp_surface.get("host_order", []),
|
||||
"refresh_timeout": bool((mesh or {}).get("refresh_timeout")),
|
||||
},
|
||||
"mascarade": {
|
||||
"path": relative_path(mascarade_path),
|
||||
"status": runtime_surface["status"],
|
||||
"provider": (mascarade or {}).get("provider") or (mascarade or {}).get("mascarade_provider"),
|
||||
"model": (mascarade or {}).get("model") or (mascarade or {}).get("mascarade_model"),
|
||||
"refresh_timeout": bool((mascarade or {}).get("refresh_timeout")),
|
||||
},
|
||||
"firmware_cad": {
|
||||
"status": firmware_cad_surface["status"],
|
||||
"firmware_status": firmware_cad_surface["firmware_status"],
|
||||
"cad_status": firmware_cad_surface["cad_status"],
|
||||
"cad_proof_path": firmware_cad_surface["cad_proof_path"],
|
||||
},
|
||||
},
|
||||
"summary_short_artifacts": {
|
||||
"firmware_cad": {
|
||||
"json": "artifacts/cockpit/runtime_ai_gateway/firmware_cad_summary_short_latest.json",
|
||||
"markdown": "artifacts/cockpit/runtime_ai_gateway/firmware_cad_summary_short_latest.md",
|
||||
}
|
||||
},
|
||||
"firmware_cad_summary_short": firmware_cad_summary,
|
||||
}
|
||||
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
PY
|
||||
}
|
||||
|
||||
render_status_text() {
|
||||
python3 - "${1}" <<'PY'
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
payload = json.loads(sys.argv[1])
|
||||
print("# Runtime / MCP / IA gateway")
|
||||
print()
|
||||
print(f"- status: {payload.get('status')}")
|
||||
for name, source in (payload.get("surfaces") or {}).items():
|
||||
print(f"- {name}: {source.get('status')} ({source.get('path') or 'missing'})")
|
||||
if payload.get("next_steps"):
|
||||
print("- next_steps:")
|
||||
for step in payload["next_steps"]:
|
||||
print(f" - {step}")
|
||||
PY
|
||||
}
|
||||
|
||||
persist_status_snapshot() {
|
||||
python3 - "${1}" "${ARTIFACT_DIR}" <<'PY'
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
payload = json.loads(sys.argv[1])
|
||||
artifact_dir = Path(sys.argv[2])
|
||||
latest_json = artifact_dir / "latest.json"
|
||||
latest_md = artifact_dir / "latest.md"
|
||||
firmware_cad_summary_json = artifact_dir / "firmware_cad_summary_short_latest.json"
|
||||
firmware_cad_summary_md = artifact_dir / "firmware_cad_summary_short_latest.md"
|
||||
|
||||
surface_lines = []
|
||||
for name, surface in (payload.get("surfaces") or {}).items():
|
||||
surface_lines.append(
|
||||
f"- {name}: status={surface.get('status')} summary={surface.get('summary_short')}"
|
||||
)
|
||||
|
||||
md_lines = [
|
||||
"# Runtime / MCP / IA gateway snapshot",
|
||||
"",
|
||||
f"- generated_at: {payload.get('generated_at')}",
|
||||
f"- status: {payload.get('status')}",
|
||||
f"- owner: {payload.get('owner')}",
|
||||
f"- summary_short: {payload.get('summary_short')}",
|
||||
"",
|
||||
"## Surfaces",
|
||||
*surface_lines,
|
||||
"",
|
||||
"## Next steps",
|
||||
]
|
||||
|
||||
for step in payload.get("next_steps") or []:
|
||||
md_lines.append(f"- {step}")
|
||||
|
||||
if not (payload.get("next_steps") or []):
|
||||
md_lines.append("- none")
|
||||
|
||||
latest_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
latest_md.write_text("\n".join(md_lines) + "\n", encoding="utf-8")
|
||||
|
||||
firmware_cad_summary = payload.get("firmware_cad_summary_short")
|
||||
if isinstance(firmware_cad_summary, dict):
|
||||
firmware_cad_summary_json.write_text(
|
||||
json.dumps(firmware_cad_summary, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
summary_md_lines = [
|
||||
"# Firmware / CAD bridge summary",
|
||||
"",
|
||||
f"- generated_at: {firmware_cad_summary.get('generated_at')}",
|
||||
f"- status: {firmware_cad_summary.get('status')}",
|
||||
f"- owner: {firmware_cad_summary.get('owner_agent')}/{firmware_cad_summary.get('owner_subagent')}",
|
||||
f"- summary_short: {firmware_cad_summary.get('summary_short')}",
|
||||
"",
|
||||
"## Evidence",
|
||||
]
|
||||
for item in firmware_cad_summary.get("evidence") or []:
|
||||
summary_md_lines.append(f"- {item}")
|
||||
if not (firmware_cad_summary.get("evidence") or []):
|
||||
summary_md_lines.append("- none")
|
||||
summary_md_lines.extend(["", "## Next steps"])
|
||||
for item in firmware_cad_summary.get("next_steps") or []:
|
||||
summary_md_lines.append(f"- {item}")
|
||||
if not (firmware_cad_summary.get("next_steps") or []):
|
||||
summary_md_lines.append("- none")
|
||||
firmware_cad_summary_md.write_text("\n".join(summary_md_lines) + "\n", encoding="utf-8")
|
||||
PY
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
JSON=1
|
||||
shift
|
||||
;;
|
||||
--refresh)
|
||||
REFRESH=1
|
||||
shift
|
||||
;;
|
||||
--load-profile)
|
||||
LOAD_PROFILE="${2:-tower-first}"
|
||||
shift 2
|
||||
;;
|
||||
--intelligence-report)
|
||||
INTELLIGENCE_REPORT="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--mesh-report)
|
||||
MESH_REPORT="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--mascarade-report)
|
||||
MASCARADE_REPORT="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "${MESH_REPORT}" ]; then
|
||||
MESH_REPORT="$(latest_mesh_report || true)"
|
||||
fi
|
||||
|
||||
if [ "${REFRESH}" -eq 1 ]; then
|
||||
refresh_sources
|
||||
fi
|
||||
|
||||
case "${ACTION}" in
|
||||
status)
|
||||
log_line "INFO" "action=status refresh=${REFRESH}"
|
||||
STATUS_JSON="$(emit_status_json)"
|
||||
persist_status_snapshot "${STATUS_JSON}"
|
||||
if [ "${JSON}" -eq 1 ]; then
|
||||
printf '%s\n' "${STATUS_JSON}"
|
||||
else
|
||||
render_status_text "${STATUS_JSON}"
|
||||
fi
|
||||
;;
|
||||
sources)
|
||||
log_line "INFO" "action=sources"
|
||||
if [ "${JSON}" -eq 1 ]; then
|
||||
printf '{\n'
|
||||
printf ' "contract_version": "cockpit-v1",\n'
|
||||
printf ' "component": "%s",\n' "${COMPONENT}"
|
||||
printf ' "action": "sources",\n'
|
||||
printf ' "status": "done",\n'
|
||||
printf ' "contract_status": "ok",\n'
|
||||
printf ' "log_file": "%s",\n' "${RUN_LOG}"
|
||||
printf ' "artifacts": %s,\n' "$(json_contract_array_from_args "${RUN_LOG}" "${INTELLIGENCE_REPORT}" "${MESH_REPORT}" "${MASCARADE_REPORT}")"
|
||||
printf ' "degraded_reasons": [],\n'
|
||||
printf ' "next_steps": [],\n'
|
||||
printf ' "intelligence_report": "%s",\n' "${INTELLIGENCE_REPORT}"
|
||||
if [ -n "${MESH_REPORT}" ]; then
|
||||
printf ' "mesh_report": "%s",\n' "${MESH_REPORT}"
|
||||
else
|
||||
printf ' "mesh_report": null,\n'
|
||||
fi
|
||||
printf ' "mascarade_report": "%s"\n' "${MASCARADE_REPORT}"
|
||||
printf '}\n'
|
||||
else
|
||||
printf '# Runtime / MCP / IA sources\n\n'
|
||||
printf -- '- intelligence: %s\n' "${INTELLIGENCE_REPORT}"
|
||||
printf -- '- mesh: %s\n' "${MESH_REPORT:-missing}"
|
||||
printf -- '- mascarade: %s\n' "${MASCARADE_REPORT}"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
printf 'Unsupported action: %s\n' "${ACTION}" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,370 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# sentinelle_cron.sh — Cron daily health-check via Sentinelle agent
|
||||
# Contrat: cockpit-v1
|
||||
# Lot: 23 — T-MA-022
|
||||
# Date: 2026-03-21
|
||||
#
|
||||
# Usage: Ajouter au crontab:
|
||||
# 0 6 * * * /path/to/sentinelle_cron.sh >> /var/log/sentinelle-health.log 2>&1
|
||||
#
|
||||
# Ou via n8n: HTTP Request node → POST /webhook/sentinelle-health
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
# shellcheck source=/dev/null
|
||||
source "${ROOT_DIR}/tools/cockpit/load_mistral_governance_env.sh"
|
||||
LOG_DIR="${LOG_DIR:-/var/log/mascarade}"
|
||||
REPORT_FILE="${LOG_DIR}/sentinelle-health-$(date +%Y%m%d).json"
|
||||
MISTRAL_API_KEY="${MISTRAL_GOVERNANCE_API_KEY:-${MISTRAL_API_KEY:-}}"
|
||||
SENTINELLE_AGENT_ID="${MISTRAL_AGENT_SENTINELLE_ID:-ag_019d124c302375a8bf06f9ff8a99fb5f}"
|
||||
MISTRAL_BASE="https://api.mistral.ai/v1"
|
||||
API_MODE="${MISTRAL_AGENTS_API_MODE:-beta}"
|
||||
SENTINELLE_ANALYSIS_API_MODE="not-requested"
|
||||
|
||||
# Services à checker
|
||||
MASCARADE_URL="${MASCARADE_URL:-https://mascarade.saillant.cc}"
|
||||
LANGFUSE_URL="${LANGFUSE_URL:-https://langfuse.saillant.cc}"
|
||||
GRAFANA_URL="${GRAFANA_URL:-https://grafana.saillant.cc}"
|
||||
|
||||
# Alerting
|
||||
ALERT_WEBHOOK="${ALERT_WEBHOOK:-}" # n8n ou Slack webhook
|
||||
ALERT_EMAIL="${ALERT_EMAIL:-c.saillant@gmail.com}"
|
||||
|
||||
# --- Fonctions ---
|
||||
|
||||
mkdir -p "$LOG_DIR" 2>/dev/null || true
|
||||
|
||||
timestamp() {
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ
|
||||
}
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
||||
}
|
||||
|
||||
check_url() {
|
||||
local name="$1" url="$2"
|
||||
local http_code
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -L "$url" 2>/dev/null || echo "000")
|
||||
echo "{\"service\":\"$name\",\"url\":\"$url\",\"http_code\":$http_code,\"healthy\":$([ "$http_code" -ge 200 ] && [ "$http_code" -lt 500 ] && echo "true" || echo "false")}"
|
||||
}
|
||||
|
||||
build_beta_payload() {
|
||||
local agent_id="$1"
|
||||
local message="$2"
|
||||
python3 - "$agent_id" "$message" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
agent_id = sys.argv[1]
|
||||
message = sys.argv[2]
|
||||
print(json.dumps({
|
||||
"agent_id": agent_id,
|
||||
"inputs": [{"role": "user", "content": message}],
|
||||
}))
|
||||
PY
|
||||
}
|
||||
|
||||
build_deprecated_payload() {
|
||||
local message="$1"
|
||||
python3 - "$message" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
message = sys.argv[1]
|
||||
print(json.dumps({
|
||||
"messages": [{"role": "user", "content": message}],
|
||||
"max_tokens": 500,
|
||||
}))
|
||||
PY
|
||||
}
|
||||
|
||||
call_mistral_api() {
|
||||
local endpoint="$1"
|
||||
local payload="$2"
|
||||
local response
|
||||
response=$(curl -s -w "\n%{http_code}" \
|
||||
-H "Authorization: Bearer $MISTRAL_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" \
|
||||
--max-time 60 \
|
||||
"${MISTRAL_BASE}/${endpoint}" 2>/dev/null) || true
|
||||
|
||||
local http_code body
|
||||
http_code=$(printf '%s\n' "$response" | tail -1)
|
||||
body=$(printf '%s\n' "$response" | sed '$d')
|
||||
|
||||
if [ "$http_code" = "200" ]; then
|
||||
printf '%s\n' "$body"
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf 'HTTP_ERROR:%s:%s\n' "$http_code" "$body"
|
||||
return 1
|
||||
}
|
||||
|
||||
extract_agent_content() {
|
||||
local response="$1"
|
||||
python3 - "$response" <<'PY' 2>/dev/null || echo ""
|
||||
import json
|
||||
import sys
|
||||
|
||||
raw = sys.argv[1]
|
||||
|
||||
def render_content(content):
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
chunks = []
|
||||
for item in content:
|
||||
if isinstance(item, str):
|
||||
if item:
|
||||
chunks.append(item)
|
||||
continue
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
text = item.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
chunks.append(text)
|
||||
return "\n".join(chunks)
|
||||
return ""
|
||||
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
outputs = data.get("outputs", [])
|
||||
for out in outputs:
|
||||
if out.get("role") == "assistant":
|
||||
print(render_content(out.get("content", "")))
|
||||
raise SystemExit(0)
|
||||
choices = data.get("choices", [])
|
||||
if choices:
|
||||
print(render_content(choices[0].get("message", {}).get("content", "")))
|
||||
else:
|
||||
print(data.get("error", "no response"))
|
||||
except Exception:
|
||||
print("")
|
||||
PY
|
||||
}
|
||||
|
||||
# --- Health Checks ---
|
||||
|
||||
run_health_checks() {
|
||||
log "Starting daily health check..."
|
||||
|
||||
local checks=()
|
||||
|
||||
# Core services
|
||||
checks+=("$(check_url "mascarade" "$MASCARADE_URL/health")")
|
||||
checks+=("$(check_url "langfuse" "$LANGFUSE_URL")")
|
||||
checks+=("$(check_url "grafana" "$GRAFANA_URL")")
|
||||
checks+=("$(check_url "authentik" "https://auth.saillant.cc")")
|
||||
checks+=("$(check_url "outline" "https://wiki.saillant.cc")")
|
||||
checks+=("$(check_url "n8n" "https://n8n.saillant.cc")")
|
||||
checks+=("$(check_url "gitea" "https://git.saillant.cc")")
|
||||
checks+=("$(check_url "uptime-kuma" "https://uptime.saillant.cc")")
|
||||
checks+=("$(check_url "portainer" "https://portainer.saillant.cc")")
|
||||
|
||||
# Count healthy
|
||||
local total=${#checks[@]}
|
||||
local healthy=0
|
||||
for c in "${checks[@]}"; do
|
||||
if echo "$c" | grep -q '"healthy":true'; then
|
||||
((healthy++))
|
||||
fi
|
||||
done
|
||||
|
||||
local status="healthy"
|
||||
[ "$healthy" -lt "$total" ] && status="degraded"
|
||||
[ "$healthy" -lt $((total / 2)) ] && status="critical"
|
||||
|
||||
# Build JSON array
|
||||
local checks_json=""
|
||||
for i in "${!checks[@]}"; do
|
||||
[ "$i" -gt 0 ] && checks_json="${checks_json},"
|
||||
checks_json="${checks_json}${checks[$i]}"
|
||||
done
|
||||
|
||||
# Generate report
|
||||
cat <<EOF > "$REPORT_FILE"
|
||||
{
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "sentinelle-daily-health",
|
||||
"timestamp": "$(timestamp)",
|
||||
"status": "$status",
|
||||
"services_healthy": $healthy,
|
||||
"services_total": $total,
|
||||
"uptime_pct": $(( healthy * 100 / total )),
|
||||
"checks": [$checks_json]
|
||||
}
|
||||
EOF
|
||||
|
||||
log "Health check complete: $healthy/$total healthy ($status)"
|
||||
echo "$status"
|
||||
}
|
||||
|
||||
# --- Sentinelle Agent Analysis ---
|
||||
|
||||
ask_sentinelle() {
|
||||
local report="$1"
|
||||
|
||||
if [ -z "$MISTRAL_API_KEY" ]; then
|
||||
log "MISTRAL_API_KEY not set — skipping Sentinelle analysis"
|
||||
return
|
||||
fi
|
||||
|
||||
log "Sending report to Sentinelle agent for analysis..."
|
||||
|
||||
local report_content
|
||||
report_content=$(cat "$report")
|
||||
local message
|
||||
message="Daily health check report for saillant.cc ecosystem.
|
||||
Analyze this report and provide:
|
||||
1. A brief status summary (1-2 lines)
|
||||
2. Any issues that need immediate attention
|
||||
3. Recommendations for the next 24h
|
||||
4. Risk level: LOW/MEDIUM/HIGH/CRITICAL
|
||||
|
||||
Report:
|
||||
${report_content}"
|
||||
|
||||
local beta_payload
|
||||
local deprecated_payload
|
||||
local response=""
|
||||
local analysis=""
|
||||
local api_mode="failed"
|
||||
|
||||
beta_payload="$(build_beta_payload "$SENTINELLE_AGENT_ID" "$message")"
|
||||
deprecated_payload="$(build_deprecated_payload "$message")"
|
||||
|
||||
if [ "$API_MODE" = "beta" ]; then
|
||||
if response=$(call_mistral_api "conversations" "$beta_payload" 2>/dev/null); then
|
||||
api_mode="beta"
|
||||
else
|
||||
log "Beta API failed for Sentinelle — fallback deprecated"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$api_mode" = "failed" ]; then
|
||||
if response=$(call_mistral_api "agents/${SENTINELLE_AGENT_ID}/completions" "$deprecated_payload" 2>/dev/null); then
|
||||
api_mode="deprecated"
|
||||
else
|
||||
response='{"error":"timeout"}'
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$response" == HTTP_ERROR:* ]]; then
|
||||
analysis="$response"
|
||||
else
|
||||
analysis=$(extract_agent_content "$response")
|
||||
fi
|
||||
|
||||
if [ -z "$analysis" ]; then
|
||||
analysis="no analysis"
|
||||
fi
|
||||
|
||||
SENTINELLE_ANALYSIS_API_MODE="$api_mode"
|
||||
|
||||
# Append analysis to report
|
||||
python3 - "$report" "$analysis" "$api_mode" <<'PY' 2>/dev/null || true
|
||||
import json
|
||||
import sys
|
||||
|
||||
report_path = sys.argv[1]
|
||||
analysis = sys.argv[2]
|
||||
api_mode = sys.argv[3]
|
||||
|
||||
with open(report_path, "r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
|
||||
data["sentinelle_analysis"] = analysis
|
||||
data["sentinelle_api_mode"] = api_mode
|
||||
|
||||
with open(report_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, indent=2)
|
||||
PY
|
||||
|
||||
log "Sentinelle analysis appended to report"
|
||||
}
|
||||
|
||||
# --- Alerting ---
|
||||
|
||||
send_alert() {
|
||||
local status="$1" report="$2"
|
||||
|
||||
# Only alert on degraded or critical
|
||||
if [ "$status" = "healthy" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
log "Sending alert for status: $status"
|
||||
|
||||
# Webhook alert (n8n, Slack, etc.)
|
||||
if [ -n "$ALERT_WEBHOOK" ]; then
|
||||
curl -s -X POST "$ALERT_WEBHOOK" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @"$report" \
|
||||
--max-time 10 2>/dev/null || log "Webhook alert failed"
|
||||
fi
|
||||
|
||||
# Console output for cron email
|
||||
echo ""
|
||||
echo "=== SENTINELLE ALERT: $status ==="
|
||||
echo "Date: $(date)"
|
||||
echo "Report: $report"
|
||||
cat "$report"
|
||||
echo "================================="
|
||||
}
|
||||
|
||||
# --- Main ---
|
||||
|
||||
main() {
|
||||
local status
|
||||
status=$(run_health_checks)
|
||||
|
||||
ask_sentinelle "$REPORT_FILE"
|
||||
|
||||
send_alert "$status" "$REPORT_FILE"
|
||||
|
||||
# Cleanup old reports (keep 30 days)
|
||||
find "$LOG_DIR" -name "sentinelle-health-*.json" -mtime +30 -delete 2>/dev/null || true
|
||||
|
||||
log "Daily health check finished (status: $status, report: $REPORT_FILE)"
|
||||
|
||||
# Output JSON for cockpit-v1
|
||||
cat <<EOF
|
||||
{
|
||||
"contract_version": "cockpit-v1",
|
||||
"component": "sentinelle-cron",
|
||||
"action": "daily-health",
|
||||
"status": "$status",
|
||||
"timestamp": "$(timestamp)",
|
||||
"report_file": "$REPORT_FILE",
|
||||
"analysis_api_mode": "$SENTINELLE_ANALYSIS_API_MODE"
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--help]"
|
||||
echo ""
|
||||
echo "Daily health check via Sentinelle agent."
|
||||
echo ""
|
||||
echo "Env vars:"
|
||||
echo " MISTRAL_API_KEY Mistral API key"
|
||||
echo " MISTRAL_AGENT_SENTINELLE_ID Sentinelle agent ID"
|
||||
echo " LOG_DIR Log directory (default: /var/log/mascarade)"
|
||||
echo " ALERT_WEBHOOK Webhook URL for alerts"
|
||||
echo " ALERT_EMAIL Email for alerts"
|
||||
echo ""
|
||||
echo "Crontab example:"
|
||||
echo " 0 6 * * * $0 >> /var/log/sentinelle-cron.log 2>&1"
|
||||
;;
|
||||
*)
|
||||
main
|
||||
;;
|
||||
esac
|
||||
Executable
+203
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
|
||||
source "${PROJECT_DIR}/tools/cockpit/json_contract.sh"
|
||||
LOG_DIR="${PROJECT_DIR}/artifacts/cockpit"
|
||||
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
|
||||
REGISTRY_FILE="${PROJECT_DIR}/specs/contracts/machine_registry.mesh.json"
|
||||
|
||||
JSON_OUTPUT=0
|
||||
VERBOSE=0
|
||||
LOG_FILE="${LOG_DIR}/ssh_healthcheck_${TIMESTAMP}.log"
|
||||
TARGETS=()
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: bash tools/cockpit/ssh_healthcheck.sh [options]
|
||||
|
||||
Options:
|
||||
--json Emit a JSON summary to stdout.
|
||||
--no-log Disable log file creation.
|
||||
--verbose Show command-level details.
|
||||
-h, --help Show this help message.
|
||||
|
||||
This script checks SSH reachability on the project-operator machines.
|
||||
Each JSON result now includes `priority`, `port` and `role` for audit traceability.
|
||||
USAGE
|
||||
}
|
||||
|
||||
log_line() {
|
||||
local level="$1"
|
||||
shift
|
||||
local msg="${level} $(date '+%Y-%m-%d %H:%M:%S %z') ${*}"
|
||||
printf '%s\n' "${msg}"
|
||||
if [[ -n "${LOG_FILE:-}" ]]; then
|
||||
printf '%s\n' "${msg}" >> "${LOG_FILE}"
|
||||
fi
|
||||
}
|
||||
|
||||
load_targets_from_registry() {
|
||||
python3 - "${REGISTRY_FILE}" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
registry_path = Path(sys.argv[1])
|
||||
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
||||
targets = sorted(data.get("targets", []), key=lambda item: item.get("priority", 999))
|
||||
|
||||
for item in targets:
|
||||
target = item.get("target", "")
|
||||
port = int(item.get("port", 0) or 0)
|
||||
if target == "local" or port <= 0:
|
||||
continue
|
||||
priority = item.get("priority", 999)
|
||||
role = item.get("role", "unknown")
|
||||
print(f"{target}|{port}|{priority}|{role}")
|
||||
PY
|
||||
}
|
||||
|
||||
run_target() {
|
||||
local target_host
|
||||
local port
|
||||
local priority
|
||||
local role
|
||||
local entry="$1"
|
||||
local ok=0
|
||||
|
||||
IFS='|' read -r target_host port priority role <<< "${entry}"
|
||||
|
||||
local ssh_cmd=(
|
||||
ssh
|
||||
-o BatchMode=yes
|
||||
-o ConnectTimeout=5
|
||||
-o StrictHostKeyChecking=accept-new
|
||||
-p "${port}"
|
||||
"${target_host}"
|
||||
'echo OK'
|
||||
)
|
||||
|
||||
if [[ "${VERBOSE}" -eq 1 ]]; then
|
||||
if "${ssh_cmd[@]}" >/dev/null; then
|
||||
ok=1
|
||||
fi
|
||||
else
|
||||
if "${ssh_cmd[@]}" >/dev/null 2>&1; then
|
||||
ok=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${ok}" -eq 1 ]]; then
|
||||
log_line "INFO" "[OK] P${priority} ${role} -> ${target_host} (port ${port})"
|
||||
else
|
||||
log_line "WARN" "[KO] P${priority} ${role} -> ${target_host} (port ${port})"
|
||||
fi
|
||||
|
||||
RESULTS+=("${target_host}|${port}|${priority}|${role}|${ok}")
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--json)
|
||||
JSON_OUTPUT=1
|
||||
;;
|
||||
--no-log)
|
||||
LOG_FILE=""
|
||||
;;
|
||||
--verbose)
|
||||
VERBOSE=1
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [[ -n "${LOG_FILE}" ]]; then
|
||||
mkdir -p "${LOG_DIR}"
|
||||
touch "${LOG_DIR}/.keep"
|
||||
log_line "INFO" "Log file: ${LOG_FILE}"
|
||||
fi
|
||||
|
||||
log_line "INFO" "Starting Kill_LIFE SSH health-check"
|
||||
mapfile -t TARGETS < <(load_targets_from_registry)
|
||||
if [[ "${#TARGETS[@]}" -eq 0 ]]; then
|
||||
log_line "WARN" "No SSH targets resolved from ${REGISTRY_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RESULTS=()
|
||||
for entry in "${TARGETS[@]}"; do
|
||||
run_target "${entry}"
|
||||
done
|
||||
|
||||
ok_count=0
|
||||
ko_count=0
|
||||
for r in "${RESULTS[@]}"; do
|
||||
ok_count=$((ok_count + $(awk -F'|' '{print $5}' <<< "${r}")))
|
||||
done
|
||||
ko_count=$((${#TARGETS[@]} - ok_count))
|
||||
|
||||
log_line "INFO" "Completed: ${ok_count} OK, ${ko_count} KO"
|
||||
for r in "${RESULTS[@]}"; do
|
||||
IFS='|' read -r target_host port priority role ok <<< "${r}"
|
||||
log_line "INFO" " - P${priority} ${target_host} (${port}) ${role} => $([[ "${ok}" -eq 1 ]] && echo OK || echo KO)"
|
||||
done
|
||||
|
||||
if [[ "${JSON_OUTPUT}" -eq 1 ]]; then
|
||||
contract_status="ok"
|
||||
degraded_reasons=()
|
||||
next_steps=()
|
||||
artifacts=()
|
||||
[[ -n "${LOG_FILE}" ]] && artifacts+=("${LOG_FILE}")
|
||||
if [[ "${ko_count}" -gt 0 ]]; then
|
||||
contract_status="degraded"
|
||||
degraded_reasons+=("ssh-target-unreachable")
|
||||
next_steps+=("Inspect SSH access, port 22 reachability and operator keys on KO targets.")
|
||||
fi
|
||||
printf '{\n'
|
||||
printf ' "contract_version": "cockpit-v1",\n'
|
||||
printf ' "component": "ssh_healthcheck",\n'
|
||||
printf ' "action": "summary",\n'
|
||||
printf ' "status": "%s",\n' "${contract_status}"
|
||||
printf ' "contract_status": "%s",\n' "${contract_status}"
|
||||
printf ' "generated_at": "%s",\n' "$(date '+%Y-%m-%d %H:%M:%S %z')"
|
||||
printf ' "log_file": "%s",\n' "${LOG_FILE}"
|
||||
printf ' "registry_file": "%s",\n' "${REGISTRY_FILE}"
|
||||
printf ' "target_source": "registry",\n'
|
||||
printf ' "artifacts": %s,\n' "$(json_contract_array_from_args "${artifacts[@]}")"
|
||||
printf ' "degraded_reasons": %s,\n' "$(json_contract_array_from_args "${degraded_reasons[@]}")"
|
||||
printf ' "next_steps": %s,\n' "$(json_contract_array_from_args "${next_steps[@]}")"
|
||||
printf ' "ok_count": %d,\n' "${ok_count}"
|
||||
printf ' "ko_count": %d,\n' "${ko_count}"
|
||||
printf ' "targets": [\n'
|
||||
for ((idx = 0; idx < ${#RESULTS[@]}; idx++)); do
|
||||
IFS='|' read -r target_host port priority role ok <<< "${RESULTS[${idx}]}"
|
||||
status="ok"
|
||||
[[ "${ok}" -eq 0 ]] && status="ko"
|
||||
comma=","
|
||||
if [[ ${idx} -eq $(( ${#RESULTS[@]} - 1 )) ]]; then
|
||||
comma=""
|
||||
fi
|
||||
printf ' {"target":"%s","port":"%s","priority":"%s","role":"%s","status":"%s"}%s\n' \
|
||||
"${target_host}" "${port}" "${priority}" "${role}" "${status}" "${comma}"
|
||||
done
|
||||
printf ' ]\n'
|
||||
printf '}\n'
|
||||
fi
|
||||
|
||||
if [[ "${ok_count}" -eq "${#TARGETS[@]}" ]]; then
|
||||
log_line "INFO" "SSH health-check: all targets reachable"
|
||||
exit 0
|
||||
else
|
||||
log_line "WARN" "SSH health-check: one or more targets unreachable"
|
||||
exit 2
|
||||
fi
|
||||
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
|
||||
MEMORY_DIR="${ROOT_DIR}/artifacts/cockpit/kill_life_memory"
|
||||
COMPONENT="unknown"
|
||||
STATUS="degraded"
|
||||
OWNER="SyncOps"
|
||||
DECISION_ACTION="record-memory-entry"
|
||||
DECISION_REASON="Persist execution continuity in kill_life."
|
||||
NEXT_STEP="Review the latest cockpit artifact."
|
||||
RESUME_REF=""
|
||||
TRUST_LEVEL="inferred"
|
||||
ROUTING_FILE=""
|
||||
JSON_OUTPUT=0
|
||||
ARTIFACTS=()
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: bash tools/cockpit/write_kill_life_memory_entry.sh [options]
|
||||
|
||||
Options:
|
||||
--component NAME
|
||||
--status ok|degraded|error|blocked
|
||||
--owner NAME
|
||||
--decision-action TEXT
|
||||
--decision-reason TEXT
|
||||
--next-step TEXT
|
||||
--resume-ref TEXT
|
||||
--trust-level verified|bounded|inferred
|
||||
--routing-file FILE
|
||||
--artifact PATH Repeatable
|
||||
--json
|
||||
-h, --help
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--component)
|
||||
COMPONENT="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--status)
|
||||
STATUS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--owner)
|
||||
OWNER="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--decision-action)
|
||||
DECISION_ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--decision-reason)
|
||||
DECISION_REASON="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--next-step)
|
||||
NEXT_STEP="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--resume-ref)
|
||||
RESUME_REF="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--trust-level)
|
||||
TRUST_LEVEL="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--routing-file)
|
||||
ROUTING_FILE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--artifact)
|
||||
ARTIFACTS+=("${2:-}")
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
JSON_OUTPUT=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir -p "${MEMORY_DIR}"
|
||||
timestamp="$(date '+%Y%m%d_%H%M%S')"
|
||||
entry_json="${MEMORY_DIR}/${COMPONENT}_${timestamp}.json"
|
||||
entry_md="${MEMORY_DIR}/${COMPONENT}_${timestamp}.md"
|
||||
latest_json="${MEMORY_DIR}/latest.json"
|
||||
latest_md="${MEMORY_DIR}/latest.md"
|
||||
component_latest_json="${MEMORY_DIR}/${COMPONENT}_latest.json"
|
||||
component_latest_md="${MEMORY_DIR}/${COMPONENT}_latest.md"
|
||||
intelligence_json="${ROOT_DIR}/artifacts/cockpit/intelligence_program/latest.json"
|
||||
intelligence_md="${ROOT_DIR}/artifacts/cockpit/intelligence_program/latest.md"
|
||||
|
||||
if [[ ! -f "${intelligence_json}" || ! -f "${intelligence_md}" ]]; then
|
||||
bash "${ROOT_DIR}/tools/cockpit/intelligence_tui.sh" --action memory --json >/dev/null 2>/dev/null || true
|
||||
fi
|
||||
|
||||
ARTIFACTS_JSON="$(
|
||||
python3 - "${ARTIFACTS[@]}" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
print(json.dumps([item for item in sys.argv[1:] if item], ensure_ascii=True))
|
||||
PY
|
||||
)"
|
||||
|
||||
ARTIFACTS_JSON="${ARTIFACTS_JSON}" python3 - "${entry_json}" "${entry_md}" "${latest_json}" "${latest_md}" "${component_latest_json}" "${component_latest_md}" "${COMPONENT}" "${STATUS}" "${OWNER}" "${DECISION_ACTION}" "${DECISION_REASON}" "${NEXT_STEP}" "${RESUME_REF}" "${TRUST_LEVEL}" "${ROUTING_FILE}" "${intelligence_json}" "${intelligence_md}" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
entry_json = Path(sys.argv[1])
|
||||
entry_md = Path(sys.argv[2])
|
||||
latest_json = Path(sys.argv[3])
|
||||
latest_md = Path(sys.argv[4])
|
||||
component_latest_json = Path(sys.argv[5])
|
||||
component_latest_md = Path(sys.argv[6])
|
||||
component = sys.argv[7]
|
||||
status = sys.argv[8]
|
||||
owner = sys.argv[9]
|
||||
decision_action = sys.argv[10]
|
||||
decision_reason = sys.argv[11]
|
||||
next_step = sys.argv[12]
|
||||
resume_ref = sys.argv[13]
|
||||
trust_level = sys.argv[14]
|
||||
routing_file = Path(sys.argv[15]) if sys.argv[15] else None
|
||||
intelligence_json = Path(sys.argv[16])
|
||||
intelligence_md = Path(sys.argv[17])
|
||||
artifacts = json.loads(os.environ.get("ARTIFACTS_JSON", "[]"))
|
||||
|
||||
routing = {}
|
||||
if routing_file and routing_file.exists():
|
||||
try:
|
||||
routing = json.loads(routing_file.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
routing = {"status": "invalid-json", "file": str(routing_file)}
|
||||
|
||||
intelligence_memory = {
|
||||
"json": str(intelligence_json) if intelligence_json.exists() else "",
|
||||
"markdown": str(intelligence_md) if intelligence_md.exists() else "",
|
||||
}
|
||||
|
||||
entry = {
|
||||
"status": status,
|
||||
"component": component,
|
||||
"owner": owner,
|
||||
"decision": {
|
||||
"action": decision_action,
|
||||
"reason": decision_reason,
|
||||
},
|
||||
"next_step": next_step,
|
||||
"resume_ref": resume_ref,
|
||||
"trust_level": trust_level,
|
||||
"routing": routing,
|
||||
"artifacts": artifacts,
|
||||
"intelligence_memory": intelligence_memory,
|
||||
}
|
||||
|
||||
summary = {
|
||||
"status": "ok",
|
||||
"component": component,
|
||||
"entry": entry,
|
||||
"artifacts": artifacts,
|
||||
"entry_file": str(entry_json),
|
||||
"markdown_file": str(entry_md),
|
||||
"latest_json": str(latest_json),
|
||||
"latest_markdown": str(latest_md),
|
||||
"component_latest_json": str(component_latest_json),
|
||||
"component_latest_markdown": str(component_latest_md),
|
||||
"resume_ref": resume_ref,
|
||||
"trust_level": trust_level,
|
||||
"intelligence_memory": intelligence_memory,
|
||||
}
|
||||
|
||||
entry_md.write_text(
|
||||
"\n".join(
|
||||
(
|
||||
[
|
||||
f"# kill_life memory entry - {component}",
|
||||
"",
|
||||
f"- status: {status}",
|
||||
f"- owner: {owner}",
|
||||
f"- trust_level: {trust_level}",
|
||||
f"- resume_ref: {resume_ref}",
|
||||
f"- next_step: {next_step}",
|
||||
"",
|
||||
"## Decision",
|
||||
f"- action: {decision_action}",
|
||||
f"- reason: {decision_reason}",
|
||||
"",
|
||||
"## Routing",
|
||||
f"- selected_target: {routing.get('selected_target', 'unknown')}",
|
||||
f"- selected_host: {routing.get('selected_host', 'unknown')}",
|
||||
f"- family: {routing.get('family', 'unknown')}",
|
||||
"",
|
||||
"## Intelligence memory",
|
||||
f"- json: {intelligence_memory['json'] or 'missing'}",
|
||||
f"- markdown: {intelligence_memory['markdown'] or 'missing'}",
|
||||
"",
|
||||
"## Artifacts",
|
||||
]
|
||||
+ ([f"- {item}" for item in artifacts] if artifacts else ["- none"])
|
||||
)
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
entry_json.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
latest_json.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
latest_md.write_text(entry_md.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
component_latest_json.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
component_latest_md.write_text(entry_md.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
|
||||
print(json.dumps(summary, ensure_ascii=False))
|
||||
PY
|
||||
Executable
+448
@@ -0,0 +1,448 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
ARTIFACTS_DIR="${ROOT_DIR}/artifacts/yiacad_backend_proof"
|
||||
PROOF_FIXTURES_DIR="${ROOT_DIR}/tools/cad/proof_fixtures/yiacad_backend_proof"
|
||||
mkdir -p "${ARTIFACTS_DIR}"
|
||||
|
||||
ACTION=""
|
||||
JSON_MODE=0
|
||||
VERBOSE=0
|
||||
YES=0
|
||||
DAYS=14
|
||||
LINES=80
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: yiacad_backend_proof.sh --action <run|status|logs-summary|logs-list|logs-latest|purge-logs> [options]
|
||||
|
||||
Options:
|
||||
--action <name> Action to run
|
||||
--days <N> Retention window for purge-logs (default: 14)
|
||||
--lines <N> Number of lines for logs-latest (default: 80)
|
||||
--json Emit JSON for run/status/logs-summary
|
||||
--yes Confirm destructive purge in non-interactive mode
|
||||
--verbose Print executed commands
|
||||
--help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
have_tty() {
|
||||
[[ -t 0 && -t 1 ]]
|
||||
}
|
||||
|
||||
log_cmd() {
|
||||
if [[ "${VERBOSE}" -eq 1 ]]; then
|
||||
printf '[cmd] %s\n' "$*"
|
||||
fi
|
||||
}
|
||||
|
||||
collect_log_files() {
|
||||
find "${ARTIFACTS_DIR}" -type f 2>/dev/null
|
||||
}
|
||||
|
||||
latest_summary_json() {
|
||||
if [[ -f "${ARTIFACTS_DIR}/latest.json" ]]; then
|
||||
printf '%s\n' "${ARTIFACTS_DIR}/latest.json"
|
||||
return 0
|
||||
fi
|
||||
find "${ARTIFACTS_DIR}" -type f -name 'summary.json' 2>/dev/null | sort | tail -n 1
|
||||
}
|
||||
|
||||
latest_summary_md() {
|
||||
if [[ -f "${ARTIFACTS_DIR}/latest.md" ]]; then
|
||||
printf '%s\n' "${ARTIFACTS_DIR}/latest.md"
|
||||
return 0
|
||||
fi
|
||||
find "${ARTIFACTS_DIR}" -type f -name 'summary.md' 2>/dev/null | sort | tail -n 1
|
||||
}
|
||||
|
||||
run_proof() {
|
||||
local stamp run_dir proof_status
|
||||
stamp="$(date +%Y%m%d_%H%M%S)"
|
||||
run_dir="${ARTIFACTS_DIR}/${stamp}"
|
||||
mkdir -p "${run_dir}"
|
||||
|
||||
log_cmd python3 "${ROOT_DIR}/tools/cad/yiacad_backend_client.py" --json-output status
|
||||
python3 "${ROOT_DIR}/tools/cad/yiacad_backend_client.py" --json-output status > "${run_dir}/backend_status.json"
|
||||
|
||||
log_cmd python3 "${ROOT_DIR}/tools/cad/yiacad_backend_client.py" --json-output status
|
||||
python3 "${ROOT_DIR}/tools/cad/yiacad_backend_client.py" --json-output status > "${run_dir}/backend_invoke_status.json"
|
||||
|
||||
log_cmd python3 kicad transport proof
|
||||
ROOT_DIR="${ROOT_DIR}" python3 - <<'PY' > "${run_dir}/kicad_transport.json"
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(os.environ["ROOT_DIR"])
|
||||
mod_path = root / ".runtime-home" / "cad-ai-native-forks" / "kicad-ki" / "scripting" / "plugins" / "yiacad_kicad_plugin" / "_native_common.py"
|
||||
spec = importlib.util.spec_from_file_location("yiacad_kicad_native_common_proof", mod_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
result = module.run_native_action("bom-review", "--source-path", "/tmp/nonexistent.kicad_pcb", "--json-output")
|
||||
proof_source = str(root / "tools" / "cad" / "proof_fixtures" / "yiacad_backend_proof" / "probe_board.kicad_pcb")
|
||||
result = module.run_native_action("bom-review", "--source-path", proof_source, "--json-output")
|
||||
payload = json.loads((result.stdout or "{}").strip() or "{}")
|
||||
required = ["component", "surface", "action", "execution_mode", "status", "severity", "summary", "artifacts", "next_steps"]
|
||||
missing = [field for field in required if field not in payload]
|
||||
payload_status = payload.get("status")
|
||||
proof = {
|
||||
"transport": "kicad-shell",
|
||||
"status": "done" if not missing and payload_status in {"done", "degraded", "blocked"} else "blocked",
|
||||
"returncode": result.returncode,
|
||||
"payload_status": payload_status,
|
||||
"contract_ok": not missing,
|
||||
"missing_fields": missing,
|
||||
"payload": payload,
|
||||
}
|
||||
print(json.dumps(proof, indent=2, ensure_ascii=False))
|
||||
PY
|
||||
|
||||
log_cmd python3 freecad transport proof
|
||||
ROOT_DIR="${ROOT_DIR}" python3 - <<'PY' > "${run_dir}/freecad_transport.json"
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(os.environ["ROOT_DIR"])
|
||||
mod_path = root / ".runtime-home" / "cad-ai-native-forks" / "freecad-ki" / "src" / "Mod" / "YiACADWorkbench" / "yiacad_freecad_gui.py"
|
||||
|
||||
freecad = types.ModuleType("FreeCAD")
|
||||
freecad.ActiveDocument = None
|
||||
freecad_gui = types.ModuleType("FreeCADGui")
|
||||
freecad_gui.runCommand = lambda *args, **kwargs: None
|
||||
freecad_gui.addCommand = lambda *args, **kwargs: None
|
||||
pyside = types.ModuleType("PySide")
|
||||
qtcore = types.ModuleType("QtCore")
|
||||
qtgui = types.ModuleType("QtGui")
|
||||
pyside.QtCore = qtcore
|
||||
pyside.QtGui = qtgui
|
||||
sys.modules["FreeCAD"] = freecad
|
||||
sys.modules["FreeCADGui"] = freecad_gui
|
||||
sys.modules["PySide"] = pyside
|
||||
sys.modules["PySide.QtCore"] = qtcore
|
||||
sys.modules["PySide.QtGui"] = qtgui
|
||||
|
||||
spec = importlib.util.spec_from_file_location("yiacad_freecad_gui_proof", mod_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
payload = module.run_native_json_action("bom-review", "--source-path", "/tmp/nonexistent.FCStd")
|
||||
proof_source = str(root / "tools" / "cad" / "proof_fixtures" / "yiacad_backend_proof" / "probe_model.FCStd")
|
||||
payload = module.run_native_json_action("bom-review", "--source-path", proof_source)
|
||||
required = ["component", "surface", "action", "execution_mode", "status", "severity", "summary", "artifacts", "next_steps"]
|
||||
missing = [field for field in required if field not in payload]
|
||||
proof = {
|
||||
"transport": "freecad-workbench",
|
||||
"status": "done" if not missing else "blocked",
|
||||
"contract_ok": not missing,
|
||||
"missing_fields": missing,
|
||||
"payload": payload,
|
||||
}
|
||||
print(json.dumps(proof, indent=2, ensure_ascii=False))
|
||||
PY
|
||||
|
||||
log_cmd python3 build backend proof summary
|
||||
ROOT_DIR="${ROOT_DIR}" RUN_DIR="${run_dir}" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
run_dir = Path(os.environ["RUN_DIR"])
|
||||
backend_status = json.loads((run_dir / "backend_status.json").read_text(encoding="utf-8"))
|
||||
backend_invoke = json.loads((run_dir / "backend_invoke_status.json").read_text(encoding="utf-8"))
|
||||
kicad = json.loads((run_dir / "kicad_transport.json").read_text(encoding="utf-8"))
|
||||
freecad = json.loads((run_dir / "freecad_transport.json").read_text(encoding="utf-8"))
|
||||
|
||||
backend_ok = backend_status.get("status") in {"done", "degraded"}
|
||||
invoke_ok = backend_invoke.get("status") in {"done", "degraded"}
|
||||
kicad_ok = bool(kicad.get("contract_ok")) and kicad.get("status") == "done"
|
||||
freecad_ok = bool(freecad.get("contract_ok")) and freecad.get("status") == "done"
|
||||
overall_status = "done" if backend_ok and invoke_ok and kicad_ok and freecad_ok else "blocked"
|
||||
|
||||
degraded_reasons = []
|
||||
if backend_status.get("status") == "degraded":
|
||||
degraded_reasons.append("backend-facade-no-recent-runs")
|
||||
if backend_invoke.get("status") == "degraded":
|
||||
degraded_reasons.append("backend-invoke-status-degraded")
|
||||
|
||||
summary = {
|
||||
"component": "yiacad-backend-proof",
|
||||
"action": "run",
|
||||
"status": overall_status,
|
||||
"transport": "local-facade",
|
||||
"backend_status": backend_status.get("status"),
|
||||
"backend_invoke_status": backend_invoke.get("status"),
|
||||
"kicad_transport_status": kicad.get("status"),
|
||||
"freecad_transport_status": freecad.get("status"),
|
||||
"contract_ok": bool(kicad.get("contract_ok")) and bool(freecad.get("contract_ok")),
|
||||
"degraded_reasons": degraded_reasons,
|
||||
"artifacts": [
|
||||
{"kind": "report", "path": str(run_dir / "backend_status.json"), "label": "Backend facade status"},
|
||||
{"kind": "report", "path": str(run_dir / "backend_invoke_status.json"), "label": "Backend facade invoke status"},
|
||||
{"kind": "evidence", "path": str(run_dir / "kicad_transport.json"), "label": "KiCad shell transport proof"},
|
||||
{"kind": "evidence", "path": str(run_dir / "freecad_transport.json"), "label": "FreeCAD workbench transport proof"},
|
||||
{"kind": "report", "path": str(run_dir / "summary.md"), "label": "Unified operator proof summary"},
|
||||
],
|
||||
"next_steps": [
|
||||
"promote the proof runbook via yiacad_operator_index.sh",
|
||||
"bind review center and inspector to uiux_output.json everywhere",
|
||||
"keep yiacad-fusion blocked until the KiCad host entrypoint exists or container fallback is accepted",
|
||||
],
|
||||
}
|
||||
|
||||
summary_json = run_dir / "summary.json"
|
||||
summary_json.write_text(json.dumps(summary, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
|
||||
md = [
|
||||
"# YiACAD Backend Operator Proof",
|
||||
"",
|
||||
f"- status: {summary['status']}",
|
||||
f"- transport: {summary['transport']}",
|
||||
f"- backend_status: {summary['backend_status']}",
|
||||
f"- backend_invoke_status: {summary['backend_invoke_status']}",
|
||||
f"- kicad_transport_status: {summary['kicad_transport_status']}",
|
||||
f"- freecad_transport_status: {summary['freecad_transport_status']}",
|
||||
f"- contract_ok: {'yes' if summary['contract_ok'] else 'no'}",
|
||||
"",
|
||||
"## Artifacts",
|
||||
]
|
||||
for artifact in summary["artifacts"]:
|
||||
md.append(f"- {artifact['label']}: {artifact['path']}")
|
||||
md.extend(["", "## Next steps"])
|
||||
for step in summary["next_steps"]:
|
||||
md.append(f"- {step}")
|
||||
(run_dir / "summary.md").write_text("\n".join(md) + "\n", encoding="utf-8")
|
||||
PY
|
||||
|
||||
cp "${run_dir}/summary.json" "${ARTIFACTS_DIR}/latest.json"
|
||||
cp "${run_dir}/summary.md" "${ARTIFACTS_DIR}/latest.md"
|
||||
|
||||
if [[ "${JSON_MODE}" -eq 1 ]]; then
|
||||
cat "${run_dir}/summary.json"
|
||||
else
|
||||
cat "${run_dir}/summary.md"
|
||||
fi
|
||||
|
||||
proof_status="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["status"])' "${run_dir}/summary.json")"
|
||||
[[ "${proof_status}" == "done" ]]
|
||||
}
|
||||
|
||||
show_status() {
|
||||
local latest_json latest_md
|
||||
latest_json="$(latest_summary_json)"
|
||||
latest_md="$(latest_summary_md)"
|
||||
if [[ -z "${latest_json}" || -z "${latest_md}" ]]; then
|
||||
if [[ "${JSON_MODE}" -eq 1 ]]; then
|
||||
python3 - <<PY
|
||||
import json
|
||||
print(json.dumps({
|
||||
"component": "yiacad-backend-proof",
|
||||
"action": "status",
|
||||
"status": "degraded",
|
||||
"summary": "No unified backend proof has been generated yet.",
|
||||
"artifacts_dir": "${ARTIFACTS_DIR}",
|
||||
}, ensure_ascii=False))
|
||||
PY
|
||||
else
|
||||
cat <<EOF
|
||||
# YiACAD Backend Operator Proof
|
||||
|
||||
- status: degraded
|
||||
- summary: No unified backend proof has been generated yet.
|
||||
- artifacts_dir: ${ARTIFACTS_DIR}
|
||||
EOF
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "${JSON_MODE}" -eq 1 ]]; then
|
||||
cat "${latest_json}"
|
||||
else
|
||||
cat "${latest_md}"
|
||||
fi
|
||||
}
|
||||
|
||||
logs_list() {
|
||||
local found=0
|
||||
local path=""
|
||||
|
||||
while IFS= read -r path; do
|
||||
[[ -n "${path}" ]] || continue
|
||||
[[ "${path}" == "${LOG_FILE:-}" ]] && continue
|
||||
printf '%s\n' "${path}"
|
||||
found=1
|
||||
done < <(collect_log_files | sort)
|
||||
|
||||
if [[ "${found}" -eq 0 ]]; then
|
||||
if [[ -n "${LOG_FILE:-}" ]]; then
|
||||
printf '%s\n' "${LOG_FILE}"
|
||||
else
|
||||
printf 'no logs found\n'
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
logs_latest() {
|
||||
local latest=""
|
||||
local path=""
|
||||
|
||||
while IFS= read -r path; do
|
||||
[[ -n "${path}" ]] || continue
|
||||
[[ "${path}" == "${LOG_FILE:-}" ]] && continue
|
||||
latest="${path}"
|
||||
done < <(collect_log_files | sort)
|
||||
|
||||
if [[ -z "${latest}" ]]; then
|
||||
if [[ -n "${LOG_FILE:-}" ]]; then
|
||||
latest="${LOG_FILE}"
|
||||
else
|
||||
printf 'no logs found\n'
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '# Latest log\n\n'
|
||||
printf -- '- path: %s\n' "${latest}"
|
||||
printf -- '- lines: %s\n\n' "${LINES}"
|
||||
tail -n "${LINES}" "${latest}"
|
||||
}
|
||||
|
||||
logs_summary() {
|
||||
local log_count
|
||||
log_count="$(find "${ARTIFACTS_DIR}" -type f | wc -l | tr -d ' ')"
|
||||
|
||||
if [[ "${JSON_MODE}" -eq 1 ]]; then
|
||||
python3 - <<PY
|
||||
import json
|
||||
print(json.dumps({
|
||||
"status": "done",
|
||||
"component": "yiacad-backend-proof",
|
||||
"log_files": int("${log_count}"),
|
||||
"artifacts_dir": "${ARTIFACTS_DIR}",
|
||||
"latest_summary": "${ARTIFACTS_DIR}/latest.json",
|
||||
}, ensure_ascii=False))
|
||||
PY
|
||||
return 0
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
# YiACAD backend proof logs summary
|
||||
|
||||
- log files: ${log_count}
|
||||
- artifacts dir: ${ARTIFACTS_DIR}
|
||||
- latest summary: ${ARTIFACTS_DIR}/latest.json
|
||||
EOF
|
||||
}
|
||||
|
||||
purge_logs() {
|
||||
if [[ "${YES}" -ne 1 ]]; then
|
||||
if command -v gum >/dev/null 2>&1 && have_tty; then
|
||||
if ! gum confirm "Purger les preuves/logs backend YiACAD de plus de ${DAYS} jours ?"; then
|
||||
printf 'purge cancelled\n'
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
printf 'Refusing purge without --yes outside interactive confirm\n' >&2
|
||||
return 2
|
||||
fi
|
||||
fi
|
||||
|
||||
find "${ARTIFACTS_DIR}" -type f -mtime +"${DAYS}" -delete
|
||||
find "${ARTIFACTS_DIR}" -mindepth 1 -type d -empty -delete
|
||||
printf 'purged yiacad backend proof logs older than %s days in %s\n' "${DAYS}" "${ARTIFACTS_DIR}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--days)
|
||||
DAYS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--lines)
|
||||
LINES="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
JSON_MODE=1
|
||||
shift
|
||||
;;
|
||||
--yes)
|
||||
YES=1
|
||||
shift
|
||||
;;
|
||||
--verbose)
|
||||
VERBOSE=1
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "${ACTION}" ]]; then
|
||||
printf 'Missing --action\n' >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! [[ "${DAYS}" =~ ^[0-9]+$ ]]; then
|
||||
printf -- '--days requires an integer\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! [[ "${LINES}" =~ ^[0-9]+$ ]]; then
|
||||
printf -- '--lines requires an integer\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
STAMP="$(date +%Y%m%d_%H%M%S)"
|
||||
LOG_FILE="${ARTIFACTS_DIR}/yiacad_backend_proof_${STAMP}.log"
|
||||
exec > >(tee -a "${LOG_FILE}") 2>&1
|
||||
|
||||
if [[ "${JSON_MODE}" -ne 1 ]]; then
|
||||
printf '[yiacad-backend-proof] action=%s timestamp=%s\n' "${ACTION}" "${STAMP}"
|
||||
fi
|
||||
|
||||
case "${ACTION}" in
|
||||
run)
|
||||
run_proof
|
||||
;;
|
||||
status)
|
||||
show_status
|
||||
;;
|
||||
logs-summary)
|
||||
logs_summary
|
||||
;;
|
||||
logs-list)
|
||||
logs_list
|
||||
;;
|
||||
logs-latest)
|
||||
logs_latest
|
||||
;;
|
||||
purge-logs)
|
||||
purge_logs
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown action: %s\n' "${ACTION}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SERVICE_ARTIFACTS_DIR="${ROOT_DIR}/artifacts/cad-ai-native/service"
|
||||
TUI_ARTIFACTS_DIR="${ROOT_DIR}/artifacts/yiacad_backend_service_tui"
|
||||
mkdir -p "${TUI_ARTIFACTS_DIR}"
|
||||
|
||||
ACTION=""
|
||||
JSON_MODE=0
|
||||
YES=0
|
||||
DAYS=14
|
||||
LINES=80
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: yiacad_backend_service_tui.sh --action <status|health|logs-summary|logs-list|logs-latest|purge-logs> [options]
|
||||
|
||||
Options:
|
||||
--action <name> Action to run
|
||||
--days <N> Retention window for purge-logs (default: 14)
|
||||
--lines <N> Number of lines for logs-latest (default: 80)
|
||||
--json Emit JSON for health/logs-summary
|
||||
--yes Confirm destructive purge in non-interactive mode
|
||||
--help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
have_tty() {
|
||||
[[ -t 0 && -t 1 ]]
|
||||
}
|
||||
|
||||
choose_action_interactive() {
|
||||
if command -v gum >/dev/null 2>&1 && have_tty; then
|
||||
gum choose status health logs-summary logs-list logs-latest purge-logs
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
collect_log_files() {
|
||||
find "${SERVICE_ARTIFACTS_DIR}" -type f 2>/dev/null
|
||||
find "${TUI_ARTIFACTS_DIR}" -type f 2>/dev/null
|
||||
}
|
||||
|
||||
service_health() {
|
||||
if [[ "${JSON_MODE}" -eq 1 ]]; then
|
||||
python3 "${ROOT_DIR}/tools/cad/yiacad_backend_client.py" --json-output health 2>/dev/null || printf '{"status":"blocked"}\n'
|
||||
else
|
||||
if ! python3 "${ROOT_DIR}/tools/cad/yiacad_backend_client.py" --json-output health 2>/dev/null; then
|
||||
printf '# YiACAD backend service health\n\n- status: blocked\n'
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
status_view() {
|
||||
cat <<EOF
|
||||
# YiACAD backend service status
|
||||
|
||||
- operator_index:
|
||||
- tools/cockpit/yiacad_operator_index.sh --action status
|
||||
- mode:
|
||||
- service-first
|
||||
- health:
|
||||
- python3 tools/cad/yiacad_backend_client.py --json-output health
|
||||
- service_artifacts:
|
||||
- ${SERVICE_ARTIFACTS_DIR}
|
||||
- latest_server:
|
||||
- ${SERVICE_ARTIFACTS_DIR}/latest_server.json
|
||||
- latest_health:
|
||||
- ${SERVICE_ARTIFACTS_DIR}/latest_health.json
|
||||
- latest_response:
|
||||
- ${SERVICE_ARTIFACTS_DIR}/latest_response.json
|
||||
EOF
|
||||
}
|
||||
|
||||
logs_list() {
|
||||
local found=0
|
||||
local path=""
|
||||
while IFS= read -r path; do
|
||||
[[ -n "${path}" ]] || continue
|
||||
[[ "${path}" == "${LOG_FILE:-}" ]] && continue
|
||||
printf '%s\n' "${path}"
|
||||
found=1
|
||||
done < <(collect_log_files | sort)
|
||||
if [[ "${found}" -eq 0 ]]; then
|
||||
printf 'no logs found\n'
|
||||
fi
|
||||
}
|
||||
|
||||
logs_latest() {
|
||||
local latest=""
|
||||
local path=""
|
||||
while IFS= read -r path; do
|
||||
[[ -n "${path}" ]] || continue
|
||||
[[ "${path}" == "${LOG_FILE:-}" ]] && continue
|
||||
latest="${path}"
|
||||
done < <(collect_log_files | sort)
|
||||
if [[ -z "${latest}" ]]; then
|
||||
printf 'no logs found\n'
|
||||
return 1
|
||||
fi
|
||||
printf '# Latest log\n\n'
|
||||
printf -- '- path: %s\n' "${latest}"
|
||||
printf -- '- lines: %s\n\n' "${LINES}"
|
||||
tail -n "${LINES}" "${latest}"
|
||||
}
|
||||
|
||||
logs_summary() {
|
||||
local service_count tui_count
|
||||
service_count="$(find "${SERVICE_ARTIFACTS_DIR}" -type f 2>/dev/null | wc -l | tr -d ' ')"
|
||||
tui_count="$(find "${TUI_ARTIFACTS_DIR}" -type f 2>/dev/null | wc -l | tr -d ' ')"
|
||||
if [[ "${JSON_MODE}" -eq 1 ]]; then
|
||||
python3 - <<PY
|
||||
import json
|
||||
print(json.dumps({
|
||||
"status": "done",
|
||||
"service_artifact_files": int("${service_count}"),
|
||||
"tui_log_files": int("${tui_count}"),
|
||||
"service_artifacts_dir": "${SERVICE_ARTIFACTS_DIR}",
|
||||
"tui_artifacts_dir": "${TUI_ARTIFACTS_DIR}",
|
||||
}, ensure_ascii=False))
|
||||
PY
|
||||
return 0
|
||||
fi
|
||||
cat <<EOF
|
||||
# YiACAD backend service logs summary
|
||||
|
||||
- service artifact files: ${service_count}
|
||||
- tui log files: ${tui_count}
|
||||
- service artifacts dir: ${SERVICE_ARTIFACTS_DIR}
|
||||
- tui artifacts dir: ${TUI_ARTIFACTS_DIR}
|
||||
EOF
|
||||
}
|
||||
|
||||
purge_logs() {
|
||||
if [[ "${YES}" -ne 1 ]]; then
|
||||
if command -v gum >/dev/null 2>&1 && have_tty; then
|
||||
if ! gum confirm "Purger les logs backend service YiACAD de plus de ${DAYS} jours ?"; then
|
||||
printf 'purge cancelled\n'
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
printf 'Refusing purge without --yes outside interactive confirm\n' >&2
|
||||
return 2
|
||||
fi
|
||||
fi
|
||||
find "${SERVICE_ARTIFACTS_DIR}" -type f -mtime +"${DAYS}" -delete 2>/dev/null || true
|
||||
find "${TUI_ARTIFACTS_DIR}" -type f -mtime +"${DAYS}" -delete 2>/dev/null || true
|
||||
printf 'purged backend service logs older than %s days\n' "${DAYS}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--days)
|
||||
DAYS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--lines)
|
||||
LINES="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
JSON_MODE=1
|
||||
shift
|
||||
;;
|
||||
--yes)
|
||||
YES=1
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "${ACTION}" ]]; then
|
||||
if ACTION="$(choose_action_interactive)"; then
|
||||
:
|
||||
else
|
||||
printf 'Missing --action (interactive selection unavailable)\n' >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
STAMP="$(date +%Y%m%d_%H%M%S)"
|
||||
LOG_FILE="${TUI_ARTIFACTS_DIR}/yiacad_backend_service_tui_${STAMP}.log"
|
||||
exec > >(tee -a "${LOG_FILE}") 2>&1
|
||||
|
||||
case "${ACTION}" in
|
||||
status)
|
||||
status_view
|
||||
;;
|
||||
health)
|
||||
service_health
|
||||
;;
|
||||
logs-summary)
|
||||
logs_summary
|
||||
;;
|
||||
logs-list)
|
||||
logs_list
|
||||
;;
|
||||
logs-latest)
|
||||
logs_latest
|
||||
;;
|
||||
purge-logs)
|
||||
purge_logs
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown action: %s\n' "${ACTION}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Executable
+187
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
ARTIFACTS_DIR="${ROOT_DIR}/artifacts/yiacad_logs_tui"
|
||||
ACTION="status"
|
||||
DAYS="14"
|
||||
YES="false"
|
||||
JSON="false"
|
||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
LOG_FILE=""
|
||||
LOG_SOURCES=(
|
||||
"${ROOT_DIR}/artifacts/yiacad_operator_index"
|
||||
"${ROOT_DIR}/artifacts/yiacad_uiux_tui"
|
||||
"${ROOT_DIR}/artifacts/yiacad_backend_service_tui"
|
||||
"${ROOT_DIR}/artifacts/yiacad_proofs_tui"
|
||||
"${ROOT_DIR}/artifacts/cad-ai-native/service"
|
||||
)
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: yiacad_logs_tui.sh --action <status|summary|list|latest|purge-logs> [options]
|
||||
|
||||
Options:
|
||||
--action ACTION Action to execute.
|
||||
--days N Purge logs older than N days. Default: 14.
|
||||
--json Emit JSON status for machine use.
|
||||
--yes Confirm destructive actions like purge.
|
||||
--help Show this help.
|
||||
USAGE
|
||||
}
|
||||
|
||||
ensure_dirs() {
|
||||
mkdir -p "${ARTIFACTS_DIR}"
|
||||
LOG_FILE="${ARTIFACTS_DIR}/yiacad_logs_tui_${STAMP}.log"
|
||||
touch "${LOG_FILE}"
|
||||
}
|
||||
|
||||
log() {
|
||||
printf '[yiacad-logs] %s\n' "$*" | tee -a "${LOG_FILE}" >&2
|
||||
}
|
||||
|
||||
collect_logs() {
|
||||
local source
|
||||
for source in "${LOG_SOURCES[@]}"; do
|
||||
if [[ -d "${source}" ]]; then
|
||||
find "${source}" -maxdepth 2 -type f \( -name '*.log' -o -name 'server.log' \) -print
|
||||
fi
|
||||
done | sort -u
|
||||
}
|
||||
|
||||
render_status_text() {
|
||||
cat <<EOF2
|
||||
YiACAD logs status
|
||||
- canonical_entry: bash tools/cockpit/yiacad_logs_tui.sh --action status
|
||||
- operator_entry: bash tools/cockpit/yiacad_operator_index.sh --action status
|
||||
- source_count: $(printf '%s
|
||||
' "${LOG_SOURCES[@]}" | wc -l | tr -d ' ')
|
||||
- action_hint: utilisez --action summary pour un agregat et --action purge-logs --days 14 --yes pour nettoyer
|
||||
EOF2
|
||||
}
|
||||
|
||||
render_status_json() {
|
||||
cat <<EOF2
|
||||
{
|
||||
"component": "yiacad-logs-tui",
|
||||
"timestamp": "${STAMP}",
|
||||
"canonical_entry": "bash tools/cockpit/yiacad_logs_tui.sh --action status",
|
||||
"operator_entry": "bash tools/cockpit/yiacad_operator_index.sh --action status",
|
||||
"source_count": $(printf '%s\n' "${LOG_SOURCES[@]}" | wc -l | tr -d ' ')
|
||||
}
|
||||
EOF2
|
||||
}
|
||||
|
||||
summary_action() {
|
||||
local tmp count latest
|
||||
tmp="$(mktemp)"
|
||||
collect_logs > "${tmp}"
|
||||
count="$(wc -l < "${tmp}" | tr -d ' ')"
|
||||
latest="$(tail -n 1 "${tmp}")"
|
||||
if [[ "${JSON}" == "true" ]]; then
|
||||
cat <<EOF2
|
||||
{
|
||||
"component": "yiacad-logs-tui",
|
||||
"log_count": ${count:-0},
|
||||
"latest_log": "${latest}",
|
||||
"sources": [
|
||||
"${ROOT_DIR}/artifacts/yiacad_operator_index",
|
||||
"${ROOT_DIR}/artifacts/yiacad_uiux_tui",
|
||||
"${ROOT_DIR}/artifacts/yiacad_backend_service_tui",
|
||||
"${ROOT_DIR}/artifacts/yiacad_proofs_tui",
|
||||
"${ROOT_DIR}/artifacts/cad-ai-native/service"
|
||||
]
|
||||
}
|
||||
EOF2
|
||||
else
|
||||
printf 'YiACAD aggregated logs\n'
|
||||
printf -- '- log_count: %s\n' "${count:-0}"
|
||||
printf -- '- latest_log: %s\n' "${latest:-none}"
|
||||
fi
|
||||
rm -f "${tmp}"
|
||||
}
|
||||
|
||||
list_action() {
|
||||
collect_logs
|
||||
}
|
||||
|
||||
latest_action() {
|
||||
local latest
|
||||
latest="$(collect_logs | tail -n 1)"
|
||||
if [[ -n "${latest}" ]]; then
|
||||
cat "${latest}"
|
||||
fi
|
||||
}
|
||||
|
||||
purge_action() {
|
||||
local source
|
||||
if [[ "${YES}" != "true" ]]; then
|
||||
printf 'Refusing to purge logs without --yes\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
for source in "${LOG_SOURCES[@]}"; do
|
||||
if [[ -d "${source}" ]]; then
|
||||
find "${source}" -maxdepth 2 -type f \( -name '*.log' -o -name 'server.log' \) -mtime "+${DAYS}" -delete
|
||||
fi
|
||||
done
|
||||
printf 'Purged YiACAD logs older than %s days\n' "${DAYS}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--days)
|
||||
DAYS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
JSON="true"
|
||||
shift
|
||||
;;
|
||||
--yes)
|
||||
YES="true"
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
ensure_dirs
|
||||
|
||||
case "${ACTION}" in
|
||||
status)
|
||||
if [[ "${JSON}" == "true" ]]; then
|
||||
render_status_json
|
||||
else
|
||||
render_status_text
|
||||
fi
|
||||
;;
|
||||
summary)
|
||||
summary_action
|
||||
;;
|
||||
list)
|
||||
list_action
|
||||
;;
|
||||
latest)
|
||||
latest_action
|
||||
;;
|
||||
purge-logs)
|
||||
purge_action
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown action: %s\n' "${ACTION}" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,551 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
ARTIFACTS_DIR="${ROOT_DIR}/artifacts/yiacad_operator_index"
|
||||
mkdir -p "${ARTIFACTS_DIR}"
|
||||
|
||||
ACTION=""
|
||||
JSON_MODE=0
|
||||
VERBOSE=0
|
||||
YES=0
|
||||
DAYS=14
|
||||
LINES=80
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: yiacad_operator_index.sh --action <action> [options]
|
||||
|
||||
Actions:
|
||||
status View operator index status and routes
|
||||
incident-watch Monitor incidents via mascarade_incidents_tui.sh
|
||||
incident-history View incident history
|
||||
uiux Run UI/UX status checks
|
||||
global Run global refonte status
|
||||
backend Backend service status
|
||||
review-context Review context/session/history/taxonomy
|
||||
proofs View proofs and continuity artifacts
|
||||
logs-summary Summarize logs (supports --json)
|
||||
logs-list List all log files
|
||||
logs-latest Show latest log
|
||||
purge-logs Clean old logs (--days N, requires --yes)
|
||||
--- Mistral Agents (Lot 23) ---
|
||||
agents-status Check all 4 Mistral agents status
|
||||
agents-chat Chat with a Mistral agent interactively
|
||||
agents-health Run Sentinelle full diagnostic
|
||||
agents-e2e Run E2E agents integration tests
|
||||
--- Mistral Studio (Lot 24) ---
|
||||
studio-status Mistral Studio health overview (agents+files+ft+libraries)
|
||||
studio-files List Mistral Files API
|
||||
studio-finetune List fine-tune jobs status
|
||||
studio-libraries List Document Libraries (Beta RAG)
|
||||
studio-conversations List Beta Conversations
|
||||
studio-models Full models catalog
|
||||
infra-health Infrastructure container health check (web+docker)
|
||||
|
||||
Aliases: backend-proof, review-session, review-history, review-taxonomy
|
||||
|
||||
Options:
|
||||
--action <name> Action to run
|
||||
--days <N> Retention window for purge-logs (default: 14)
|
||||
--lines <N> Number of lines for logs-latest (default: 80)
|
||||
--json Emit JSON for logs-summary
|
||||
--yes Confirm destructive purge in non-interactive mode
|
||||
--verbose Print executed commands
|
||||
--help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
have_tty() {
|
||||
[[ -t 0 && -t 1 ]]
|
||||
}
|
||||
|
||||
choose_action_interactive() {
|
||||
if command -v gum >/dev/null 2>&1 && have_tty; then
|
||||
gum choose \
|
||||
status \
|
||||
incident-watch \
|
||||
incident-history \
|
||||
uiux \
|
||||
global \
|
||||
backend \
|
||||
backend-proof \
|
||||
review-session \
|
||||
review-history \
|
||||
review-taxonomy \
|
||||
review-context \
|
||||
proofs \
|
||||
agents-status \
|
||||
agents-chat \
|
||||
agents-health \
|
||||
agents-e2e \
|
||||
studio-status \
|
||||
studio-files \
|
||||
studio-finetune \
|
||||
studio-libraries \
|
||||
studio-conversations \
|
||||
studio-models \
|
||||
infra-health \
|
||||
logs-summary \
|
||||
logs-list \
|
||||
logs-latest \
|
||||
purge-logs
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
log_cmd() {
|
||||
if [[ "${VERBOSE}" -eq 1 ]]; then
|
||||
printf '[cmd] %s\n' "$*"
|
||||
fi
|
||||
}
|
||||
|
||||
run_uiux_status() {
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action lane-status
|
||||
bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action lane-status
|
||||
}
|
||||
|
||||
run_global_status() {
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_refonte_tui.sh" --action status
|
||||
bash "${ROOT_DIR}/tools/cockpit/yiacad_refonte_tui.sh" --action status
|
||||
}
|
||||
|
||||
run_backend_service() {
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_backend_service_tui.sh" --action status
|
||||
bash "${ROOT_DIR}/tools/cockpit/yiacad_backend_service_tui.sh" --action status
|
||||
}
|
||||
|
||||
run_backend_proof() {
|
||||
run_backend_service
|
||||
}
|
||||
|
||||
run_incident_watch() {
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/mascarade_incidents_tui.sh" --action watch
|
||||
bash "${ROOT_DIR}/tools/cockpit/mascarade_incidents_tui.sh" --action watch
|
||||
}
|
||||
|
||||
run_incident_history() {
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/render_mascarade_watch_history.sh"
|
||||
bash "${ROOT_DIR}/tools/cockpit/render_mascarade_watch_history.sh"
|
||||
}
|
||||
|
||||
run_review_session() {
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-session
|
||||
bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-session
|
||||
}
|
||||
|
||||
run_review_history() {
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-history
|
||||
bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-history
|
||||
}
|
||||
|
||||
run_review_taxonomy() {
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-taxonomy
|
||||
bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-taxonomy
|
||||
}
|
||||
|
||||
run_review_context() {
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-context
|
||||
bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-context
|
||||
}
|
||||
|
||||
run_review_context() {
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-context
|
||||
bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-context
|
||||
}
|
||||
|
||||
# ── Mistral Agents (Lot 23 T-MA-024) ─────────────────────────────────────
|
||||
run_agents_status() {
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/mistral_agents_tui.sh" --action status
|
||||
bash "${ROOT_DIR}/tools/cockpit/mistral_agents_tui.sh" --action status
|
||||
}
|
||||
|
||||
run_agents_chat() {
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/mistral_agents_tui.sh" --action chat
|
||||
bash "${ROOT_DIR}/tools/cockpit/mistral_agents_tui.sh" --action chat
|
||||
}
|
||||
|
||||
run_agents_health() {
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/mistral_agents_tui.sh" --action health
|
||||
bash "${ROOT_DIR}/tools/cockpit/mistral_agents_tui.sh" --action health
|
||||
}
|
||||
|
||||
run_agents_e2e() {
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/e2e_agents_test.sh" --action all
|
||||
bash "${ROOT_DIR}/tools/cockpit/e2e_agents_test.sh" --action all
|
||||
}
|
||||
|
||||
# ── Mistral Studio (Lot 24) ──────────────────────────────────────────────
|
||||
run_studio_status() {
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/mistral_studio_tui.sh" --health
|
||||
bash "${ROOT_DIR}/tools/cockpit/mistral_studio_tui.sh" --health
|
||||
}
|
||||
|
||||
run_studio_files() {
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/mistral_studio_tui.sh" --files-list
|
||||
bash "${ROOT_DIR}/tools/cockpit/mistral_studio_tui.sh" --files-list
|
||||
}
|
||||
|
||||
run_studio_finetune() {
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/mistral_studio_tui.sh" --finetune-list
|
||||
bash "${ROOT_DIR}/tools/cockpit/mistral_studio_tui.sh" --finetune-list
|
||||
}
|
||||
|
||||
run_studio_libraries() {
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/mistral_studio_tui.sh" --libraries-list
|
||||
bash "${ROOT_DIR}/tools/cockpit/mistral_studio_tui.sh" --libraries-list
|
||||
}
|
||||
|
||||
run_studio_conversations() {
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/mistral_studio_tui.sh" --conversations-list
|
||||
bash "${ROOT_DIR}/tools/cockpit/mistral_studio_tui.sh" --conversations-list
|
||||
}
|
||||
|
||||
run_studio_models() {
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/mistral_studio_tui.sh" --models-list
|
||||
bash "${ROOT_DIR}/tools/cockpit/mistral_studio_tui.sh" --models-list
|
||||
}
|
||||
|
||||
run_infra_health() {
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/infra_container_health.sh" --action status
|
||||
bash "${ROOT_DIR}/tools/cockpit/infra_container_health.sh" --action status
|
||||
}
|
||||
|
||||
status_view() {
|
||||
cat <<EOF
|
||||
# YiACAD operator index
|
||||
|
||||
- short_entry:
|
||||
- bash tools/cockpit/yiacad_operator_index.sh --action status
|
||||
- execution_continuity:
|
||||
- kill_life memory latest: ${ROOT_DIR}/artifacts/cockpit/kill_life_memory/latest.md
|
||||
- operator daily summary: ${ROOT_DIR}/artifacts/cockpit/daily_operator_summary_latest.md
|
||||
- product contract handoff json: ${ROOT_DIR}/artifacts/cockpit/product_contract_handoff/latest.json
|
||||
- product contract handoff markdown: ${ROOT_DIR}/artifacts/cockpit/product_contract_handoff/latest.md
|
||||
- incident watch: bash tools/cockpit/mascarade_incidents_tui.sh --action watch
|
||||
- routes:
|
||||
- incident-watch: bash tools/cockpit/mascarade_incidents_tui.sh --action watch
|
||||
- incident-history: bash tools/cockpit/render_mascarade_watch_history.sh
|
||||
- uiux: bash tools/cockpit/yiacad_uiux_tui.sh --action status
|
||||
- global: bash tools/cockpit/yiacad_refonte_tui.sh --action status
|
||||
- backend: bash tools/cockpit/yiacad_backend_service_tui.sh --action status
|
||||
- review-context: bash tools/cockpit/yiacad_operator_index.sh --action review-context
|
||||
- proofs: bash tools/cockpit/yiacad_operator_index.sh --action proofs
|
||||
- mistral_agents:
|
||||
- agents-status: bash tools/cockpit/mistral_agents_tui.sh --action status
|
||||
- agents-chat: bash tools/cockpit/mistral_agents_tui.sh --action chat
|
||||
- agents-health: bash tools/cockpit/mistral_agents_tui.sh --action health
|
||||
- agents-e2e: bash tools/cockpit/e2e_agents_test.sh --action all
|
||||
- mistral_studio:
|
||||
- studio-status: bash tools/cockpit/mistral_studio_tui.sh --health
|
||||
- studio-files: bash tools/cockpit/mistral_studio_tui.sh --files-list
|
||||
- studio-finetune: bash tools/cockpit/mistral_studio_tui.sh --finetune-list
|
||||
- studio-libraries: bash tools/cockpit/mistral_studio_tui.sh --libraries-list
|
||||
- studio-conversations: bash tools/cockpit/mistral_studio_tui.sh --conversations-list
|
||||
- studio-models: bash tools/cockpit/mistral_studio_tui.sh --models-list
|
||||
- infrastructure:
|
||||
- infra-health: bash tools/cockpit/infra_container_health.sh --action status
|
||||
- aliases:
|
||||
- backend-proof -> backend
|
||||
- review-session -> yiacad_uiux_tui.sh --action review-session
|
||||
- review-history -> yiacad_uiux_tui.sh --action review-history
|
||||
- review-taxonomy -> yiacad_uiux_tui.sh --action review-taxonomy
|
||||
- review-context -> yiacad_uiux_tui.sh --action review-context
|
||||
- next_lots:
|
||||
- T-UX-006
|
||||
- T-ARCH-101C
|
||||
- operator_doc:
|
||||
- docs/YIACAD_OPERATOR_INDEX_2026-03-21.md
|
||||
- artifacts:
|
||||
- ${ARTIFACTS_DIR}
|
||||
EOF
|
||||
}
|
||||
|
||||
proofs_view() {
|
||||
cat <<EOF
|
||||
# YiACAD operator proofs
|
||||
|
||||
- operator_index:
|
||||
- docs/YIACAD_OPERATOR_INDEX_2026-03-21.md
|
||||
- tools/cockpit/yiacad_operator_index.sh
|
||||
- uiux_lane:
|
||||
- docs/plans/20_plan_refonte_ui_ux_yiacad_apple_native.md
|
||||
- docs/plans/20_todo_refonte_ui_ux_yiacad_apple_native.md
|
||||
- tools/cockpit/yiacad_uiux_tui.sh
|
||||
- global_lane:
|
||||
- docs/plans/21_plan_refonte_globale_yiacad.md
|
||||
- docs/plans/21_todo_refonte_globale_yiacad.md
|
||||
- tools/cockpit/yiacad_refonte_tui.sh
|
||||
- global_docs:
|
||||
- docs/YIACAD_GLOBAL_REFACTOR_AUDIT_2026-03-20.md
|
||||
- docs/YIACAD_GLOBAL_AI_INTEGRATION_ASSESSMENT_2026-03-20.md
|
||||
- backend:
|
||||
- docs/YIACAD_BACKEND_SERVICE_2026-03-21.md
|
||||
- tools/cockpit/yiacad_backend_service_tui.sh
|
||||
- proofs:
|
||||
- docs/YIACAD_OPERATOR_INDEX_2026-03-21.md
|
||||
- tools/cockpit/yiacad_operator_index.sh --action proofs
|
||||
- tools/cockpit/mascarade_incidents_tui.sh --action watch
|
||||
- tools/cockpit/render_mascarade_watch_history.sh
|
||||
- continuity:
|
||||
- artifacts/cockpit/kill_life_memory/latest.json
|
||||
- artifacts/cockpit/kill_life_memory/latest.md
|
||||
- artifacts/cockpit/daily_operator_summary_latest.md
|
||||
- artifacts/cockpit/product_contract_handoff/latest.json
|
||||
- artifacts/cockpit/product_contract_handoff/latest.md
|
||||
EOF
|
||||
}
|
||||
|
||||
collect_log_files() {
|
||||
find "${ARTIFACTS_DIR}" -type f 2>/dev/null
|
||||
}
|
||||
|
||||
logs_list() {
|
||||
local found=0
|
||||
local path=""
|
||||
|
||||
while IFS= read -r path; do
|
||||
[[ -n "${path}" ]] || continue
|
||||
[[ "${path}" == "${LOG_FILE:-}" ]] && continue
|
||||
printf '%s\n' "${path}"
|
||||
found=1
|
||||
done < <(collect_log_files | sort)
|
||||
|
||||
if [[ "${found}" -eq 0 ]]; then
|
||||
if [[ -n "${LOG_FILE:-}" ]]; then
|
||||
printf '%s\n' "${LOG_FILE}"
|
||||
else
|
||||
printf 'no logs found\n'
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
logs_latest() {
|
||||
local latest=""
|
||||
local path=""
|
||||
|
||||
while IFS= read -r path; do
|
||||
[[ -n "${path}" ]] || continue
|
||||
[[ "${path}" == "${LOG_FILE:-}" ]] && continue
|
||||
latest="${path}"
|
||||
done < <(collect_log_files | sort)
|
||||
|
||||
if [[ -z "${latest}" ]]; then
|
||||
if [[ -n "${LOG_FILE:-}" ]]; then
|
||||
latest="${LOG_FILE}"
|
||||
else
|
||||
printf 'no logs found\n'
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '# Latest log\n\n'
|
||||
printf -- '- path: %s\n' "${latest}"
|
||||
printf -- '- lines: %s\n\n' "${LINES}"
|
||||
tail -n "${LINES}" "${latest}"
|
||||
}
|
||||
|
||||
logs_summary() {
|
||||
local log_count
|
||||
log_count="$(find "${ARTIFACTS_DIR}" -type f | wc -l | tr -d ' ')"
|
||||
|
||||
if [[ "${JSON_MODE}" -eq 1 ]]; then
|
||||
python3 - <<PY
|
||||
import json
|
||||
print(json.dumps({
|
||||
"status": "done",
|
||||
"log_files": int("${log_count}"),
|
||||
"artifacts_dir": "${ARTIFACTS_DIR}",
|
||||
"entrypoint": "tools/cockpit/yiacad_operator_index.sh",
|
||||
"uiux_artifacts_dir": "${ROOT_DIR}/artifacts/uiux_tui",
|
||||
"global_artifacts_dir": "${ROOT_DIR}/artifacts/yiacad_refonte_tui",
|
||||
"backend_artifacts_dir": "${ROOT_DIR}/artifacts/yiacad_backend_service_tui",
|
||||
}, ensure_ascii=False))
|
||||
PY
|
||||
return 0
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
# YiACAD operator index logs summary
|
||||
|
||||
- log files: ${log_count}
|
||||
- artifacts dir: ${ARTIFACTS_DIR}
|
||||
- uiux artifacts dir: ${ROOT_DIR}/artifacts/uiux_tui
|
||||
- global artifacts dir: ${ROOT_DIR}/artifacts/yiacad_refonte_tui
|
||||
- backend artifacts dir: ${ROOT_DIR}/artifacts/yiacad_backend_service_tui
|
||||
EOF
|
||||
}
|
||||
|
||||
purge_logs() {
|
||||
if [[ "${YES}" -ne 1 ]]; then
|
||||
if command -v gum >/dev/null 2>&1 && have_tty; then
|
||||
if ! gum confirm "Purger les logs d'index operateur YiACAD de plus de ${DAYS} jours ?"; then
|
||||
printf 'purge cancelled\n'
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
printf 'Refusing purge without --yes outside interactive confirm\n' >&2
|
||||
return 2
|
||||
fi
|
||||
fi
|
||||
|
||||
find "${ARTIFACTS_DIR}" -type f -mtime +"${DAYS}" -delete
|
||||
printf 'purged yiacad operator index logs older than %s days in %s\n' "${DAYS}" "${ARTIFACTS_DIR}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--days)
|
||||
DAYS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--lines)
|
||||
LINES="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
JSON_MODE=1
|
||||
shift
|
||||
;;
|
||||
--yes)
|
||||
YES=1
|
||||
shift
|
||||
;;
|
||||
--verbose)
|
||||
VERBOSE=1
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "${ACTION}" ]]; then
|
||||
if ACTION="$(choose_action_interactive)"; then
|
||||
:
|
||||
else
|
||||
printf 'Missing --action (interactive selection unavailable)\n' >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! [[ "${DAYS}" =~ ^[0-9]+$ ]]; then
|
||||
printf -- '--days requires an integer\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! [[ "${LINES}" =~ ^[0-9]+$ ]]; then
|
||||
printf -- '--lines requires an integer\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
STAMP="$(date +%Y%m%d_%H%M%S)"
|
||||
LOG_FILE="${ARTIFACTS_DIR}/yiacad_operator_index_${STAMP}.log"
|
||||
exec > >(tee -a "${LOG_FILE}") 2>&1
|
||||
|
||||
printf '[yiacad-operator-index] action=%s timestamp=%s\n' "${ACTION}" "${STAMP}"
|
||||
|
||||
case "${ACTION}" in
|
||||
status)
|
||||
status_view
|
||||
;;
|
||||
incident-watch)
|
||||
run_incident_watch
|
||||
;;
|
||||
incident-history)
|
||||
run_incident_history
|
||||
;;
|
||||
uiux)
|
||||
run_uiux_status
|
||||
;;
|
||||
global)
|
||||
run_global_status
|
||||
;;
|
||||
backend)
|
||||
run_backend_service
|
||||
;;
|
||||
backend-proof)
|
||||
run_backend_proof
|
||||
;;
|
||||
review-session)
|
||||
run_review_session
|
||||
;;
|
||||
review-history)
|
||||
run_review_history
|
||||
;;
|
||||
review-taxonomy)
|
||||
run_review_taxonomy
|
||||
;;
|
||||
review-context)
|
||||
run_review_context
|
||||
;;
|
||||
review-context)
|
||||
run_review_context
|
||||
;;
|
||||
proofs)
|
||||
proofs_view
|
||||
;;
|
||||
agents-status)
|
||||
run_agents_status
|
||||
;;
|
||||
agents-chat)
|
||||
run_agents_chat
|
||||
;;
|
||||
agents-health)
|
||||
run_agents_health
|
||||
;;
|
||||
agents-e2e)
|
||||
run_agents_e2e
|
||||
;;
|
||||
studio-status)
|
||||
run_studio_status
|
||||
;;
|
||||
studio-files)
|
||||
run_studio_files
|
||||
;;
|
||||
studio-finetune)
|
||||
run_studio_finetune
|
||||
;;
|
||||
studio-libraries)
|
||||
run_studio_libraries
|
||||
;;
|
||||
studio-conversations)
|
||||
run_studio_conversations
|
||||
;;
|
||||
studio-models)
|
||||
run_studio_models
|
||||
;;
|
||||
infra-health)
|
||||
run_infra_health
|
||||
;;
|
||||
logs-summary)
|
||||
logs_summary
|
||||
;;
|
||||
logs-list)
|
||||
logs_list
|
||||
;;
|
||||
logs-latest)
|
||||
logs_latest
|
||||
;;
|
||||
purge-logs)
|
||||
purge_logs
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown action: %s\n' "${ACTION}" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Executable
+203
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
ARTIFACTS_DIR="${ROOT_DIR}/artifacts/yiacad_proofs_tui"
|
||||
ACTION="status"
|
||||
DAYS="14"
|
||||
YES="false"
|
||||
JSON="false"
|
||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
LOG_FILE=""
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: yiacad_proofs_tui.sh --action <status|backend|review-session|review-history|review-taxonomy|logs-summary|logs-list|logs-latest|purge-logs> [options]
|
||||
|
||||
Options:
|
||||
--action ACTION Action to execute.
|
||||
--days N Purge logs older than N days. Default: 14.
|
||||
--json Emit JSON status for machine use.
|
||||
--yes Confirm destructive actions like purge.
|
||||
--help Show this help.
|
||||
USAGE
|
||||
}
|
||||
|
||||
log() {
|
||||
printf '[yiacad-proofs] %s\n' "$*" | tee -a "${LOG_FILE}" >&2
|
||||
}
|
||||
|
||||
log_cmd() {
|
||||
log "cmd: $*"
|
||||
}
|
||||
|
||||
ensure_dirs() {
|
||||
mkdir -p "${ARTIFACTS_DIR}"
|
||||
LOG_FILE="${ARTIFACTS_DIR}/yiacad_proofs_tui_${STAMP}.log"
|
||||
touch "${LOG_FILE}"
|
||||
}
|
||||
|
||||
run_backend() {
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_backend_proof.sh" --action run
|
||||
bash "${ROOT_DIR}/tools/cockpit/yiacad_backend_proof.sh" --action run
|
||||
}
|
||||
|
||||
run_review_session() {
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-session
|
||||
bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-session
|
||||
}
|
||||
|
||||
run_review_history() {
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-history
|
||||
bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-history
|
||||
}
|
||||
|
||||
run_review_taxonomy() {
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-taxonomy
|
||||
bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-taxonomy
|
||||
}
|
||||
|
||||
render_status_text() {
|
||||
cat <<EOF2
|
||||
YiACAD proofs status
|
||||
- canonical_entry: bash tools/cockpit/yiacad_proofs_tui.sh --action status
|
||||
- backend_proof: bash tools/cockpit/yiacad_backend_proof.sh --action run
|
||||
- review_session: bash tools/cockpit/yiacad_uiux_tui.sh --action review-session
|
||||
- review_history: bash tools/cockpit/yiacad_uiux_tui.sh --action review-history
|
||||
- review_taxonomy: bash tools/cockpit/yiacad_uiux_tui.sh --action review-taxonomy
|
||||
- log_dir: ${ARTIFACTS_DIR}
|
||||
- note: surface canonique pour les preuves et l hygiene des logs, sans casser les alias historiques
|
||||
EOF2
|
||||
}
|
||||
|
||||
render_status_json() {
|
||||
cat <<EOF2
|
||||
{
|
||||
"component": "yiacad-proofs-tui",
|
||||
"timestamp": "${STAMP}",
|
||||
"canonical_entry": "bash tools/cockpit/yiacad_proofs_tui.sh --action status",
|
||||
"routes": {
|
||||
"backend": "bash tools/cockpit/yiacad_backend_proof.sh --action run",
|
||||
"review_session": "bash tools/cockpit/yiacad_uiux_tui.sh --action review-session",
|
||||
"review_history": "bash tools/cockpit/yiacad_uiux_tui.sh --action review-history",
|
||||
"review_taxonomy": "bash tools/cockpit/yiacad_uiux_tui.sh --action review-taxonomy"
|
||||
},
|
||||
"log_dir": "${ARTIFACTS_DIR}",
|
||||
"notes": [
|
||||
"surface canonique pour les preuves et l hygiene des logs",
|
||||
"les alias historiques peuvent continuer a pointer vers cette surface plus tard"
|
||||
]
|
||||
}
|
||||
EOF2
|
||||
}
|
||||
|
||||
logs_summary() {
|
||||
local count latest
|
||||
count="$(find "${ARTIFACTS_DIR}" -maxdepth 1 -type f -name '*.log' | wc -l | tr -d ' ')"
|
||||
latest="$(find "${ARTIFACTS_DIR}" -maxdepth 1 -type f -name '*.log' | sort | tail -n 1)"
|
||||
if [[ "${JSON}" == "true" ]]; then
|
||||
cat <<EOF2
|
||||
{
|
||||
"component": "yiacad-proofs-tui",
|
||||
"log_count": ${count:-0},
|
||||
"latest_log": "${latest}"
|
||||
}
|
||||
EOF2
|
||||
else
|
||||
printf 'YiACAD proofs logs\n'
|
||||
printf -- '- log_count: %s\n' "${count:-0}"
|
||||
printf -- '- latest_log: %s\n' "${latest:-none}"
|
||||
fi
|
||||
}
|
||||
|
||||
logs_list() {
|
||||
find "${ARTIFACTS_DIR}" -maxdepth 1 -type f -name '*.log' | sort
|
||||
}
|
||||
|
||||
logs_latest() {
|
||||
local latest
|
||||
latest="$(find "${ARTIFACTS_DIR}" -maxdepth 1 -type f -name '*.log' | sort | tail -n 1)"
|
||||
if [[ -n "${latest}" ]]; then
|
||||
cat "${latest}"
|
||||
fi
|
||||
}
|
||||
|
||||
purge_logs() {
|
||||
if [[ "${YES}" != "true" ]]; then
|
||||
printf 'Refusing to purge logs without --yes\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
find "${ARTIFACTS_DIR}" -maxdepth 1 -type f -name '*.log' -mtime "+${DAYS}" -delete
|
||||
printf 'Purged logs older than %s days from %s\n' "${DAYS}" "${ARTIFACTS_DIR}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--days)
|
||||
DAYS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
JSON="true"
|
||||
shift
|
||||
;;
|
||||
--yes)
|
||||
YES="true"
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
ensure_dirs
|
||||
|
||||
case "${ACTION}" in
|
||||
status)
|
||||
if [[ "${JSON}" == "true" ]]; then
|
||||
render_status_json
|
||||
else
|
||||
render_status_text
|
||||
fi
|
||||
;;
|
||||
backend)
|
||||
run_backend
|
||||
;;
|
||||
review-session)
|
||||
run_review_session
|
||||
;;
|
||||
review-history)
|
||||
run_review_history
|
||||
;;
|
||||
review-taxonomy)
|
||||
run_review_taxonomy
|
||||
;;
|
||||
logs-summary)
|
||||
logs_summary
|
||||
;;
|
||||
logs-list)
|
||||
logs_list
|
||||
;;
|
||||
logs-latest)
|
||||
logs_latest
|
||||
;;
|
||||
purge-logs)
|
||||
purge_logs
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown action: %s\n' "${ACTION}" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+284
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
ARTIFACTS_DIR="${ROOT_DIR}/artifacts/yiacad_refonte_tui"
|
||||
mkdir -p "${ARTIFACTS_DIR}"
|
||||
|
||||
ACTION=""
|
||||
JSON_MODE=0
|
||||
VERBOSE=0
|
||||
YES=0
|
||||
DAYS=14
|
||||
LINES=80
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: yiacad_refonte_tui.sh --action <status|backend-architecture|audit|ai-assessment|feature-map|spec|plan|todo|research|logs-summary|logs-list|logs-latest|purge-logs> [options]
|
||||
|
||||
Options:
|
||||
--action <name> Action to run
|
||||
--days <N> Retention window for purge-logs (default: 14)
|
||||
--lines <N> Number of lines for logs-latest (default: 80)
|
||||
--json Emit JSON for logs-summary
|
||||
--yes Confirm destructive purge in non-interactive mode
|
||||
--verbose Print executed commands
|
||||
--help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
have_tty() {
|
||||
[[ -t 0 && -t 1 ]]
|
||||
}
|
||||
|
||||
choose_action_interactive() {
|
||||
if command -v gum >/dev/null 2>&1 && have_tty; then
|
||||
gum choose \
|
||||
status \
|
||||
backend-architecture \
|
||||
audit \
|
||||
ai-assessment \
|
||||
feature-map \
|
||||
spec \
|
||||
plan \
|
||||
todo \
|
||||
research \
|
||||
logs-summary \
|
||||
logs-list \
|
||||
logs-latest \
|
||||
purge-logs
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
show_file() {
|
||||
local path="$1"
|
||||
if [[ -f "${path}" ]]; then
|
||||
cat "${path}"
|
||||
else
|
||||
printf 'missing file: %s\n' "${path}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
status_view() {
|
||||
cat <<EOF
|
||||
# YiACAD Global Refonte Status
|
||||
|
||||
- operator-index: ${ROOT_DIR}/tools/cockpit/yiacad_operator_index.sh
|
||||
- audit: ${ROOT_DIR}/docs/YIACAD_GLOBAL_REFACTOR_AUDIT_2026-03-20.md
|
||||
- ai-assessment: ${ROOT_DIR}/docs/YIACAD_GLOBAL_AI_INTEGRATION_ASSESSMENT_2026-03-20.md
|
||||
- feature-map: ${ROOT_DIR}/docs/YIACAD_GLOBAL_FEATURE_MAP_2026-03-20.md
|
||||
- backend-architecture: ${ROOT_DIR}/docs/YIACAD_BACKEND_ARCHITECTURE_2026-03-20.md
|
||||
- backend-service: ${ROOT_DIR}/docs/YIACAD_BACKEND_SERVICE_2026-03-21.md
|
||||
- spec: ${ROOT_DIR}/specs/yiacad_global_refonte_spec.md
|
||||
- plan: ${ROOT_DIR}/docs/plans/21_plan_refonte_globale_yiacad.md
|
||||
- todo: ${ROOT_DIR}/docs/plans/21_todo_refonte_globale_yiacad.md
|
||||
- research: ${ROOT_DIR}/docs/YIACAD_GLOBAL_OSS_RESEARCH_2026-03-20.md
|
||||
- operator-doc: ${ROOT_DIR}/docs/YIACAD_OPERATOR_INDEX_2026-03-21.md
|
||||
- next lot: T-UX-004 + T-RE-209
|
||||
EOF
|
||||
}
|
||||
|
||||
collect_log_files() {
|
||||
find "${ARTIFACTS_DIR}" -type f 2>/dev/null
|
||||
}
|
||||
|
||||
logs_list() {
|
||||
local found=0
|
||||
local path=""
|
||||
|
||||
while IFS= read -r path; do
|
||||
[[ -n "${path}" ]] || continue
|
||||
[[ "${path}" == "${LOG_FILE:-}" ]] && continue
|
||||
printf '%s\n' "${path}"
|
||||
found=1
|
||||
done < <(collect_log_files | sort)
|
||||
|
||||
if [[ "${found}" -eq 0 ]]; then
|
||||
if [[ -n "${LOG_FILE:-}" ]]; then
|
||||
printf '%s\n' "${LOG_FILE}"
|
||||
else
|
||||
printf 'no logs found\n'
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
logs_latest() {
|
||||
local latest=""
|
||||
local path=""
|
||||
|
||||
while IFS= read -r path; do
|
||||
[[ -n "${path}" ]] || continue
|
||||
[[ "${path}" == "${LOG_FILE:-}" ]] && continue
|
||||
latest="${path}"
|
||||
done < <(collect_log_files | sort)
|
||||
|
||||
if [[ -z "${latest}" ]]; then
|
||||
if [[ -n "${LOG_FILE:-}" ]]; then
|
||||
latest="${LOG_FILE}"
|
||||
else
|
||||
printf 'no logs found\n'
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '# Latest log\n\n'
|
||||
printf -- '- path: %s\n' "${latest}"
|
||||
printf -- '- lines: %s\n\n' "${LINES}"
|
||||
tail -n "${LINES}" "${latest}"
|
||||
}
|
||||
|
||||
logs_summary() {
|
||||
local log_count
|
||||
log_count="$(find "${ARTIFACTS_DIR}" -type f | wc -l | tr -d ' ')"
|
||||
|
||||
if [[ "${JSON_MODE}" -eq 1 ]]; then
|
||||
python3 - <<PY
|
||||
import json
|
||||
print(json.dumps({
|
||||
"status": "done",
|
||||
"log_files": int("${log_count}"),
|
||||
"artifacts_dir": "${ARTIFACTS_DIR}",
|
||||
"next_lot": "T-UX-004 + T-RE-209",
|
||||
}, ensure_ascii=False))
|
||||
PY
|
||||
return 0
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
# YiACAD global refonte logs summary
|
||||
|
||||
- log files: ${log_count}
|
||||
- artifacts dir: ${ARTIFACTS_DIR}
|
||||
- next lot: T-UX-004 + lot operateur YiACAD
|
||||
EOF
|
||||
}
|
||||
|
||||
purge_logs() {
|
||||
if [[ "${YES}" -ne 1 ]]; then
|
||||
if command -v gum >/dev/null 2>&1 && have_tty; then
|
||||
if ! gum confirm "Purger les logs YiACAD globaux de plus de ${DAYS} jours ?"; then
|
||||
printf 'purge cancelled\n'
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
printf 'Refusing purge without --yes outside interactive confirm\n' >&2
|
||||
return 2
|
||||
fi
|
||||
fi
|
||||
|
||||
find "${ARTIFACTS_DIR}" -type f -mtime +"${DAYS}" -delete
|
||||
printf 'purged yiacad refonte logs older than %s days in %s\n' "${DAYS}" "${ARTIFACTS_DIR}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--days)
|
||||
DAYS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--lines)
|
||||
LINES="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
JSON_MODE=1
|
||||
shift
|
||||
;;
|
||||
--yes)
|
||||
YES=1
|
||||
shift
|
||||
;;
|
||||
--verbose)
|
||||
VERBOSE=1
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "${ACTION}" ]]; then
|
||||
if ACTION="$(choose_action_interactive)"; then
|
||||
:
|
||||
else
|
||||
printf 'Missing --action (interactive selection unavailable)\n' >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! [[ "${DAYS}" =~ ^[0-9]+$ ]]; then
|
||||
printf -- '--days requires an integer\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! [[ "${LINES}" =~ ^[0-9]+$ ]]; then
|
||||
printf -- '--lines requires an integer\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
STAMP="$(date +%Y%m%d_%H%M%S)"
|
||||
LOG_FILE="${ARTIFACTS_DIR}/yiacad_refonte_tui_${STAMP}.log"
|
||||
exec > >(tee -a "${LOG_FILE}") 2>&1
|
||||
|
||||
printf '[yiacad-refonte-tui] action=%s timestamp=%s\n' "${ACTION}" "${STAMP}"
|
||||
|
||||
case "${ACTION}" in
|
||||
status)
|
||||
status_view
|
||||
;;
|
||||
backend-architecture)
|
||||
show_file "${ROOT_DIR}/docs/YIACAD_BACKEND_ARCHITECTURE_2026-03-20.md"
|
||||
;;
|
||||
audit)
|
||||
show_file "${ROOT_DIR}/docs/YIACAD_GLOBAL_REFACTOR_AUDIT_2026-03-20.md"
|
||||
;;
|
||||
ai-assessment)
|
||||
show_file "${ROOT_DIR}/docs/YIACAD_GLOBAL_AI_INTEGRATION_ASSESSMENT_2026-03-20.md"
|
||||
;;
|
||||
feature-map)
|
||||
show_file "${ROOT_DIR}/docs/YIACAD_GLOBAL_FEATURE_MAP_2026-03-20.md"
|
||||
;;
|
||||
spec)
|
||||
show_file "${ROOT_DIR}/specs/yiacad_global_refonte_spec.md"
|
||||
;;
|
||||
plan)
|
||||
show_file "${ROOT_DIR}/docs/plans/21_plan_refonte_globale_yiacad.md"
|
||||
;;
|
||||
todo)
|
||||
show_file "${ROOT_DIR}/docs/plans/21_todo_refonte_globale_yiacad.md"
|
||||
;;
|
||||
research)
|
||||
show_file "${ROOT_DIR}/docs/YIACAD_GLOBAL_OSS_RESEARCH_2026-03-20.md"
|
||||
;;
|
||||
logs-summary)
|
||||
logs_summary
|
||||
;;
|
||||
logs-list)
|
||||
logs_list
|
||||
;;
|
||||
logs-latest)
|
||||
logs_latest
|
||||
;;
|
||||
purge-logs)
|
||||
purge_logs
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown action: %s\n' "${ACTION}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Executable
+680
@@ -0,0 +1,680 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
ARTIFACTS_DIR="${ROOT_DIR}/artifacts/uiux_tui"
|
||||
mkdir -p "${ARTIFACTS_DIR}"
|
||||
|
||||
ACTION=""
|
||||
JSON_MODE=0
|
||||
VERBOSE=0
|
||||
YES=0
|
||||
DAYS=14
|
||||
LINES=80
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: yiacad_uiux_tui.sh --action <status|lane-status|owners|proofs|backend|backend-proof|review-session|review-history|review-taxonomy|review-context|audit|program-audit|feature-map|next-feature-map|plan|todo|research|next-spec|insertion-points|agent-matrix|logs-summary|logs-list|logs-latest|purge-logs> [options]
|
||||
|
||||
Options:
|
||||
--action <name> Action to run
|
||||
--days <N> Retention window for purge-logs (default: 14)
|
||||
--lines <N> Number of lines for logs-latest (default: 80)
|
||||
--json Emit JSON for status/logs-summary
|
||||
--yes Confirm destructive purge in non-interactive mode
|
||||
--verbose Print executed commands
|
||||
--help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
have_tty() {
|
||||
[[ -t 0 && -t 1 ]]
|
||||
}
|
||||
|
||||
choose_action_interactive() {
|
||||
if command -v gum >/dev/null 2>&1 && have_tty; then
|
||||
gum choose \
|
||||
status \
|
||||
lane-status \
|
||||
owners \
|
||||
proofs \
|
||||
backend \
|
||||
backend-proof \
|
||||
review-session \
|
||||
review-history \
|
||||
review-taxonomy \
|
||||
review-context \
|
||||
audit \
|
||||
program-audit \
|
||||
feature-map \
|
||||
next-feature-map \
|
||||
plan \
|
||||
todo \
|
||||
research \
|
||||
next-spec \
|
||||
insertion-points \
|
||||
agent-matrix \
|
||||
logs-summary \
|
||||
logs-list \
|
||||
logs-latest \
|
||||
purge-logs
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
log_cmd() {
|
||||
if [[ "${VERBOSE}" -eq 1 ]]; then
|
||||
printf '[cmd] %s\n' "$*"
|
||||
fi
|
||||
}
|
||||
|
||||
render_backend_status() {
|
||||
YIACAD_STATUS_JSON="$1" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
payload = json.loads(os.environ["YIACAD_STATUS_JSON"])
|
||||
service = payload.get("service") or {}
|
||||
artifacts = payload.get("artifacts") or []
|
||||
next_steps = payload.get("next_steps") or []
|
||||
|
||||
print("# YiACAD Backend Status")
|
||||
print("")
|
||||
print(f"- status: {payload.get('status', 'unknown')}")
|
||||
print(f"- severity: {payload.get('severity', 'unknown')}")
|
||||
print(f"- action: {payload.get('action', 'unknown')}")
|
||||
print(f"- execution_mode: {payload.get('execution_mode', 'unknown')}")
|
||||
print(f"- transport: service-first ({service.get('mode', 'fallback')})")
|
||||
print(f"- summary: {payload.get('summary', '')}")
|
||||
details = payload.get("details")
|
||||
if details:
|
||||
print(f"- details: {details}")
|
||||
|
||||
if artifacts:
|
||||
print("")
|
||||
print("## Artifacts")
|
||||
for artifact in artifacts:
|
||||
print(f"- {artifact.get('label', 'artifact')}: {artifact.get('path', '')}")
|
||||
|
||||
if next_steps:
|
||||
print("")
|
||||
print("## Next steps")
|
||||
for step in next_steps:
|
||||
print(f"- {step}")
|
||||
PY
|
||||
}
|
||||
|
||||
run_backend_status() {
|
||||
local payload=""
|
||||
local -a backend_cmd=()
|
||||
local -a native_cmd=()
|
||||
|
||||
if [[ -x "${ROOT_DIR}/tools/cad/yiacad_backend_client.py" ]]; then
|
||||
backend_cmd=(python3 "${ROOT_DIR}/tools/cad/yiacad_backend_client.py" --json-output status)
|
||||
log_cmd "${backend_cmd[@]}"
|
||||
if payload="$("${backend_cmd[@]}" 2>/dev/null)"; then
|
||||
if [[ "${JSON_MODE}" -eq 1 ]]; then
|
||||
printf '%s\n' "${payload}"
|
||||
else
|
||||
render_backend_status "${payload}"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -x "${ROOT_DIR}/tools/cad/yiacad_native_ops.py" ]]; then
|
||||
native_cmd=(python3 "${ROOT_DIR}/tools/cad/yiacad_native_ops.py" status)
|
||||
if [[ "${JSON_MODE}" -eq 1 ]]; then
|
||||
native_cmd+=(--json-output)
|
||||
fi
|
||||
log_cmd "${native_cmd[@]}"
|
||||
"${native_cmd[@]}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf 'yiacad_backend_client.py and yiacad_native_ops.py not found\n' >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
lane_status() {
|
||||
cat <<EOF
|
||||
# YiACAD lane status
|
||||
|
||||
- active_lots:
|
||||
- T-UX-004
|
||||
- T-UX-003
|
||||
- support_lane:
|
||||
- Support UI/UX Ops
|
||||
- next_blocking_lot:
|
||||
- T-UX-004
|
||||
- T-UX-003
|
||||
- blocker_reason:
|
||||
- le backend YiACAD passe maintenant en service-first jusque dans la TUI UI/UX et la preuve operateur est archivee
|
||||
- le prochain travail produit est la palette persistante, l inspector contextuel et la remontee vers les points d insertion natifs documentes
|
||||
- canon_write_set:
|
||||
- KiCad shell: pcbnew/toolbars_pcb_editor.cpp + eeschema/toolbars_sch_editor.cpp
|
||||
- KiCad control: board_editor_control.* + sch_editor_control.*
|
||||
- FreeCAD safe: yiacad_freecad_gui.py
|
||||
- FreeCAD next shell: MainWindow.cpp
|
||||
- no_touch:
|
||||
- common/eda_base_frame.cpp
|
||||
- src/Gui/DockWindowManager.cpp
|
||||
- src/Gui/ComboView.cpp
|
||||
- proofs:
|
||||
- docs/YIACAD_OPERATOR_INDEX_2026-03-21.md
|
||||
- docs/YIACAD_BACKEND_SERVICE_2026-03-21.md
|
||||
- docs/plans/20_plan_refonte_ui_ux_yiacad_apple_native.md
|
||||
- docs/plans/20_todo_refonte_ui_ux_yiacad_apple_native.md
|
||||
- docs/plans/21_plan_refonte_globale_yiacad.md
|
||||
- docs/plans/21_todo_refonte_globale_yiacad.md
|
||||
- docs/YIACAD_NATIVE_UI_INSERTION_POINTS_2026-03-20.md
|
||||
- .runtime-home/cad-ai-native-forks/kicad-ki/scripting/plugins/yiacad_kicad_plugin/yiacad_action.py
|
||||
- .runtime-home/cad-ai-native-forks/freecad-ki/src/Mod/YiACADWorkbench/yiacad_freecad_gui.py
|
||||
EOF
|
||||
}
|
||||
|
||||
owners_summary() {
|
||||
cat <<EOF
|
||||
# YiACAD owners summary
|
||||
|
||||
- T-UX-003A: CAD-UX / KiCad-Shell
|
||||
- T-UX-003B: CAD-UX / FreeCAD-Shell
|
||||
- T-UX-003C: CAD-UX / KiCad-Native
|
||||
- T-UX-003D: CAD-UX / FreeCAD-Native
|
||||
- T-UX-004A: CAD-UX / KiCad-Surface + FreeCAD-Surface
|
||||
- T-UX-004B: Doc-Research / Mermaid-Map + OSS-Watch
|
||||
- T-ARCH-101C: CAD-Native + AI-Integration
|
||||
- Support UI/UX Ops: SyncOps / TUI-Ops + Log-Guard
|
||||
|
||||
- matrix:
|
||||
- docs/AGENT_SPEC_MODULE_MATRIX_2026-03-20.md
|
||||
- docs/plans/12_plan_gestion_des_agents.md
|
||||
EOF
|
||||
}
|
||||
|
||||
proofs_summary() {
|
||||
cat <<EOF
|
||||
# YiACAD proofs
|
||||
|
||||
- plan:
|
||||
- docs/plans/20_plan_refonte_ui_ux_yiacad_apple_native.md
|
||||
- todo:
|
||||
- docs/plans/20_todo_refonte_ui_ux_yiacad_apple_native.md
|
||||
- output_contract:
|
||||
- docs/YIACAD_UIUX_OUTPUT_CONTRACT_2026-03-20.md
|
||||
- specs/contracts/yiacad_uiux_output.schema.json
|
||||
- specs/contracts/examples/yiacad_uiux_output.example.json
|
||||
- insertion_points:
|
||||
- docs/YIACAD_NATIVE_UI_INSERTION_POINTS_2026-03-20.md
|
||||
- feature_map:
|
||||
- docs/YIACAD_APPLE_UI_UX_FEATURE_MAP_2026-03-20.md
|
||||
- research:
|
||||
- docs/YIACAD_APPLE_UI_UX_OSS_RESEARCH_2026-03-20.md
|
||||
- agent_matrix:
|
||||
- docs/AGENT_SPEC_MODULE_MATRIX_2026-03-20.md
|
||||
- docs/plans/12_plan_gestion_des_agents.md
|
||||
- global_bundle:
|
||||
- docs/YIACAD_GLOBAL_REFACTOR_AUDIT_2026-03-20.md
|
||||
- docs/YIACAD_GLOBAL_AI_INTEGRATION_ASSESSMENT_2026-03-20.md
|
||||
- docs/plans/21_plan_refonte_globale_yiacad.md
|
||||
- docs/plans/21_todo_refonte_globale_yiacad.md
|
||||
- operator_entrypoint:
|
||||
- docs/YIACAD_OPERATOR_INDEX_2026-03-21.md
|
||||
- tools/cockpit/yiacad_operator_index.sh
|
||||
- backend:
|
||||
- docs/YIACAD_BACKEND_ARCHITECTURE_2026-03-20.md
|
||||
- docs/YIACAD_BACKEND_SERVICE_2026-03-21.md
|
||||
- specs/yiacad_backend_architecture_spec.md
|
||||
- specs/contracts/yiacad_context_broker.schema.json
|
||||
- specs/contracts/examples/yiacad_context_broker.example.json
|
||||
- backend_service:
|
||||
- tools/cockpit/yiacad_backend_service_tui.sh
|
||||
- backend_proof:
|
||||
- docs/YIACAD_BACKEND_OPERATOR_PROOF_2026-03-21.md
|
||||
- tools/cockpit/yiacad_backend_proof.sh
|
||||
- review_session:
|
||||
- docs/YIACAD_REVIEW_SESSION_2026-03-21.md
|
||||
- artifacts/cad-ai-native/latest_review_session.json
|
||||
- review_history:
|
||||
- docs/YIACAD_REVIEW_SESSION_2026-03-21.md
|
||||
- artifacts/cad-ai-native/review_history.json
|
||||
- review_context:
|
||||
- docs/YIACAD_REVIEW_SESSION_2026-03-21.md
|
||||
- tools/cockpit/yiacad_uiux_tui.sh --action review-context
|
||||
EOF
|
||||
}
|
||||
|
||||
review_session_view() {
|
||||
local session_file="${ROOT_DIR}/artifacts/cad-ai-native/latest_review_session.json"
|
||||
if [[ -f "${session_file}" ]]; then
|
||||
cat "${session_file}"
|
||||
else
|
||||
printf 'missing file: %s\n' "${session_file}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
review_history_view() {
|
||||
local history_file="${ROOT_DIR}/artifacts/cad-ai-native/review_history.json"
|
||||
if [[ -f "${history_file}" ]]; then
|
||||
cat "${history_file}"
|
||||
else
|
||||
printf 'missing file: %s\n' "${history_file}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
review_taxonomy_view() {
|
||||
local history_file="${ROOT_DIR}/artifacts/cad-ai-native/review_history.json"
|
||||
if [[ ! -f "${history_file}" ]]; then
|
||||
printf 'missing file: %s\n' "${history_file}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
python3 - <<PY
|
||||
import json
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
history = json.loads(Path("${history_file}").read_text(encoding="utf-8"))
|
||||
entries = history.get("entries") or []
|
||||
taxonomy = Counter((entry.get("taxonomy") or "unknown") for entry in entries)
|
||||
status = Counter((entry.get("status") or "unknown") for entry in entries)
|
||||
severity = Counter((entry.get("severity") or "unknown") for entry in entries)
|
||||
|
||||
print("# YiACAD review taxonomy")
|
||||
print()
|
||||
print(f"- entries: {len(entries)}")
|
||||
print("- taxonomy:")
|
||||
for key in sorted(taxonomy):
|
||||
print(f" - {key}: {taxonomy[key]}")
|
||||
print("- status:")
|
||||
for key in sorted(status):
|
||||
print(f" - {key}: {status[key]}")
|
||||
print("- severity:")
|
||||
for key in sorted(severity):
|
||||
print(f" - {key}: {severity[key]}")
|
||||
PY
|
||||
}
|
||||
|
||||
review_context_view() {
|
||||
local session_file="${ROOT_DIR}/artifacts/cad-ai-native/latest_review_session.json"
|
||||
local history_file="${ROOT_DIR}/artifacts/cad-ai-native/review_history.json"
|
||||
if [[ ! -f "${session_file}" ]]; then
|
||||
printf 'missing file: %s\n' "${session_file}"
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -f "${history_file}" ]]; then
|
||||
printf 'missing file: %s\n' "${history_file}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
python3 - <<PY
|
||||
import json
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
session = json.loads(Path("${session_file}").read_text(encoding="utf-8"))
|
||||
history = json.loads(Path("${history_file}").read_text(encoding="utf-8"))
|
||||
payload = session.get("payload") if isinstance(session.get("payload"), dict) else session
|
||||
entries = history.get("entries") or []
|
||||
taxonomy = Counter((entry.get("taxonomy") or "unknown") for entry in entries)
|
||||
status = Counter((entry.get("status") or "unknown") for entry in entries)
|
||||
|
||||
print("# YiACAD review context")
|
||||
print()
|
||||
print(f"- current_action: {payload.get('action') or 'unknown'}")
|
||||
print(f"- current_status: {payload.get('status') or 'unknown'}")
|
||||
print(f"- current_severity: {payload.get('severity') or 'unknown'}")
|
||||
print(f"- current_context: {payload.get('context_ref') or 'no-context'}")
|
||||
print(f"- current_summary: {(payload.get('summary') or '').strip() or '-'}")
|
||||
print("- taxonomy:")
|
||||
for key in sorted(taxonomy):
|
||||
print(f" - {key}: {taxonomy[key]}")
|
||||
print("- status_mix:")
|
||||
for key in sorted(status):
|
||||
print(f" - {key}: {status[key]}")
|
||||
print("- recent_trail:")
|
||||
for entry in entries[:3]:
|
||||
action = entry.get("action") or "unknown"
|
||||
entry_status = entry.get("status") or "unknown"
|
||||
severity = entry.get("severity") or "unknown"
|
||||
summary = (entry.get("summary") or "").strip() or "-"
|
||||
print(f" - {action} | {entry_status} | {severity} | {summary}")
|
||||
print("- next_steps:")
|
||||
seen = set()
|
||||
for entry in entries[:5]:
|
||||
for step in entry.get("next_steps") or []:
|
||||
if step not in seen:
|
||||
seen.add(step)
|
||||
print(f" - {step}")
|
||||
if not seen:
|
||||
print(" - none")
|
||||
PY
|
||||
}
|
||||
|
||||
review_context_view() {
|
||||
local session_file="${ROOT_DIR}/artifacts/cad-ai-native/latest_review_session.json"
|
||||
local history_file="${ROOT_DIR}/artifacts/cad-ai-native/review_history.json"
|
||||
if [[ ! -f "${session_file}" ]]; then
|
||||
printf 'missing file: %s\n' "${session_file}"
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -f "${history_file}" ]]; then
|
||||
printf 'missing file: %s\n' "${history_file}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
python3 - <<PY
|
||||
import json
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
session = json.loads(Path("${session_file}").read_text(encoding="utf-8"))
|
||||
history = json.loads(Path("${history_file}").read_text(encoding="utf-8"))
|
||||
entries = history.get("entries") or []
|
||||
payload = session.get("payload") if isinstance(session, dict) else {}
|
||||
if not isinstance(payload, dict) or not payload:
|
||||
payload = session if isinstance(session, dict) else {}
|
||||
head = payload
|
||||
action = str(head.get("action") or "").strip()
|
||||
if (not action or action == "unknown") and entries:
|
||||
first = entries[0]
|
||||
if isinstance(first, dict):
|
||||
head = first
|
||||
taxonomy = Counter((entry.get("taxonomy") or "unknown") for entry in entries)
|
||||
|
||||
print("# YiACAD review context")
|
||||
print()
|
||||
print(f"- command: {session.get('command') or head.get('action') or 'unknown'}")
|
||||
print(f"- context_ref: {head.get('context_ref') or 'no context'}")
|
||||
print(f"- status: {head.get('status') or 'unknown'}")
|
||||
print(f"- severity: {head.get('severity') or 'unknown'}")
|
||||
summary = str(head.get("summary") or "").strip()
|
||||
if summary:
|
||||
print(f"- summary: {summary}")
|
||||
print("- taxonomy:")
|
||||
for key in sorted(taxonomy):
|
||||
print(f" - {key}: {taxonomy[key]}")
|
||||
print("- recent:")
|
||||
for entry in entries[:4]:
|
||||
action = entry.get("action") or "unknown"
|
||||
status = entry.get("status") or "unknown"
|
||||
severity = entry.get("severity") or "unknown"
|
||||
context_ref = entry.get("context_ref") or "no context"
|
||||
summary = str(entry.get("summary") or "").strip()
|
||||
generated_at = entry.get("generated_at") or ""
|
||||
print(f" - {action} | {status} | {severity}")
|
||||
print(f" context: {context_ref}")
|
||||
if summary:
|
||||
print(f" summary: {summary}")
|
||||
if generated_at:
|
||||
print(f" generated_at: {generated_at}")
|
||||
PY
|
||||
}
|
||||
|
||||
show_file() {
|
||||
local path="$1"
|
||||
if [[ -f "${path}" ]]; then
|
||||
cat "${path}"
|
||||
else
|
||||
printf 'missing file: %s\n' "${path}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
collect_log_files() {
|
||||
find "${ARTIFACTS_DIR}" -type f 2>/dev/null
|
||||
if [[ -d "${ROOT_DIR}/artifacts/cad-ai-native" ]]; then
|
||||
find "${ROOT_DIR}/artifacts/cad-ai-native" -type f 2>/dev/null
|
||||
fi
|
||||
if [[ -d "${ROOT_DIR}/artifacts/yiacad_backend_service_tui" ]]; then
|
||||
find "${ROOT_DIR}/artifacts/yiacad_backend_service_tui" -type f 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
logs_list() {
|
||||
local found=0
|
||||
local path=""
|
||||
|
||||
while IFS= read -r path; do
|
||||
[[ -n "${path}" ]] || continue
|
||||
[[ "${path}" == "${LOG_FILE:-}" ]] && continue
|
||||
printf '%s\n' "${path}"
|
||||
found=1
|
||||
done < <(collect_log_files | sort)
|
||||
|
||||
if [[ "${found}" -eq 0 ]]; then
|
||||
if [[ -n "${LOG_FILE:-}" ]]; then
|
||||
printf '%s\n' "${LOG_FILE}"
|
||||
else
|
||||
printf 'no logs found\n'
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
logs_latest() {
|
||||
local latest=""
|
||||
local path=""
|
||||
|
||||
while IFS= read -r path; do
|
||||
[[ -n "${path}" ]] || continue
|
||||
[[ "${path}" == "${LOG_FILE:-}" ]] && continue
|
||||
latest="${path}"
|
||||
done < <(collect_log_files | sort)
|
||||
|
||||
if [[ -z "${latest}" ]]; then
|
||||
if [[ -n "${LOG_FILE:-}" ]]; then
|
||||
latest="${LOG_FILE}"
|
||||
else
|
||||
printf 'no logs found\n'
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '# Latest log\n\n'
|
||||
printf -- '- path: %s\n' "${latest}"
|
||||
printf -- '- lines: %s\n\n' "${LINES}"
|
||||
tail -n "${LINES}" "${latest}"
|
||||
}
|
||||
|
||||
logs_summary() {
|
||||
local uiux_count native_count backend_service_count
|
||||
uiux_count="$(find "${ARTIFACTS_DIR}" -type f | wc -l | tr -d ' ')"
|
||||
native_count="0"
|
||||
backend_service_count="0"
|
||||
if [[ -d "${ROOT_DIR}/artifacts/cad-ai-native" ]]; then
|
||||
native_count="$(find "${ROOT_DIR}/artifacts/cad-ai-native" -type f | wc -l | tr -d ' ')"
|
||||
fi
|
||||
if [[ -d "${ROOT_DIR}/artifacts/yiacad_backend_service_tui" ]]; then
|
||||
backend_service_count="$(find "${ROOT_DIR}/artifacts/yiacad_backend_service_tui" -type f | wc -l | tr -d ' ')"
|
||||
fi
|
||||
|
||||
if [[ "${JSON_MODE}" -eq 1 ]]; then
|
||||
python3 - <<PY
|
||||
import json
|
||||
print(json.dumps({
|
||||
"status": "done",
|
||||
"uiux_tui_files": int("${uiux_count}"),
|
||||
"cad_ai_native_files": int("${native_count}"),
|
||||
"backend_service_files": int("${backend_service_count}"),
|
||||
"artifacts_dir": "${ARTIFACTS_DIR}",
|
||||
}, ensure_ascii=False))
|
||||
PY
|
||||
return 0
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
# YiACAD UI/UX logs summary
|
||||
|
||||
- uiux_tui files: ${uiux_count}
|
||||
- cad-ai-native files: ${native_count}
|
||||
- backend service files: ${backend_service_count}
|
||||
- artifacts dir: ${ARTIFACTS_DIR}
|
||||
EOF
|
||||
}
|
||||
|
||||
purge_logs() {
|
||||
if [[ "${YES}" -ne 1 ]]; then
|
||||
if command -v gum >/dev/null 2>&1 && have_tty; then
|
||||
if ! gum confirm "Purger les logs UI/UX de plus de ${DAYS} jours ?"; then
|
||||
printf 'purge cancelled\n'
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
printf 'Refusing purge without --yes outside interactive confirm\n' >&2
|
||||
return 2
|
||||
fi
|
||||
fi
|
||||
|
||||
find "${ARTIFACTS_DIR}" -type f -mtime +"${DAYS}" -delete
|
||||
printf 'purged uiux logs older than %s days in %s\n' "${DAYS}" "${ARTIFACTS_DIR}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--days)
|
||||
DAYS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--lines)
|
||||
LINES="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
JSON_MODE=1
|
||||
shift
|
||||
;;
|
||||
--yes)
|
||||
YES=1
|
||||
shift
|
||||
;;
|
||||
--verbose)
|
||||
VERBOSE=1
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "${ACTION}" ]]; then
|
||||
if ACTION="$(choose_action_interactive)"; then
|
||||
:
|
||||
else
|
||||
printf 'Missing --action (interactive selection unavailable)\n' >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! [[ "${DAYS}" =~ ^[0-9]+$ ]]; then
|
||||
printf -- '--days requires an integer\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! [[ "${LINES}" =~ ^[0-9]+$ ]]; then
|
||||
printf -- '--lines requires an integer\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
STAMP="$(date +%Y%m%d_%H%M%S)"
|
||||
LOG_FILE="${ARTIFACTS_DIR}/yiacad_uiux_tui_${STAMP}.log"
|
||||
exec > >(tee -a "${LOG_FILE}") 2>&1
|
||||
|
||||
if [[ "${JSON_MODE}" -ne 1 ]]; then
|
||||
printf '[yiacad-uiux-tui] action=%s timestamp=%s\n' "${ACTION}" "${STAMP}"
|
||||
fi
|
||||
|
||||
case "${ACTION}" in
|
||||
status)
|
||||
run_backend_status
|
||||
;;
|
||||
lane-status)
|
||||
lane_status
|
||||
;;
|
||||
owners)
|
||||
owners_summary
|
||||
;;
|
||||
proofs)
|
||||
proofs_summary
|
||||
;;
|
||||
backend)
|
||||
show_file "${ROOT_DIR}/docs/YIACAD_BACKEND_SERVICE_2026-03-21.md"
|
||||
;;
|
||||
backend-proof)
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_backend_proof.sh" --action run
|
||||
bash "${ROOT_DIR}/tools/cockpit/yiacad_backend_proof.sh" --action run
|
||||
;;
|
||||
review-session)
|
||||
review_session_view
|
||||
;;
|
||||
review-history)
|
||||
review_history_view
|
||||
;;
|
||||
review-taxonomy)
|
||||
review_taxonomy_view
|
||||
;;
|
||||
review-context)
|
||||
review_context_view
|
||||
;;
|
||||
audit)
|
||||
show_file "${ROOT_DIR}/docs/YIACAD_APPLE_UI_UX_AUDIT_2026-03-20.md"
|
||||
;;
|
||||
program-audit)
|
||||
show_file "${ROOT_DIR}/docs/YIACAD_EXHAUSTIVE_REFOUNTE_AUDIT_2026-03-20.md"
|
||||
;;
|
||||
feature-map)
|
||||
show_file "${ROOT_DIR}/docs/YIACAD_APPLE_UI_UX_FEATURE_MAP_2026-03-20.md"
|
||||
;;
|
||||
next-feature-map)
|
||||
show_file "${ROOT_DIR}/docs/YIACAD_TUX004_FEATURE_MAP_2026-03-20.md"
|
||||
;;
|
||||
plan)
|
||||
show_file "${ROOT_DIR}/docs/plans/20_plan_refonte_ui_ux_yiacad_apple_native.md"
|
||||
;;
|
||||
todo)
|
||||
show_file "${ROOT_DIR}/docs/plans/20_todo_refonte_ui_ux_yiacad_apple_native.md"
|
||||
;;
|
||||
research)
|
||||
show_file "${ROOT_DIR}/docs/YIACAD_APPLE_UI_UX_OSS_RESEARCH_2026-03-20.md"
|
||||
;;
|
||||
next-spec)
|
||||
show_file "${ROOT_DIR}/specs/yiacad_tux004_orchestration_spec.md"
|
||||
;;
|
||||
insertion-points)
|
||||
show_file "${ROOT_DIR}/docs/YIACAD_NATIVE_UI_INSERTION_POINTS_2026-03-20.md"
|
||||
;;
|
||||
agent-matrix)
|
||||
log_cmd bash "${ROOT_DIR}/tools/cockpit/agent_matrix_tui.sh" --action summary
|
||||
bash "${ROOT_DIR}/tools/cockpit/agent_matrix_tui.sh" --action summary
|
||||
;;
|
||||
logs-summary)
|
||||
logs_summary
|
||||
;;
|
||||
logs-list)
|
||||
logs_list
|
||||
;;
|
||||
logs-latest)
|
||||
logs_latest
|
||||
;;
|
||||
purge-logs)
|
||||
purge_logs
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown action: %s\n' "${ACTION}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,50 @@
|
||||
# T-MA-021 — Benchmark Multi-Provider Mascarade
|
||||
|
||||
Benchmark comparatif base models vs fine-tuned sur prompts métier électronique.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
evals/
|
||||
├── benchmark_providers.py # Runner principal (Mistral, Anthropic, OpenAI)
|
||||
├── prompts/
|
||||
│ └── metier_100_template.jsonl # 20 prompts template (à étendre à 100)
|
||||
└── results/ # Résultats des runs (gitignored)
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Dry run (pas d'appels API)
|
||||
python benchmark_providers.py --prompts prompts/metier_100_template.jsonl --dry-run
|
||||
|
||||
# Run Mistral uniquement
|
||||
python benchmark_providers.py --prompts prompts/metier_100_template.jsonl --providers mistral
|
||||
|
||||
# Run complet 3 providers
|
||||
python benchmark_providers.py --prompts prompts/metier_100_template.jsonl --providers mistral,anthropic,openai
|
||||
|
||||
# Avec rate limiting adapté au free tier Anthropic (5 RPM)
|
||||
python benchmark_providers.py --prompts prompts/metier_100_template.jsonl --providers anthropic --rate-limit 13
|
||||
```
|
||||
|
||||
## Format prompts (JSONL)
|
||||
|
||||
```json
|
||||
{"id": "K001", "domain": "kicad", "prompt": "...", "system": "...", "difficulty": "medium"}
|
||||
```
|
||||
|
||||
## Domaines couverts
|
||||
|
||||
- **KiCad** (K001-K040): Schéma, PCB, DRC, symboles, empreintes
|
||||
- **SPICE** (S001-S030): Simulation, netlists, analyse AC/DC/transitoire
|
||||
- **Embedded** (E001-E020): Firmware STM32/ESP32, drivers, protocoles
|
||||
- **Mixed** (M001-M010): Architecture hardware+firmware complète
|
||||
|
||||
## Env vars
|
||||
|
||||
```bash
|
||||
export MISTRAL_API_KEY="708zM4biF4WjZIAROJh2roFiAPK9O7kG"
|
||||
export ANTHROPIC_API_KEY="sk-ant-api03-..."
|
||||
export OPENAI_API_KEY="sk-proj-..."
|
||||
```
|
||||
Executable
+373
@@ -0,0 +1,373 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
T-MA-021 — Benchmark comparatif multi-provider Mascarade
|
||||
=========================================================
|
||||
Compare base models vs fine-tuned sur 100 prompts métier (KiCad, SPICE, embedded).
|
||||
|
||||
Providers testés:
|
||||
- Mistral (mistral-small-latest, codestral-latest, + fine-tuned LoRA)
|
||||
- Anthropic (claude-sonnet-4-20250514, claude-haiku-4-5-20251001)
|
||||
- OpenAI (gpt-4o, gpt-4o-mini)
|
||||
|
||||
Usage:
|
||||
python benchmark_providers.py --prompts prompts/kicad_100.jsonl --output results/
|
||||
python benchmark_providers.py --prompts prompts/spice_100.jsonl --output results/ --providers mistral,anthropic
|
||||
python benchmark_providers.py --prompts prompts/all_100.jsonl --output results/ --dry-run
|
||||
|
||||
Env vars requis:
|
||||
MISTRAL_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, asdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# --- Provider abstractions ---
|
||||
|
||||
@dataclass
|
||||
class BenchmarkResult:
|
||||
prompt_id: str
|
||||
provider: str
|
||||
model: str
|
||||
prompt: str
|
||||
response: str
|
||||
latency_ms: float
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
error: Optional[str] = None
|
||||
score: Optional[float] = None # filled by evaluator
|
||||
|
||||
|
||||
class ProviderClient:
|
||||
"""Base class for provider API calls."""
|
||||
|
||||
def complete(self, prompt: str, system: str = "", max_tokens: int = 1024) -> dict:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class MistralClient(ProviderClient):
|
||||
def __init__(self, model: str = "mistral-small-latest"):
|
||||
from mistralai import Mistral
|
||||
self.client = Mistral(api_key=os.environ["MISTRAL_API_KEY"])
|
||||
self.model = model
|
||||
self.name = "mistral"
|
||||
|
||||
def complete(self, prompt: str, system: str = "", max_tokens: int = 1024) -> dict:
|
||||
messages = []
|
||||
if system:
|
||||
messages.append({"role": "system", "content": system})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
t0 = time.time()
|
||||
resp = self.client.chat.complete(
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
latency = (time.time() - t0) * 1000
|
||||
|
||||
choice = resp.choices[0]
|
||||
return {
|
||||
"response": choice.message.content,
|
||||
"latency_ms": latency,
|
||||
"input_tokens": resp.usage.prompt_tokens,
|
||||
"output_tokens": resp.usage.completion_tokens,
|
||||
}
|
||||
|
||||
|
||||
class AnthropicClient(ProviderClient):
|
||||
def __init__(self, model: str = "claude-sonnet-4-20250514"):
|
||||
import anthropic
|
||||
self.client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
|
||||
self.model = model
|
||||
self.name = "anthropic"
|
||||
|
||||
def complete(self, prompt: str, system: str = "", max_tokens: int = 1024) -> dict:
|
||||
t0 = time.time()
|
||||
resp = self.client.messages.create(
|
||||
model=self.model,
|
||||
max_tokens=max_tokens,
|
||||
system=system if system else "You are a helpful electronics engineering assistant.",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
latency = (time.time() - t0) * 1000
|
||||
|
||||
return {
|
||||
"response": resp.content[0].text,
|
||||
"latency_ms": latency,
|
||||
"input_tokens": resp.usage.input_tokens,
|
||||
"output_tokens": resp.usage.output_tokens,
|
||||
}
|
||||
|
||||
|
||||
class OpenAIClient(ProviderClient):
|
||||
def __init__(self, model: str = "gpt-4o"):
|
||||
from openai import OpenAI
|
||||
self.client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
|
||||
self.model = model
|
||||
self.name = "openai"
|
||||
|
||||
def complete(self, prompt: str, system: str = "", max_tokens: int = 1024) -> dict:
|
||||
messages = []
|
||||
if system:
|
||||
messages.append({"role": "system", "content": system})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
t0 = time.time()
|
||||
resp = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
latency = (time.time() - t0) * 1000
|
||||
|
||||
choice = resp.choices[0]
|
||||
return {
|
||||
"response": choice.message.content,
|
||||
"latency_ms": latency,
|
||||
"input_tokens": resp.usage.prompt_tokens,
|
||||
"output_tokens": resp.usage.completion_tokens,
|
||||
}
|
||||
|
||||
|
||||
# --- Benchmark runner ---
|
||||
|
||||
PROVIDER_CONFIGS = {
|
||||
"mistral": [
|
||||
("mistral-small-latest", "base"),
|
||||
("codestral-latest", "base"),
|
||||
# ("ft:mistral-small:kicad-v1", "fine-tuned"), # décommenter après fine-tune
|
||||
],
|
||||
"anthropic": [
|
||||
("claude-sonnet-4-20250514", "base"),
|
||||
("claude-haiku-4-5-20251001", "base"),
|
||||
],
|
||||
"openai": [
|
||||
("gpt-4o", "base"),
|
||||
("gpt-4o-mini", "base"),
|
||||
],
|
||||
}
|
||||
|
||||
CLIENT_CLASSES = {
|
||||
"mistral": MistralClient,
|
||||
"anthropic": AnthropicClient,
|
||||
"openai": OpenAIClient,
|
||||
}
|
||||
|
||||
|
||||
def load_prompts(path: str) -> list[dict]:
|
||||
"""Load prompts from JSONL. Format: {"id": "P001", "prompt": "...", "system": "...", "expected": "..."}"""
|
||||
prompts = []
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
prompts.append(json.loads(line))
|
||||
return prompts
|
||||
|
||||
|
||||
def run_benchmark(
|
||||
prompts: list[dict],
|
||||
providers: list[str],
|
||||
output_dir: Path,
|
||||
max_tokens: int = 1024,
|
||||
dry_run: bool = False,
|
||||
rate_limit_delay: float = 1.0,
|
||||
) -> list[BenchmarkResult]:
|
||||
"""Run all prompts against all provider/model combos."""
|
||||
|
||||
results = []
|
||||
total = sum(len(PROVIDER_CONFIGS.get(p, [])) for p in providers) * len(prompts)
|
||||
current = 0
|
||||
|
||||
for provider_name in providers:
|
||||
configs = PROVIDER_CONFIGS.get(provider_name, [])
|
||||
if not configs:
|
||||
print(f" [SKIP] Provider inconnu: {provider_name}")
|
||||
continue
|
||||
|
||||
for model_id, model_type in configs:
|
||||
print(f"\n{'='*60}")
|
||||
print(f" Provider: {provider_name} | Model: {model_id} ({model_type})")
|
||||
print(f"{'='*60}")
|
||||
|
||||
try:
|
||||
client = CLIENT_CLASSES[provider_name](model=model_id)
|
||||
except Exception as e:
|
||||
print(f" [ERROR] Init client: {e}")
|
||||
continue
|
||||
|
||||
for prompt_data in prompts:
|
||||
current += 1
|
||||
pid = prompt_data.get("id", f"P{current:03d}")
|
||||
prompt_text = prompt_data["prompt"]
|
||||
system_text = prompt_data.get("system", "")
|
||||
|
||||
print(f" [{current}/{total}] {pid}...", end=" ", flush=True)
|
||||
|
||||
if dry_run:
|
||||
print("[DRY RUN]")
|
||||
results.append(BenchmarkResult(
|
||||
prompt_id=pid, provider=provider_name, model=model_id,
|
||||
prompt=prompt_text[:100], response="[dry run]",
|
||||
latency_ms=0, input_tokens=0, output_tokens=0,
|
||||
))
|
||||
continue
|
||||
|
||||
try:
|
||||
resp = client.complete(prompt_text, system=system_text, max_tokens=max_tokens)
|
||||
result = BenchmarkResult(
|
||||
prompt_id=pid,
|
||||
provider=provider_name,
|
||||
model=model_id,
|
||||
prompt=prompt_text[:200],
|
||||
response=resp["response"][:500],
|
||||
latency_ms=round(resp["latency_ms"], 1),
|
||||
input_tokens=resp["input_tokens"],
|
||||
output_tokens=resp["output_tokens"],
|
||||
)
|
||||
print(f"OK ({resp['latency_ms']:.0f}ms, {resp['output_tokens']} tokens)")
|
||||
except Exception as e:
|
||||
result = BenchmarkResult(
|
||||
prompt_id=pid,
|
||||
provider=provider_name,
|
||||
model=model_id,
|
||||
prompt=prompt_text[:200],
|
||||
response="",
|
||||
latency_ms=0,
|
||||
input_tokens=0,
|
||||
output_tokens=0,
|
||||
error=str(e),
|
||||
)
|
||||
print(f"ERROR: {e}")
|
||||
|
||||
results.append(result)
|
||||
time.sleep(rate_limit_delay)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def save_results(results: list[BenchmarkResult], output_dir: Path, run_name: str):
|
||||
"""Save results as JSONL + summary."""
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Raw results
|
||||
results_file = output_dir / f"{run_name}_results.jsonl"
|
||||
with open(results_file, "w") as f:
|
||||
for r in results:
|
||||
f.write(json.dumps(asdict(r), ensure_ascii=False) + "\n")
|
||||
print(f"\nResults saved: {results_file}")
|
||||
|
||||
# Summary
|
||||
summary = generate_summary(results)
|
||||
summary_file = output_dir / f"{run_name}_summary.json"
|
||||
with open(summary_file, "w") as f:
|
||||
json.dump(summary, f, indent=2, ensure_ascii=False)
|
||||
print(f"Summary saved: {summary_file}")
|
||||
|
||||
# Print summary
|
||||
print_summary(summary)
|
||||
|
||||
|
||||
def generate_summary(results: list[BenchmarkResult]) -> dict:
|
||||
"""Generate benchmark summary with per-model stats."""
|
||||
from collections import defaultdict
|
||||
|
||||
stats = defaultdict(lambda: {
|
||||
"count": 0, "errors": 0,
|
||||
"total_latency": 0, "total_input_tokens": 0, "total_output_tokens": 0,
|
||||
"latencies": [],
|
||||
})
|
||||
|
||||
for r in results:
|
||||
key = f"{r.provider}/{r.model}"
|
||||
s = stats[key]
|
||||
s["count"] += 1
|
||||
if r.error:
|
||||
s["errors"] += 1
|
||||
else:
|
||||
s["total_latency"] += r.latency_ms
|
||||
s["total_input_tokens"] += r.input_tokens
|
||||
s["total_output_tokens"] += r.output_tokens
|
||||
s["latencies"].append(r.latency_ms)
|
||||
|
||||
summary = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"total_prompts": len(set(r.prompt_id for r in results)),
|
||||
"total_calls": len(results),
|
||||
"models": {},
|
||||
}
|
||||
|
||||
for key, s in stats.items():
|
||||
success = s["count"] - s["errors"]
|
||||
latencies = sorted(s["latencies"])
|
||||
summary["models"][key] = {
|
||||
"calls": s["count"],
|
||||
"success": success,
|
||||
"errors": s["errors"],
|
||||
"avg_latency_ms": round(s["total_latency"] / max(success, 1), 1),
|
||||
"p50_latency_ms": round(latencies[len(latencies)//2], 1) if latencies else 0,
|
||||
"p95_latency_ms": round(latencies[int(len(latencies)*0.95)], 1) if latencies else 0,
|
||||
"total_input_tokens": s["total_input_tokens"],
|
||||
"total_output_tokens": s["total_output_tokens"],
|
||||
"avg_output_tokens": round(s["total_output_tokens"] / max(success, 1), 1),
|
||||
}
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
def print_summary(summary: dict):
|
||||
"""Pretty-print benchmark summary."""
|
||||
print(f"\n{'='*80}")
|
||||
print(f" BENCHMARK SUMMARY — {summary['timestamp']}")
|
||||
print(f" {summary['total_prompts']} prompts × {len(summary['models'])} models = {summary['total_calls']} calls")
|
||||
print(f"{'='*80}")
|
||||
print(f"{'Model':<40} {'OK':>4} {'Err':>4} {'Avg ms':>8} {'P95 ms':>8} {'Avg tok':>8}")
|
||||
print(f"{'-'*80}")
|
||||
|
||||
for model, s in sorted(summary["models"].items()):
|
||||
print(f"{model:<40} {s['success']:>4} {s['errors']:>4} {s['avg_latency_ms']:>8.0f} {s['p95_latency_ms']:>8.0f} {s['avg_output_tokens']:>8.0f}")
|
||||
|
||||
print(f"{'='*80}")
|
||||
|
||||
|
||||
# --- CLI ---
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="T-MA-021 Benchmark multi-provider Mascarade")
|
||||
parser.add_argument("--prompts", required=True, help="Path to prompts JSONL file")
|
||||
parser.add_argument("--output", default="results/", help="Output directory")
|
||||
parser.add_argument("--providers", default="mistral,anthropic,openai", help="Comma-separated providers")
|
||||
parser.add_argument("--max-tokens", type=int, default=1024, help="Max output tokens")
|
||||
parser.add_argument("--rate-limit", type=float, default=1.0, help="Delay between calls (seconds)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Don't make actual API calls")
|
||||
parser.add_argument("--run-name", default=None, help="Run name for output files")
|
||||
args = parser.parse_args()
|
||||
|
||||
providers = [p.strip() for p in args.providers.split(",")]
|
||||
prompts = load_prompts(args.prompts)
|
||||
run_name = args.run_name or f"bench_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
|
||||
print(f"Benchmark: {len(prompts)} prompts × providers {providers}")
|
||||
print(f"Output: {args.output}/{run_name}_*")
|
||||
|
||||
results = run_benchmark(
|
||||
prompts=prompts,
|
||||
providers=providers,
|
||||
output_dir=Path(args.output),
|
||||
max_tokens=args.max_tokens,
|
||||
dry_run=args.dry_run,
|
||||
rate_limit_delay=args.rate_limit,
|
||||
)
|
||||
|
||||
save_results(results, Path(args.output), run_name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,20 @@
|
||||
{"id": "K001", "domain": "kicad", "prompt": "Génère un schéma KiCad (netlist format) pour un circuit de protection ESD avec une TVS bidirectionnelle SMAJ5.0CA sur une ligne USB 2.0.", "system": "Tu es un ingénieur électronique spécialisé en conception de circuits imprimés avec KiCad.", "difficulty": "medium"}
|
||||
{"id": "K002", "domain": "kicad", "prompt": "Explique comment créer une empreinte custom pour un connecteur FPC 0.5mm pitch 40 broches dans KiCad 8. Donne le code Python pour le scripting.", "system": "Tu es un ingénieur électronique spécialisé en conception de circuits imprimés avec KiCad.", "difficulty": "hard"}
|
||||
{"id": "K003", "domain": "kicad", "prompt": "Review ce schéma KiCad : un régulateur LDO AMS1117-3.3 alimenté en 5V USB. Identifie les erreurs potentielles de découplage et propose des corrections.", "system": "Tu es un ingénieur électronique spécialisé en conception de circuits imprimés avec KiCad.", "difficulty": "medium"}
|
||||
{"id": "K004", "domain": "kicad", "prompt": "Génère les règles DRC pour un PCB 4 couches avec impédance contrôlée 50 ohms sur les pistes RF 2.4GHz. Format KiCad DRC rules.", "system": "Tu es un ingénieur électronique spécialisé en conception de circuits imprimés avec KiCad.", "difficulty": "hard"}
|
||||
{"id": "K005", "domain": "kicad", "prompt": "Crée un symbole KiCad pour un microcontrôleur STM32H743VIT6 avec tous les pins groupés par fonction (GPIO, ADC, SPI, I2C, USB, etc.).", "system": "Tu es un ingénieur électronique spécialisé en conception de circuits imprimés avec KiCad.", "difficulty": "hard"}
|
||||
{"id": "S001", "domain": "spice", "prompt": "Écris une netlist SPICE pour simuler un filtre passe-bas Butterworth 4ème ordre avec une fréquence de coupure de 1kHz utilisant des ampli-ops TL072.", "system": "Tu es un ingénieur en simulation SPICE et conception analogique.", "difficulty": "medium"}
|
||||
{"id": "S002", "domain": "spice", "prompt": "Simule en SPICE le comportement transitoire d'un convertisseur buck DC-DC (LM2596) passant de 12V à 5V/3A. Inclus le modèle de l'inductance et du condensateur de sortie.", "system": "Tu es un ingénieur en simulation SPICE et conception analogique.", "difficulty": "hard"}
|
||||
{"id": "S003", "domain": "spice", "prompt": "Analyse AC d'un amplificateur audio classe AB push-pull avec transistors BD139/BD140. Trace le diagramme de Bode (gain et phase) de 20Hz à 20kHz.", "system": "Tu es un ingénieur en simulation SPICE et conception analogique.", "difficulty": "medium"}
|
||||
{"id": "S004", "domain": "spice", "prompt": "Modélise en SPICE un circuit de charge de batterie Li-Ion (CC-CV) avec un contrôleur MCP73831. Simule le profil de charge complet.", "system": "Tu es un ingénieur en simulation SPICE et conception analogique.", "difficulty": "hard"}
|
||||
{"id": "S005", "domain": "spice", "prompt": "Crée un modèle SPICE paramétrique pour une LED RGB WS2812B incluant le comportement I-V et le spectre d'émission simplifié.", "system": "Tu es un ingénieur en simulation SPICE et conception analogique.", "difficulty": "hard"}
|
||||
{"id": "E001", "domain": "embedded", "prompt": "Écris le driver SPI pour un capteur IMU MPU6050 sur STM32F4 en C. Inclus l'initialisation, la lecture des 6 axes, et le DMA.", "system": "Tu es un développeur firmware embarqué spécialisé STM32 et ESP32.", "difficulty": "medium"}
|
||||
{"id": "E002", "domain": "embedded", "prompt": "Implémente un protocole de communication custom sur UART entre un ESP32 et un STM32 avec CRC16, framing, et retransmission automatique.", "system": "Tu es un développeur firmware embarqué spécialisé STM32 et ESP32.", "difficulty": "hard"}
|
||||
{"id": "E003", "domain": "embedded", "prompt": "Écris un driver I2S pour un DAC audio PCM5102A sur ESP32-S3 avec double buffering DMA. Supporte 44.1kHz 16bit stéréo.", "system": "Tu es un développeur firmware embarqué spécialisé STM32 et ESP32.", "difficulty": "hard"}
|
||||
{"id": "E004", "domain": "embedded", "prompt": "Configure le système de power management d'un ESP32 pour un capteur IoT sur batterie : deep sleep, wake-up timer 5min, RTC memory pour compteur.", "system": "Tu es un développeur firmware embarqué spécialisé STM32 et ESP32.", "difficulty": "medium"}
|
||||
{"id": "E005", "domain": "embedded", "prompt": "Implémente un bootloader OTA sécurisé pour STM32H7 avec signature ECDSA, rollback automatique, et dual bank flash.", "system": "Tu es un développeur firmware embarqué spécialisé STM32 et ESP32.", "difficulty": "hard"}
|
||||
{"id": "M001", "domain": "mixed", "prompt": "Conçois l'architecture complète (schéma + firmware) d'un contrôleur LED DMX512 basé sur ESP32 avec 4 univers DMX, interface web, et artnet.", "system": "Tu es un ingénieur électronique full-stack (hardware + firmware).", "difficulty": "hard"}
|
||||
{"id": "M002", "domain": "mixed", "prompt": "Propose un design de PCB pour une carte d'acquisition audio 4 canaux 24bit/96kHz basée sur un codec CS4270 et un STM32H743.", "system": "Tu es un ingénieur électronique full-stack (hardware + firmware).", "difficulty": "hard"}
|
||||
{"id": "M003", "domain": "mixed", "prompt": "Analyse les causes possibles de bruit sur un bus I2C longue distance (2m) entre un ESP32 et 8 capteurs de température. Propose des solutions hardware et software.", "system": "Tu es un ingénieur électronique full-stack (hardware + firmware).", "difficulty": "medium"}
|
||||
{"id": "M004", "domain": "mixed", "prompt": "Conçois un circuit de mesure de courant haute précision (0-30A) avec un shunt + INA226 pour un BMS de batterie Li-Ion 48V. Inclus la protection et le filtrage.", "system": "Tu es un ingénieur électronique full-stack (hardware + firmware).", "difficulty": "medium"}
|
||||
{"id": "M005", "domain": "mixed", "prompt": "Crée un test bench automatisé en Python pour valider la conformité EMC d'une carte ESP32 : test de rayonnement, immunité, et ESD selon EN 61000.", "system": "Tu es un ingénieur électronique full-stack (hardware + firmware).", "difficulty": "hard"}
|
||||
@@ -26,13 +26,45 @@ MASCARADE_CORE_DIR = MASCARADE_DIR / "core"
|
||||
if str(MASCARADE_CORE_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(MASCARADE_CORE_DIR))
|
||||
|
||||
from mascarade.integrations.github_dispatch import ( # noqa: E402
|
||||
DEFAULT_GITHUB_REPO,
|
||||
GitHubDispatchAuthError,
|
||||
GitHubDispatchClient,
|
||||
GitHubDispatchError,
|
||||
list_allowlisted_workflows,
|
||||
)
|
||||
GITHUB_DISPATCH_IMPORT_ERROR: str | None = None
|
||||
|
||||
try:
|
||||
from mascarade.integrations.github_dispatch import ( # noqa: E402
|
||||
DEFAULT_GITHUB_REPO,
|
||||
GitHubDispatchAuthError,
|
||||
GitHubDispatchClient,
|
||||
GitHubDispatchError,
|
||||
list_allowlisted_workflows,
|
||||
)
|
||||
except ModuleNotFoundError as exc: # pragma: no cover - exercised through smoke flow
|
||||
GITHUB_DISPATCH_IMPORT_ERROR = str(exc)
|
||||
DEFAULT_GITHUB_REPO = os.getenv("GITHUB_DISPATCH_REPO", "electron-rare/Kill_LIFE")
|
||||
|
||||
class GitHubDispatchError(RuntimeError):
|
||||
pass
|
||||
|
||||
class GitHubDispatchAuthError(GitHubDispatchError):
|
||||
pass
|
||||
|
||||
class GitHubDispatchClient:
|
||||
async def dispatch_workflow(self, workflow_file: str, ref: str | None = None, inputs: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
raise GitHubDispatchError(
|
||||
f"Mascarade github_dispatch integration unavailable: {GITHUB_DISPATCH_IMPORT_ERROR}"
|
||||
)
|
||||
|
||||
async def get_dispatch_status(self, dispatch_id: str) -> dict[str, Any]:
|
||||
raise GitHubDispatchError(
|
||||
f"Mascarade github_dispatch integration unavailable: {GITHUB_DISPATCH_IMPORT_ERROR}"
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
def list_allowlisted_workflows() -> list[str]:
|
||||
workflows_dir = ROOT / ".github" / "workflows"
|
||||
if not workflows_dir.is_dir():
|
||||
return ["repo_state.yml"]
|
||||
return sorted(path.name for path in workflows_dir.glob("*.yml"))
|
||||
|
||||
|
||||
TOOLS = [
|
||||
|
||||
+61
-11
@@ -47,7 +47,22 @@ die() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
docker_available() {
|
||||
command -v docker >/dev/null 2>&1 || return 1
|
||||
if [[ -n "${DOCKER_HOST:-}" ]]; then
|
||||
if [[ "$DOCKER_HOST" == unix://* ]]; then
|
||||
local socket_path
|
||||
socket_path="${DOCKER_HOST#unix://}"
|
||||
[ -S "$socket_path" ] || return 1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
[ -S "/Users/electron/.docker/run/docker.sock" ] || [ -S "/var/run/docker.sock" ] || [ -S "${HOME}/.docker/run/docker.sock" ] || return 1
|
||||
}
|
||||
|
||||
compose() {
|
||||
docker_available || die "Docker is required for this operation (compose unavailable)"
|
||||
debug "docker compose -f $COMPOSE_FILE $*"
|
||||
docker compose -f "$COMPOSE_FILE" "$@"
|
||||
}
|
||||
@@ -102,6 +117,10 @@ resolve_host_openscad() {
|
||||
|
||||
ensure_service_up() {
|
||||
local service="$1"
|
||||
if ! docker_available; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! compose ps --status running --services | grep -qx "$service"; then
|
||||
log "Starting $service"
|
||||
compose up -d "$service"
|
||||
@@ -191,25 +210,42 @@ doctor_cmd() {
|
||||
local host_kicad=""
|
||||
local host_freecad=""
|
||||
local host_openscad=""
|
||||
local host_platformio=""
|
||||
local degraded=0
|
||||
|
||||
host_kicad="$(resolve_host_kicad_cli)"
|
||||
host_freecad="$(resolve_host_freecad_cmd)"
|
||||
host_openscad="$(resolve_host_openscad)"
|
||||
host_platformio="$(command -v platformio 2>/dev/null || true)"
|
||||
|
||||
if [ -z "$host_kicad" ]; then
|
||||
ensure_service_up kicad-headless
|
||||
if docker_available; then
|
||||
ensure_service_up kicad-headless
|
||||
else
|
||||
log "WARN: kicad-cli non trouvé et docker indisponible, check kicad ignoré"
|
||||
degraded=1
|
||||
fi
|
||||
fi
|
||||
if [ -z "$host_freecad" ]; then
|
||||
ensure_service_up freecad-headless
|
||||
if docker_available; then
|
||||
ensure_service_up freecad-headless
|
||||
else
|
||||
log "WARN: freecadcmd non trouvé et docker indisponible, check freecad ignoré"
|
||||
degraded=1
|
||||
fi
|
||||
fi
|
||||
if [ -z "$host_openscad" ]; then
|
||||
ensure_service_up openscad-headless
|
||||
if docker_available; then
|
||||
ensure_service_up openscad-headless
|
||||
else
|
||||
log "WARN: openscad non trouvé et docker indisponible, check openscad ignoré"
|
||||
degraded=1
|
||||
fi
|
||||
fi
|
||||
ensure_service_up platformio
|
||||
|
||||
if [ -n "$host_kicad" ]; then
|
||||
"$host_kicad" version
|
||||
else
|
||||
elif docker_available; then
|
||||
run_shell_as_host_user \
|
||||
kicad-headless \
|
||||
/workspace/.cad-home/kicad-headless \
|
||||
@@ -218,7 +254,7 @@ doctor_cmd() {
|
||||
|
||||
if [ -n "$host_freecad" ]; then
|
||||
"$host_freecad" -c 'import FreeCAD; print(".".join(FreeCAD.Version()[:3]))'
|
||||
else
|
||||
elif docker_available; then
|
||||
run_shell_in_service_user \
|
||||
freecad-headless \
|
||||
/workspace/.cad-home/freecad-headless \
|
||||
@@ -227,17 +263,31 @@ doctor_cmd() {
|
||||
|
||||
if [ -n "$host_openscad" ]; then
|
||||
"$host_openscad" --version
|
||||
else
|
||||
elif docker_available; then
|
||||
run_shell_as_host_user \
|
||||
openscad-headless \
|
||||
/workspace/.cad-home/openscad-headless \
|
||||
'mkdir -p "$HOME" && openscad --version'
|
||||
fi
|
||||
|
||||
run_shell_as_host_user \
|
||||
platformio \
|
||||
/workspace/.cad-home/platformio \
|
||||
'mkdir -p "$HOME" "$HOME/.platformio" && export PLATFORMIO_CORE_DIR="$HOME/.platformio" && pio --version'
|
||||
if [ -n "$host_platformio" ]; then
|
||||
"$host_platformio" --version
|
||||
elif docker_available; then
|
||||
ensure_service_up platformio
|
||||
run_shell_as_host_user \
|
||||
platformio \
|
||||
/workspace/.cad-home/platformio \
|
||||
'mkdir -p "$HOME" "$HOME/.platformio" && export PLATFORMIO_CORE_DIR="$HOME/.platformio" && pio --version'
|
||||
else
|
||||
log "WARN: platformio non trouvé et docker indisponible, check platformio ignoré"
|
||||
degraded=1
|
||||
fi
|
||||
|
||||
if [ "$degraded" -eq 0 ]; then
|
||||
log "OK: CAD doctor checks executed successfully."
|
||||
else
|
||||
log "DEGRADED: CAD doctor fallback host-only for unavailable container runtime."
|
||||
fi
|
||||
}
|
||||
|
||||
doctor_mcp_cmd() {
|
||||
|
||||
+30
-12
@@ -1,18 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Very small diff helper for BOM/netlist exports (placeholder)."""
|
||||
"""Small diff helper for CAD export text artefacts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import difflib
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 4:
|
||||
print("usage: hw_diff.py <before> <after> <out.md>", file=sys.stderr)
|
||||
return 2
|
||||
before = Path(sys.argv[1]).read_text(encoding="utf-8", errors="ignore").splitlines()
|
||||
after = Path(sys.argv[2]).read_text(encoding="utf-8", errors="ignore").splitlines()
|
||||
diff = difflib.unified_diff(before, after, fromfile="before", tofile="after", lineterm="")
|
||||
Path(sys.argv[3]).write_text("\n".join(diff) + "\n", encoding="utf-8")
|
||||
return 0
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 4:
|
||||
print(f"usage: {Path(sys.argv[0]).name} <before> <after> <out.md>", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
before_path = Path(sys.argv[1])
|
||||
after_path = Path(sys.argv[2])
|
||||
out_path = Path(sys.argv[3])
|
||||
|
||||
if not before_path.exists():
|
||||
print(f"Input file missing: {before_path}", file=sys.stderr)
|
||||
return 1
|
||||
if not after_path.exists():
|
||||
print(f"Input file missing: {after_path}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
before = before_path.read_text(encoding="utf-8", errors="ignore").splitlines()
|
||||
after = after_path.read_text(encoding="utf-8", errors="ignore").splitlines()
|
||||
|
||||
diff = difflib.unified_diff(before, after, fromfile=before_path.name, tofile=after_path.name, lineterm="")
|
||||
out_path.write_text("\n".join(diff) + "\n", encoding="utf-8")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -64,19 +64,28 @@ def main() -> int:
|
||||
args = parse_args()
|
||||
doctor = parse_doctor_output()
|
||||
host_status = doctor.get('HOST_PCBNEW_IMPORT', 'missing')
|
||||
entrypoint_state = doctor.get('ENTRYPOINT_STATE', 'missing')
|
||||
entrypoint = doctor.get('ENTRYPOINT')
|
||||
payload: dict[str, Any] = {
|
||||
'status': 'degraded',
|
||||
'requested_runtime': 'host',
|
||||
'runtime_mode': 'host',
|
||||
'quick': args.quick,
|
||||
'host_pcbnew_import': host_status,
|
||||
'entrypoint_state': entrypoint_state,
|
||||
'error': None,
|
||||
}
|
||||
|
||||
if host_status != 'ok':
|
||||
payload['status'] = 'blocked'
|
||||
payload['error'] = 'pcbnew not importable on host runtime'
|
||||
return emit(payload, json_output=args.json)
|
||||
|
||||
if entrypoint_state != 'present':
|
||||
payload['status'] = 'blocked'
|
||||
payload['error'] = f'host entrypoint missing: {entrypoint or "unknown"}'
|
||||
return emit(payload, json_output=args.json)
|
||||
|
||||
cmd = ['python3', str(BASE_SMOKE), '--runtime', 'host', '--json', '--timeout', str(args.timeout)]
|
||||
if args.quick:
|
||||
cmd.append('--quick')
|
||||
|
||||
@@ -4,6 +4,7 @@ set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source "$ROOT_DIR/tools/lib/runtime_home.sh"
|
||||
COMPOSE_FILE="$ROOT_DIR/deploy/cad/docker-compose.yml"
|
||||
DOCKER_CLIENT_HOME="${HOME:-}"
|
||||
VERBOSE=0
|
||||
DOCTOR=0
|
||||
RUNTIME="${KICAD_MCP_RUNTIME:-auto}"
|
||||
@@ -56,7 +57,17 @@ die() {
|
||||
}
|
||||
|
||||
compose() {
|
||||
docker compose -f "$COMPOSE_FILE" "$@"
|
||||
if HOME="$DOCKER_CLIENT_HOME" docker compose version >/dev/null 2>&1; then
|
||||
HOME="$DOCKER_CLIENT_HOME" docker compose -f "$COMPOSE_FILE" "$@"
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v docker-compose >/dev/null 2>&1; then
|
||||
HOME="$DOCKER_CLIENT_HOME" docker-compose -f "$COMPOSE_FILE" "$@"
|
||||
return
|
||||
fi
|
||||
|
||||
die "docker compose support not available"
|
||||
}
|
||||
|
||||
build_pythonpath() {
|
||||
@@ -193,7 +204,7 @@ export KICAD_PYTHON_FILE_LOG_LEVEL="${KICAD_PYTHON_FILE_LOG_LEVEL:-INFO}"
|
||||
MASCARADE_DIR="$(
|
||||
kill_life_resolve_mascarade_dir \
|
||||
"$ROOT_DIR" \
|
||||
"finetune/kicad_mcp_server"
|
||||
"finetune"
|
||||
)"
|
||||
SERVER_DIR="$MASCARADE_DIR/finetune/kicad_mcp_server"
|
||||
ENTRYPOINT="${KICAD_MCP_ENTRYPOINT:-$SERVER_DIR/dist/index.js}"
|
||||
@@ -209,12 +220,17 @@ DATA_DIR="${KICAD_MCP_DATA_DIR:-$MCP_HOME/data}"
|
||||
PYTHONPATH_VALUE="$(build_pythonpath)"
|
||||
PROBE_PYTHON="$(resolve_probe_python)"
|
||||
HOST_PCBNEW_STATUS="missing"
|
||||
HOST_ENTRYPOINT_STATE="missing"
|
||||
CONTAINER_STATUS="unknown"
|
||||
|
||||
if probe_pcbnew "$PROBE_PYTHON" "$PYTHONPATH_VALUE"; then
|
||||
HOST_PCBNEW_STATUS="ok"
|
||||
fi
|
||||
|
||||
if [ -f "$ENTRYPOINT" ]; then
|
||||
HOST_ENTRYPOINT_STATE="present"
|
||||
fi
|
||||
|
||||
if command -v docker >/dev/null 2>&1 && [ -f "$COMPOSE_FILE" ]; then
|
||||
CONTAINER_STATUS="available"
|
||||
else
|
||||
@@ -223,7 +239,7 @@ fi
|
||||
|
||||
case "$RUNTIME" in
|
||||
auto)
|
||||
if [ "$HOST_PCBNEW_STATUS" = "ok" ]; then
|
||||
if [ "$HOST_PCBNEW_STATUS" = "ok" ] && [ "$HOST_ENTRYPOINT_STATE" = "present" ]; then
|
||||
RUNTIME="host"
|
||||
else
|
||||
RUNTIME="container"
|
||||
@@ -250,6 +266,7 @@ KICAD_MCP_DATA_DIR=$DATA_DIR
|
||||
PROBE_PYTHON=$PROBE_PYTHON
|
||||
KICAD_PYTHONPATH=$PYTHONPATH_VALUE
|
||||
HOST_PCBNEW_IMPORT=$HOST_PCBNEW_STATUS
|
||||
ENTRYPOINT_STATE=$HOST_ENTRYPOINT_STATE
|
||||
CONTAINER_STATUS=$CONTAINER_STATUS
|
||||
KICAD_MCP_IMAGE=$KICAD_MCP_IMAGE
|
||||
KICAD_MCP_LOG_LEVEL=$KICAD_MCP_LOG_LEVEL
|
||||
|
||||
@@ -26,13 +26,40 @@ MASCARADE_CORE_DIR = MASCARADE_DIR / "core"
|
||||
if str(MASCARADE_CORE_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(MASCARADE_CORE_DIR))
|
||||
|
||||
from mascarade.integrations.knowledge_base import ( # noqa: E402
|
||||
KnowledgeBaseClient,
|
||||
knowledge_base_auth_configured,
|
||||
knowledge_base_provider_label,
|
||||
knowledge_base_status_detail,
|
||||
normalized_knowledge_base_provider,
|
||||
)
|
||||
KNOWLEDGE_BASE_IMPORT_ERROR: str | None = None
|
||||
|
||||
try:
|
||||
from mascarade.integrations.knowledge_base import ( # noqa: E402
|
||||
KnowledgeBaseClient,
|
||||
knowledge_base_auth_configured,
|
||||
knowledge_base_provider_label,
|
||||
knowledge_base_status_detail,
|
||||
normalized_knowledge_base_provider,
|
||||
)
|
||||
except ModuleNotFoundError as exc: # pragma: no cover - exercised through smoke flow
|
||||
KNOWLEDGE_BASE_IMPORT_ERROR = str(exc)
|
||||
|
||||
def normalized_knowledge_base_provider() -> str:
|
||||
return os.getenv("KNOWLEDGE_BASE_PROVIDER", "memos").strip().lower() or "memos"
|
||||
|
||||
def knowledge_base_provider_label() -> str:
|
||||
return normalized_knowledge_base_provider()
|
||||
|
||||
def knowledge_base_auth_configured() -> bool:
|
||||
return False
|
||||
|
||||
def knowledge_base_status_detail() -> str:
|
||||
return (
|
||||
"Mascarade knowledge-base integration unavailable: "
|
||||
f"{KNOWLEDGE_BASE_IMPORT_ERROR}"
|
||||
)
|
||||
|
||||
class KnowledgeBaseClient: # pragma: no cover - tool calls return before instantiation
|
||||
provider = normalized_knowledge_base_provider()
|
||||
label = knowledge_base_provider_label()
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
TOOLS = [
|
||||
@@ -108,7 +135,27 @@ def _missing_secret_payload() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _integration_unavailable_payload() -> dict[str, Any]:
|
||||
label = knowledge_base_provider_label()
|
||||
detail = knowledge_base_status_detail()
|
||||
return {
|
||||
"ok": False,
|
||||
"provider": normalized_knowledge_base_provider(),
|
||||
"error": {
|
||||
"code": "integration_unavailable",
|
||||
"message": detail,
|
||||
},
|
||||
"provider_label": label,
|
||||
}
|
||||
|
||||
|
||||
async def _with_client(callback):
|
||||
if KNOWLEDGE_BASE_IMPORT_ERROR is not None:
|
||||
detail = knowledge_base_status_detail()
|
||||
return error_tool_result(
|
||||
detail,
|
||||
_integration_unavailable_payload(),
|
||||
)
|
||||
if not knowledge_base_auth_configured():
|
||||
return error_tool_result(
|
||||
knowledge_base_status_detail(),
|
||||
|
||||
@@ -62,8 +62,14 @@ kill_life_resolve_mascarade_dir() {
|
||||
fi
|
||||
|
||||
candidates+=(
|
||||
"$root_dir/../mascarade"
|
||||
"$root_dir/../mascarade-main"
|
||||
"$root_dir/../mascarade"
|
||||
"$root_dir/../mascarade-github"
|
||||
"$root_dir/../Github_Repos/Perso/mascarade-main"
|
||||
"$root_dir/../Github_Repos/Perso/mascarade"
|
||||
"$root_dir/../Github_Repos/Perso/mascarade-github"
|
||||
"$root_dir/../Github_Repos/mascarade-main"
|
||||
"$root_dir/../Github_Repos/mascarade"
|
||||
)
|
||||
|
||||
for candidate in "${candidates[@]}"; do
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# T-MA-016 — Fine-tune Mistral Small sur dataset KiCad
|
||||
# Objectif: Spécialiser Mistral Small pour génération/review schémas KiCad
|
||||
# Dataset: ~15k examples (JSONL instruct format)
|
||||
# Ref: github.com/mistralai/mistral-finetune
|
||||
|
||||
# data
|
||||
data:
|
||||
instruct_data: "/data/mascarade-datasets/kicad/kicad_train.jsonl"
|
||||
data: ""
|
||||
eval_instruct_data: "/data/mascarade-datasets/kicad/kicad_eval.jsonl"
|
||||
|
||||
# model — Mistral Small (utiliser le checkpoint téléchargé)
|
||||
model_id_or_path: "/models/mistral-small-latest"
|
||||
lora:
|
||||
rank: 64
|
||||
|
||||
# optim — conservateur pour domaine technique
|
||||
seq_len: 16384 # KiCad prompts sont longs mais rarement > 16k tokens
|
||||
batch_size: 1
|
||||
max_steps: 500 # ~15k samples, ~5 epochs avec packing
|
||||
optim:
|
||||
lr: 4.e-5 # légèrement plus bas que default pour données techniques
|
||||
weight_decay: 0.1
|
||||
pct_start: 0.05
|
||||
|
||||
# other
|
||||
seed: 42
|
||||
log_freq: 1
|
||||
eval_freq: 50
|
||||
no_eval: False
|
||||
ckpt_freq: 100
|
||||
|
||||
save_adapters: True # LoRA adapters only — merge au déploiement
|
||||
|
||||
run_dir: "/runs/kicad_small_finetune"
|
||||
|
||||
wandb:
|
||||
project: "kill-life-finetune"
|
||||
run_name: "kicad-small-v1"
|
||||
key: "" # à remplir
|
||||
offline: False
|
||||
@@ -0,0 +1,41 @@
|
||||
# T-MA-017 — Fine-tune Codestral sur dataset SPICE+embedded
|
||||
# Objectif: Spécialiser Codestral pour génération/review netlist SPICE et code embedded
|
||||
# Dataset: ~20k examples (JSONL instruct format)
|
||||
# Ref: github.com/mistralai/mistral-finetune
|
||||
|
||||
# data
|
||||
data:
|
||||
instruct_data: "/data/mascarade-datasets/spice/spice_embedded_train.jsonl"
|
||||
data: ""
|
||||
eval_instruct_data: "/data/mascarade-datasets/spice/spice_embedded_eval.jsonl"
|
||||
|
||||
# model — Codestral (code-specialized)
|
||||
model_id_or_path: "/models/codestral-latest"
|
||||
lora:
|
||||
rank: 64
|
||||
|
||||
# optim — adapté code/SPICE
|
||||
seq_len: 16384 # netlists SPICE peuvent être longs
|
||||
batch_size: 1
|
||||
max_steps: 600 # ~20k samples, ~5 epochs
|
||||
optim:
|
||||
lr: 3.e-5 # plus conservateur pour Codestral (déjà spécialisé code)
|
||||
weight_decay: 0.1
|
||||
pct_start: 0.05
|
||||
|
||||
# other
|
||||
seed: 42
|
||||
log_freq: 1
|
||||
eval_freq: 50
|
||||
no_eval: False
|
||||
ckpt_freq: 100
|
||||
|
||||
save_adapters: True
|
||||
|
||||
run_dir: "/runs/spice_codestral_finetune"
|
||||
|
||||
wandb:
|
||||
project: "kill-life-finetune"
|
||||
run_name: "spice-codestral-v1"
|
||||
key: ""
|
||||
offline: False
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user