From 7d4ee627bf120422bc17a40007ad85c4ada65086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Tue, 24 Mar 2026 00:21:13 +0100 Subject: [PATCH] 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) --- tools/ai/integrations/n8n/README.md | 12 + .../n8n/kill_life_smoke_workflow.json | 19 +- tools/ai/zeroclaw_dual_bootstrap.sh | 4 +- tools/ai/zeroclaw_dual_chat.sh | 4 +- tools/ai/zeroclaw_hw_firmware_loop.sh | 4 +- tools/ai/zeroclaw_integrations_down.sh | 20 + tools/ai/zeroclaw_integrations_import_n8n.sh | 251 ++- tools/ai/zeroclaw_integrations_lot.sh | 125 +- tools/ai/zeroclaw_integrations_status.sh | 156 +- tools/ai/zeroclaw_integrations_up.sh | 157 +- tools/ai/zeroclaw_stack_up.sh | 12 +- tools/auto_check_ci_cd.py | 5 +- tools/autonomous_next_lots.py | 357 +++- tools/bootstrap_mac_mcp.sh | 5 +- tools/cad/ai_native_forks.sh | 335 ++++ tools/cad/install_yiacad_native_gui.sh | 79 + .../freecad/YiACADWorkbench/Init.py | 1 + .../freecad/YiACADWorkbench/InitGui.py | 71 + .../Resources/icons/yiacad_ai.svg | 7 + .../YiACADWorkbench/yiacad_freecad_gui.py | 154 ++ .../kicad/yiacad_kicad_plugin/__init__.py | 3 + .../kicad/yiacad_kicad_plugin/__main__.py | 4 + .../kicad/yiacad_kicad_plugin/_common.py | 67 + .../yiacad_kicad_plugin/yiacad_action.py | 114 ++ .../probe_board.kicad_pcb | 1 + .../yiacad_backend_proof/probe_model.FCStd | 1 + .../switch_yiacad_surfaces_to_native_forks.sh | 18 + tools/cad/yiacad_ai_bridge.py | 111 ++ tools/cad/yiacad_backend.py | 178 ++ tools/cad/yiacad_backend_client.py | 157 ++ tools/cad/yiacad_backend_service.py | 216 +++ tools/cad/yiacad_fusion_lot.sh | 287 +++ tools/cad/yiacad_native_ops.py | 680 +++++++ tools/cockpit/agent_matrix_tui.sh | 137 ++ tools/cockpit/aperant_bridge.sh | 193 ++ tools/cockpit/artifact_wms_index_tui.sh | 158 ++ tools/cockpit/dataset_audit_tui.sh | 440 +++++ tools/cockpit/e2e_agents_test.sh | 471 +++++ tools/cockpit/fab_package_tui.sh | 618 ++++++ tools/cockpit/full_operator_lane.sh | 853 +++++++++ tools/cockpit/full_operator_lane_sync.sh | 164 ++ tools/cockpit/infra_container_health.sh | 309 +++ tools/cockpit/integration_health_tui.sh | 196 ++ tools/cockpit/intelligence_program_tui 2.sh | 256 +++ tools/cockpit/intelligence_program_tui.sh | 7 + tools/cockpit/intelligence_tui.sh | 1669 +++++++++++++++++ tools/cockpit/json_contract.sh | 44 + .../kill_life_mistral_governance_sync.sh | 266 +++ tools/cockpit/load_mistral_governance_env.sh | 11 + tools/cockpit/log_ops.sh | 187 ++ tools/cockpit/lot_chain.sh | 110 +- tools/cockpit/machine_registry.sh | 247 +++ tools/cockpit/mascarade_dispatch_mesh.sh | 156 ++ tools/cockpit/mascarade_incident_registry.sh | 234 +++ tools/cockpit/mascarade_incidents_tui.sh | 278 +++ tools/cockpit/mascarade_logs_tui.sh | 379 ++++ tools/cockpit/mascarade_mesh_env_sync.sh | 354 ++++ tools/cockpit/mascarade_models_tui.sh | 287 +++ tools/cockpit/mascarade_runtime_health.sh | 542 ++++++ tools/cockpit/mesh_dirtyset_sync.sh | 165 ++ tools/cockpit/mesh_health_check.sh | 315 ++++ tools/cockpit/mesh_sync_preflight.sh | 1341 +++++++++++++ tools/cockpit/mistral_agents_tui.sh | 380 ++++ tools/cockpit/mistral_studio_tui.sh | 907 +++++++++ tools/cockpit/mistral_workspace_guard.sh | 165 ++ tools/cockpit/ops_erp_registry_tui.sh | 135 ++ tools/cockpit/pcb_ai_fab_tui.sh | 142 ++ tools/cockpit/product_contract_audit.sh | 329 ++++ tools/cockpit/refonte_tui.sh | 640 +++++++ .../cockpit/render_daily_operator_summary.sh | 230 +++ .../render_mascarade_incident_brief.sh | 246 +++ .../render_mascarade_incident_queue.sh | 228 +++ .../render_mascarade_incident_watch.sh | 181 ++ .../cockpit/render_mascarade_watch_history.sh | 175 ++ .../render_product_contract_handoff.sh | 299 +++ .../cockpit/render_weekly_refonte_summary.sh | 393 ++++ tools/cockpit/run_alignment_daily.sh | 794 ++++++++ tools/cockpit/runtime_ai_gateway.sh | 850 +++++++++ tools/cockpit/sentinelle_cron.sh | 370 ++++ tools/cockpit/ssh_healthcheck.sh | 203 ++ tools/cockpit/write_kill_life_memory_entry.sh | 231 +++ tools/cockpit/yiacad_backend_proof.sh | 448 +++++ tools/cockpit/yiacad_backend_service_tui.sh | 224 +++ tools/cockpit/yiacad_logs_tui.sh | 187 ++ tools/cockpit/yiacad_operator_index.sh | 551 ++++++ tools/cockpit/yiacad_proofs_tui.sh | 203 ++ tools/cockpit/yiacad_refonte_tui.sh | 284 +++ tools/cockpit/yiacad_uiux_tui.sh | 680 +++++++ tools/evals/README.md | 50 + tools/evals/benchmark_providers.py | 373 ++++ tools/evals/prompts/metier_100_template.jsonl | 20 + tools/github_dispatch_mcp.py | 46 +- tools/hw/cad_stack.sh | 72 +- tools/hw/hw_diff.py | 42 +- tools/hw/kicad_host_mcp_smoke.py | 9 + tools/hw/run_kicad_mcp.sh | 23 +- tools/knowledge_base_mcp.py | 61 +- tools/lib/runtime_home.sh | 8 +- .../mistral/finetune/configs/kicad_small.yaml | 41 + .../finetune/configs/spice_codestral.yaml | 41 + .../mistral/finetune/prepare_and_validate.sh | 88 + tools/mistral/mistral_client.py | 27 +- tools/ops/deploy_mascarade_tower_runtime.sh | 138 ++ tools/ops/operator_live_provider_smoke.js | 330 ++++ tools/ops/operator_live_provider_smoke.py | 350 ++++ tools/ops/smoke_mascarade_agents_kxkm.sh | 201 ++ tools/ops/sync_mascarade_agents_kxkm.sh | 332 ++++ tools/ops/sync_mascarade_agents_tower.sh | 203 ++ tools/repo_state/lint_header_contract.py | 8 +- tools/repo_state/repo_refresh.sh | 93 +- tools/run_github_dispatch_mcp.sh | 2 +- tools/run_knowledge_base_mcp.sh | 2 +- tools/specs/mesh_contract_check.py | 95 + tools/test_python.sh | 14 + 114 files changed, 25969 insertions(+), 177 deletions(-) create mode 100755 tools/cad/ai_native_forks.sh create mode 100755 tools/cad/install_yiacad_native_gui.sh create mode 100644 tools/cad/integrations/freecad/YiACADWorkbench/Init.py create mode 100644 tools/cad/integrations/freecad/YiACADWorkbench/InitGui.py create mode 100644 tools/cad/integrations/freecad/YiACADWorkbench/Resources/icons/yiacad_ai.svg create mode 100644 tools/cad/integrations/freecad/YiACADWorkbench/yiacad_freecad_gui.py create mode 100644 tools/cad/integrations/kicad/yiacad_kicad_plugin/__init__.py create mode 100644 tools/cad/integrations/kicad/yiacad_kicad_plugin/__main__.py create mode 100644 tools/cad/integrations/kicad/yiacad_kicad_plugin/_common.py create mode 100644 tools/cad/integrations/kicad/yiacad_kicad_plugin/yiacad_action.py create mode 100644 tools/cad/proof_fixtures/yiacad_backend_proof/probe_board.kicad_pcb create mode 100644 tools/cad/proof_fixtures/yiacad_backend_proof/probe_model.FCStd create mode 100755 tools/cad/switch_yiacad_surfaces_to_native_forks.sh create mode 100755 tools/cad/yiacad_ai_bridge.py create mode 100644 tools/cad/yiacad_backend.py create mode 100644 tools/cad/yiacad_backend_client.py create mode 100644 tools/cad/yiacad_backend_service.py create mode 100755 tools/cad/yiacad_fusion_lot.sh create mode 100755 tools/cad/yiacad_native_ops.py create mode 100755 tools/cockpit/agent_matrix_tui.sh create mode 100755 tools/cockpit/aperant_bridge.sh create mode 100755 tools/cockpit/artifact_wms_index_tui.sh create mode 100755 tools/cockpit/dataset_audit_tui.sh create mode 100644 tools/cockpit/e2e_agents_test.sh create mode 100755 tools/cockpit/fab_package_tui.sh create mode 100755 tools/cockpit/full_operator_lane.sh create mode 100755 tools/cockpit/full_operator_lane_sync.sh create mode 100644 tools/cockpit/infra_container_health.sh create mode 100644 tools/cockpit/integration_health_tui.sh create mode 100644 tools/cockpit/intelligence_program_tui 2.sh create mode 100755 tools/cockpit/intelligence_program_tui.sh create mode 100755 tools/cockpit/intelligence_tui.sh create mode 100644 tools/cockpit/json_contract.sh create mode 100755 tools/cockpit/kill_life_mistral_governance_sync.sh create mode 100755 tools/cockpit/load_mistral_governance_env.sh create mode 100755 tools/cockpit/log_ops.sh create mode 100644 tools/cockpit/machine_registry.sh create mode 100755 tools/cockpit/mascarade_dispatch_mesh.sh create mode 100644 tools/cockpit/mascarade_incident_registry.sh create mode 100644 tools/cockpit/mascarade_incidents_tui.sh create mode 100755 tools/cockpit/mascarade_logs_tui.sh create mode 100755 tools/cockpit/mascarade_mesh_env_sync.sh create mode 100644 tools/cockpit/mascarade_models_tui.sh create mode 100755 tools/cockpit/mascarade_runtime_health.sh create mode 100755 tools/cockpit/mesh_dirtyset_sync.sh create mode 100755 tools/cockpit/mesh_health_check.sh create mode 100644 tools/cockpit/mesh_sync_preflight.sh create mode 100644 tools/cockpit/mistral_agents_tui.sh create mode 100644 tools/cockpit/mistral_studio_tui.sh create mode 100755 tools/cockpit/mistral_workspace_guard.sh create mode 100755 tools/cockpit/ops_erp_registry_tui.sh create mode 100755 tools/cockpit/pcb_ai_fab_tui.sh create mode 100644 tools/cockpit/product_contract_audit.sh create mode 100755 tools/cockpit/refonte_tui.sh create mode 100644 tools/cockpit/render_daily_operator_summary.sh create mode 100644 tools/cockpit/render_mascarade_incident_brief.sh create mode 100644 tools/cockpit/render_mascarade_incident_queue.sh create mode 100644 tools/cockpit/render_mascarade_incident_watch.sh create mode 100644 tools/cockpit/render_mascarade_watch_history.sh create mode 100644 tools/cockpit/render_product_contract_handoff.sh create mode 100755 tools/cockpit/render_weekly_refonte_summary.sh create mode 100644 tools/cockpit/run_alignment_daily.sh create mode 100755 tools/cockpit/runtime_ai_gateway.sh create mode 100644 tools/cockpit/sentinelle_cron.sh create mode 100755 tools/cockpit/ssh_healthcheck.sh create mode 100644 tools/cockpit/write_kill_life_memory_entry.sh create mode 100755 tools/cockpit/yiacad_backend_proof.sh create mode 100644 tools/cockpit/yiacad_backend_service_tui.sh create mode 100755 tools/cockpit/yiacad_logs_tui.sh create mode 100644 tools/cockpit/yiacad_operator_index.sh create mode 100755 tools/cockpit/yiacad_proofs_tui.sh create mode 100755 tools/cockpit/yiacad_refonte_tui.sh create mode 100755 tools/cockpit/yiacad_uiux_tui.sh create mode 100644 tools/evals/README.md create mode 100755 tools/evals/benchmark_providers.py create mode 100644 tools/evals/prompts/metier_100_template.jsonl create mode 100644 tools/mistral/finetune/configs/kicad_small.yaml create mode 100644 tools/mistral/finetune/configs/spice_codestral.yaml create mode 100755 tools/mistral/finetune/prepare_and_validate.sh create mode 100755 tools/ops/deploy_mascarade_tower_runtime.sh create mode 100644 tools/ops/operator_live_provider_smoke.js create mode 100755 tools/ops/operator_live_provider_smoke.py create mode 100644 tools/ops/smoke_mascarade_agents_kxkm.sh create mode 100644 tools/ops/sync_mascarade_agents_kxkm.sh create mode 100755 tools/ops/sync_mascarade_agents_tower.sh create mode 100755 tools/specs/mesh_contract_check.py diff --git a/tools/ai/integrations/n8n/README.md b/tools/ai/integrations/n8n/README.md index fbfed5a..e8c82fb 100644 --- a/tools/ai/integrations/n8n/README.md +++ b/tools/ai/integrations/n8n/README.md @@ -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=` after import, with legacy fallback `n8n update:workflow --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: diff --git a/tools/ai/integrations/n8n/kill_life_smoke_workflow.json b/tools/ai/integrations/n8n/kill_life_smoke_workflow.json index c5a0dbf..8d1e7a4 100644 --- a/tools/ai/integrations/n8n/kill_life_smoke_workflow.json +++ b/tools/ai/integrations/n8n/kill_life_smoke_workflow.json @@ -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": [ [ { diff --git a/tools/ai/zeroclaw_dual_bootstrap.sh b/tools/ai/zeroclaw_dual_bootstrap.sh index af2db0a..35c0310 100755 --- a/tools/ai/zeroclaw_dual_bootstrap.sh +++ b/tools/ai/zeroclaw_dual_bootstrap.sh @@ -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() { diff --git a/tools/ai/zeroclaw_dual_chat.sh b/tools/ai/zeroclaw_dual_chat.sh index 15d36bf..ecb15fb 100755 --- a/tools/ai/zeroclaw_dual_chat.sh +++ b/tools/ai/zeroclaw_dual_chat.sh @@ -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 </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}" diff --git a/tools/ai/zeroclaw_integrations_import_n8n.sh b/tools/ai/zeroclaw_integrations_import_n8n.sh index 69de128..bd3118e 100755 --- a/tools/ai/zeroclaw_integrations_import_n8n.sh +++ b/tools/ai/zeroclaw_integrations_import_n8n.sh @@ -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 - </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 - </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}" diff --git a/tools/ai/zeroclaw_integrations_lot.sh b/tools/ai/zeroclaw_integrations_lot.sh index 6a0d4c8..349b84f 100644 --- a/tools/ai/zeroclaw_integrations_lot.sh +++ b/tools/ai/zeroclaw_integrations_lot.sh @@ -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 diff --git a/tools/ai/zeroclaw_integrations_status.sh b/tools/ai/zeroclaw_integrations_status.sh index 7084358..37b9f44 100755 --- a/tools/ai/zeroclaw_integrations_status.sh +++ b/tools/ai/zeroclaw_integrations_status.sh @@ -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)"}" diff --git a/tools/ai/zeroclaw_integrations_up.sh b/tools/ai/zeroclaw_integrations_up.sh index f1af76c..d738cbe 100755 --- a/tools/ai/zeroclaw_integrations_up.sh +++ b/tools/ai/zeroclaw_integrations_up.sh @@ -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 - </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 diff --git a/tools/ai/zeroclaw_stack_up.sh b/tools/ai/zeroclaw_stack_up.sh index a983408..1a02ae7 100755 --- a/tools/ai/zeroclaw_stack_up.sh +++ b/tools/ai/zeroclaw_stack_up.sh @@ -1163,8 +1163,8 @@ cat >"$INDEX_FILE" < @@ -1631,8 +1631,8 @@ cat >"$INDEX_FILE" <"$INDEX_FILE" < 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: diff --git a/tools/autonomous_next_lots.py b/tools/autonomous_next_lots.py index 0a275a7..9f3bae0 100755 --- a/tools/autonomous_next_lots.py +++ b/tools/autonomous_next_lots.py @@ -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": diff --git a/tools/bootstrap_mac_mcp.sh b/tools/bootstrap_mac_mcp.sh index 58d2aa5..646a713 100755 --- a/tools/bootstrap_mac_mcp.sh +++ b/tools/bootstrap_mac_mcp.sh @@ -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; } diff --git a/tools/cad/ai_native_forks.sh b/tools/cad/ai_native_forks.sh new file mode 100755 index 0000000..fa703f1 --- /dev/null +++ b/tools/cad/ai_native_forks.sh @@ -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" <>"$manifest_path" + +log "Manifest écrit dans $manifest_path" diff --git a/tools/cad/install_yiacad_native_gui.sh b/tools/cad/install_yiacad_native_gui.sh new file mode 100755 index 0000000..7daeabc --- /dev/null +++ b/tools/cad/install_yiacad_native_gui.sh @@ -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 + +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' "" + else + printf '%s' "" + 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 diff --git a/tools/cad/integrations/freecad/YiACADWorkbench/Init.py b/tools/cad/integrations/freecad/YiACADWorkbench/Init.py new file mode 100644 index 0000000..7616757 --- /dev/null +++ b/tools/cad/integrations/freecad/YiACADWorkbench/Init.py @@ -0,0 +1 @@ +# YiACAD FreeCAD workbench bootstrap placeholder. diff --git a/tools/cad/integrations/freecad/YiACADWorkbench/InitGui.py b/tools/cad/integrations/freecad/YiACADWorkbench/InitGui.py new file mode 100644 index 0000000..3b3170f --- /dev/null +++ b/tools/cad/integrations/freecad/YiACADWorkbench/InitGui.py @@ -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()) diff --git a/tools/cad/integrations/freecad/YiACADWorkbench/Resources/icons/yiacad_ai.svg b/tools/cad/integrations/freecad/YiACADWorkbench/Resources/icons/yiacad_ai.svg new file mode 100644 index 0000000..c43e12b --- /dev/null +++ b/tools/cad/integrations/freecad/YiACADWorkbench/Resources/icons/yiacad_ai.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tools/cad/integrations/freecad/YiACADWorkbench/yiacad_freecad_gui.py b/tools/cad/integrations/freecad/YiACADWorkbench/yiacad_freecad_gui.py new file mode 100644 index 0000000..e1a1dc4 --- /dev/null +++ b/tools/cad/integrations/freecad/YiACADWorkbench/yiacad_freecad_gui.py @@ -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_() diff --git a/tools/cad/integrations/kicad/yiacad_kicad_plugin/__init__.py b/tools/cad/integrations/kicad/yiacad_kicad_plugin/__init__.py new file mode 100644 index 0000000..e7267fe --- /dev/null +++ b/tools/cad/integrations/kicad/yiacad_kicad_plugin/__init__.py @@ -0,0 +1,3 @@ +from .yiacad_action import YiACADActionPlugin + +YiACADActionPlugin().register() diff --git a/tools/cad/integrations/kicad/yiacad_kicad_plugin/__main__.py b/tools/cad/integrations/kicad/yiacad_kicad_plugin/__main__.py new file mode 100644 index 0000000..a3951d9 --- /dev/null +++ b/tools/cad/integrations/kicad/yiacad_kicad_plugin/__main__.py @@ -0,0 +1,4 @@ +from ._common import fetch_status_payload + +if __name__ == "__main__": + print(fetch_status_payload()) diff --git a/tools/cad/integrations/kicad/yiacad_kicad_plugin/_common.py b/tools/cad/integrations/kicad/yiacad_kicad_plugin/_common.py new file mode 100644 index 0000000..d3a82f0 --- /dev/null +++ b/tools/cad/integrations/kicad/yiacad_kicad_plugin/_common.py @@ -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)]) diff --git a/tools/cad/integrations/kicad/yiacad_kicad_plugin/yiacad_action.py b/tools/cad/integrations/kicad/yiacad_kicad_plugin/yiacad_action.py new file mode 100644 index 0000000..c8fbd4b --- /dev/null +++ b/tools/cad/integrations/kicad/yiacad_kicad_plugin/yiacad_action.py @@ -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() diff --git a/tools/cad/proof_fixtures/yiacad_backend_proof/probe_board.kicad_pcb b/tools/cad/proof_fixtures/yiacad_backend_proof/probe_board.kicad_pcb new file mode 100644 index 0000000..4938566 --- /dev/null +++ b/tools/cad/proof_fixtures/yiacad_backend_proof/probe_board.kicad_pcb @@ -0,0 +1 @@ +proof fixture for YiACAD backend proof diff --git a/tools/cad/proof_fixtures/yiacad_backend_proof/probe_model.FCStd b/tools/cad/proof_fixtures/yiacad_backend_proof/probe_model.FCStd new file mode 100644 index 0000000..4938566 --- /dev/null +++ b/tools/cad/proof_fixtures/yiacad_backend_proof/probe_model.FCStd @@ -0,0 +1 @@ +proof fixture for YiACAD backend proof diff --git a/tools/cad/switch_yiacad_surfaces_to_native_forks.sh b/tools/cad/switch_yiacad_surfaces_to_native_forks.sh new file mode 100755 index 0000000..320cf0a --- /dev/null +++ b/tools/cad/switch_yiacad_surfaces_to_native_forks.sh @@ -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}" diff --git a/tools/cad/yiacad_ai_bridge.py b/tools/cad/yiacad_ai_bridge.py new file mode 100755 index 0000000..86bdc21 --- /dev/null +++ b/tools/cad/yiacad_ai_bridge.py @@ -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()) diff --git a/tools/cad/yiacad_backend.py b/tools/cad/yiacad_backend.py new file mode 100644 index 0000000..6951543 --- /dev/null +++ b/tools/cad/yiacad_backend.py @@ -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) diff --git a/tools/cad/yiacad_backend_client.py b/tools/cad/yiacad_backend_client.py new file mode 100644 index 0000000..dc8238b --- /dev/null +++ b/tools/cad/yiacad_backend_client.py @@ -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()) diff --git a/tools/cad/yiacad_backend_service.py b/tools/cad/yiacad_backend_service.py new file mode 100644 index 0000000..5aee2d4 --- /dev/null +++ b/tools/cad/yiacad_backend_service.py @@ -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()) diff --git a/tools/cad/yiacad_fusion_lot.sh b/tools/cad/yiacad_fusion_lot.sh new file mode 100755 index 0000000..1510ba4 --- /dev/null +++ b/tools/cad/yiacad_fusion_lot.sh @@ -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 + 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 diff --git a/tools/cad/yiacad_native_ops.py b/tools/cad/yiacad_native_ops.py new file mode 100755 index 0000000..53f3954 --- /dev/null +++ b/tools/cad/yiacad_native_ops.py @@ -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()) diff --git a/tools/cockpit/agent_matrix_tui.sh b/tools/cockpit/agent_matrix_tui.sh new file mode 100755 index 0000000..c73db04 --- /dev/null +++ b/tools/cockpit/agent_matrix_tui.sh @@ -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 diff --git a/tools/cockpit/aperant_bridge.sh b/tools/cockpit/aperant_bridge.sh new file mode 100755 index 0000000..b4bf6cd --- /dev/null +++ b/tools/cockpit/aperant_bridge.sh @@ -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 + +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 diff --git a/tools/cockpit/artifact_wms_index_tui.sh b/tools/cockpit/artifact_wms_index_tui.sh new file mode 100755 index 0000000..bb104fd --- /dev/null +++ b/tools/cockpit/artifact_wms_index_tui.sh @@ -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 diff --git a/tools/cockpit/dataset_audit_tui.sh b/tools/cockpit/dataset_audit_tui.sh new file mode 100755 index 0000000..ec9cc64 --- /dev/null +++ b/tools/cockpit/dataset_audit_tui.sh @@ -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 </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 diff --git a/tools/cockpit/e2e_agents_test.sh b/tools/cockpit/e2e_agents_test.sh new file mode 100644 index 0000000..73d4d5b --- /dev/null +++ b/tools/cockpit/e2e_agents_test.sh @@ -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 [options] + +Options: + --action Test suite to run (default: all) + --api-mode API mode: beta (conversations) or deprecated (agents/completions) + --json Emit JSON report + --timeout 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 diff --git a/tools/cockpit/fab_package_tui.sh b/tools/cockpit/fab_package_tui.sh new file mode 100755 index 0000000..58a866d --- /dev/null +++ b/tools/cockpit/fab_package_tui.sh @@ -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 <"${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 <&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 diff --git a/tools/cockpit/full_operator_lane.sh b/tools/cockpit/full_operator_lane.sh new file mode 100755 index 0000000..24de93d --- /dev/null +++ b/tools/cockpit/full_operator_lane.sh @@ -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 [--json] [--logs-action ] + +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 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://: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 diff --git a/tools/cockpit/full_operator_lane_sync.sh b/tools/cockpit/full_operator_lane_sync.sh new file mode 100755 index 0000000..4d1e3a0 --- /dev/null +++ b/tools/cockpit/full_operator_lane_sync.sh @@ -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}" < [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 Action to run + --target 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 " + 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 diff --git a/tools/cockpit/integration_health_tui.sh b/tools/cockpit/integration_health_tui.sh new file mode 100644 index 0000000..289073c --- /dev/null +++ b/tools/cockpit/integration_health_tui.sh @@ -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 </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 diff --git a/tools/cockpit/intelligence_program_tui 2.sh b/tools/cockpit/intelligence_program_tui 2.sh new file mode 100644 index 0000000..12d324e --- /dev/null +++ b/tools/cockpit/intelligence_program_tui 2.sh @@ -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 + --json + --days + --lines + -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 diff --git a/tools/cockpit/intelligence_program_tui.sh b/tools/cockpit/intelligence_program_tui.sh new file mode 100755 index 0000000..41eb88f --- /dev/null +++ b/tools/cockpit/intelligence_program_tui.sh @@ -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" "$@" diff --git a/tools/cockpit/intelligence_tui.sh b/tools/cockpit/intelligence_tui.sh new file mode 100755 index 0000000..b7ecb63 --- /dev/null +++ b/tools/cockpit/intelligence_tui.sh @@ -0,0 +1,1669 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "${ROOT_DIR}/tools/cockpit/json_contract.sh" + +COMPONENT="intelligence_tui" +ARTIFACT_DIR="${ROOT_DIR}/artifacts/cockpit/intelligence_program" +mkdir -p "${ARTIFACT_DIR}" + +STAMP="$(date +%Y%m%d-%H%M%S)" +RUN_LOG="${ARTIFACT_DIR}/intelligence_tui-${STAMP}.log" +ACTION="status" +JSON=0 +APPLY=0 +RETENTION_DAYS=14 +LIMIT=8 +LINES=120 + +usage() { + cat <<'EOF' +Usage: bash tools/cockpit/intelligence_tui.sh [options] + +Options: + --action + --json + --apply + --days + --limit + --lines + -h, --help +EOF +} + +log_line() { + local level="$1" + shift + local message="$*" + printf '[%s] [%s] %s\n' "$(date '+%H:%M:%S')" "${level}" "${message}" | tee -a "${RUN_LOG}" >&2 +} + +ensure_run_log() { + : >> "${RUN_LOG}" +} + +build_snapshot_json() { + python3 - "${ROOT_DIR}" "${RUN_LOG}" "${ACTION}" "${LIMIT}" "${ARTIFACT_DIR}" <<'PY' +from __future__ import annotations + +import json +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +root = Path(sys.argv[1]) +run_log = Path(sys.argv[2]) +action = sys.argv[3] +limit = int(sys.argv[4]) +artifact_dir = Path(sys.argv[5]) + +def resolve_latest(glob_pattern: str, fallback_relative: str) -> Path: + matches = sorted(root.glob(glob_pattern)) + if matches: + return matches[-1] + return root / fallback_relative + + +audit_file = resolve_latest( + "docs/KILL_LIFE_CONSOLIDATION_AUDIT_*.md", + "docs/KILL_LIFE_CONSOLIDATION_AUDIT_2026-03-21.md", +) +spec_file = root / "specs" / "agentic_intelligence_integration_spec.md" +feature_map_file = resolve_latest( + "docs/AGENTIC_INTELLIGENCE_FEATURE_MAP_*.md", + "docs/AGENTIC_INTELLIGENCE_FEATURE_MAP_2026-03-21.md", +) +plan_file = root / "docs" / "plans" / "22_plan_integration_intelligence_agentique.md" +todo_file = root / "docs" / "plans" / "22_todo_integration_intelligence_agentique.md" +web_spec_file = root / "specs" / "yiacad_git_eda_platform_spec.md" +web_doc_file = resolve_latest( + "docs/YIACAD_GIT_EDA_PLATFORM_*.md", + "docs/YIACAD_GIT_EDA_PLATFORM_2026-03-22.md", +) +web_plan_file = root / "docs" / "plans" / "23_plan_yiacad_git_eda_platform.md" +web_todo_file = root / "docs" / "plans" / "23_todo_yiacad_git_eda_platform.md" +web_readme_file = root / "web" / "README.md" +owners_file = root / "docs" / "plans" / "12_plan_gestion_des_agents.md" +tasks_file = root / "specs" / "04_tasks.md" +workflows_file = root / "docs" / "AI_WORKFLOWS.md" +research_file = resolve_latest( + "docs/WEB_RESEARCH_OPEN_SOURCE_*.md", + "docs/WEB_RESEARCH_OPEN_SOURCE_2026-03-20.md", +) +kill_life_memory_json = root / "artifacts" / "cockpit" / "kill_life_memory" / "latest.json" +kill_life_memory_md = root / "artifacts" / "cockpit" / "kill_life_memory" / "latest.md" + + +def read_text(path: Path) -> str: + try: + return path.read_text(encoding="utf-8") + except FileNotFoundError: + return "" + + +def parse_markdown_table(text: str, heading: str) -> list[dict[str, str]]: + section = text.split(heading, 1) + if len(section) != 2: + return [] + lines = section[1].splitlines() + table_lines: list[str] = [] + started = False + for line in lines: + if line.startswith("|"): + table_lines.append(line) + started = True + continue + if started: + break + if len(table_lines) < 3: + return [] + headers = [cell.strip() for cell in table_lines[0].strip("|").split("|")] + rows = [] + for line in table_lines[2:]: + values = [cell.strip() for cell in line.strip("|").split("|")] + if len(values) != len(headers): + continue + rows.append(dict(zip(headers, values))) + return rows + + +def parse_first_available_table(text: str, headings: list[str]) -> list[dict[str, str]]: + for heading in headings: + rows = parse_markdown_table(text, heading) + if rows: + return rows + return [] + + +def parse_bullets_after_heading(text: str, heading: str) -> list[str]: + section = text.split(heading, 1) + if len(section) != 2: + return [] + bullets: list[str] = [] + for line in section[1].splitlines(): + stripped = line.strip() + if stripped.startswith("## ") and stripped != heading.strip(): + break + if stripped.startswith("### "): + break + if stripped.startswith("- "): + bullets.append(stripped[2:].strip()) + return bullets + + +def parse_open_tasks(text: str, max_items: int) -> list[str]: + tasks = [] + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("- [ ]") or stripped.startswith("* [ ]"): + tasks.append(stripped[5:].strip()) + return tasks[:max_items] + + +def parse_all_open_tasks(text: str) -> list[str]: + return parse_open_tasks(text, 10_000) + + +def repo_snapshot(repo_path: Path) -> dict[str, object]: + exists = repo_path.exists() + payload: dict[str, object] = { + "name": repo_path.name, + "path": str(repo_path), + "exists": exists, + "dirty_count": None, + "branch": None, + "version": None, + "has_terminal_app": (repo_path / "src" / "cli.ts").exists(), + } + if not exists: + return payload + + package_file = repo_path / "package.json" + if package_file.exists(): + try: + payload["version"] = json.loads(package_file.read_text(encoding="utf-8")).get("version") + except json.JSONDecodeError: + payload["version"] = "invalid-package-json" + + dirty = subprocess.run( + ["git", "-C", str(repo_path), "status", "--short"], + check=False, + capture_output=True, + text=True, + ) + payload["dirty_count"] = len([line for line in dirty.stdout.splitlines() if line.strip()]) + + branch = subprocess.run( + ["git", "-C", str(repo_path), "branch", "--show-current"], + check=False, + capture_output=True, + text=True, + ) + payload["branch"] = branch.stdout.strip() or None + return payload + + +scorecard_view = { + "json": str(artifact_dir / "scorecard_latest.json"), + "markdown": str(artifact_dir / "scorecard_latest.md"), +} +comparison_view = { + "json": str(artifact_dir / "repo_comparison_latest.json"), + "markdown": str(artifact_dir / "repo_comparison_latest.md"), +} +recommendations_view = { + "json": str(artifact_dir / "recommendation_queue_latest.json"), + "markdown": str(artifact_dir / "recommendation_queue_latest.md"), +} + + +owners = parse_markdown_table(read_text(owners_file), "## Consolidation 2026-03-21 - extensions d'abord") +research = parse_first_available_table( + read_text(research_file), + [ + "## Delta verification officielle 2026-03-22", + "## Delta verification officielle 2026-03-21", + "### Benchmark 2026 — patterns retenus pour `summary-short` et la gateway santé `runtime/MCP/IA` (mise à jour 2026-03-21)", + "### Etat de l'art agents / MCP / IA 2026 (mise à jour 2026-03-21)", + ], +) +priorities = { + "p0": parse_bullets_after_heading(read_text(audit_file), "### P0"), + "p1": parse_bullets_after_heading(read_text(audit_file), "### P1"), + "p2": parse_bullets_after_heading(read_text(audit_file), "### P2"), +} +open_todos_all = parse_all_open_tasks(read_text(todo_file)) +web_open_todos_all = parse_all_open_tasks(read_text(web_todo_file)) +global_open_tasks_all = parse_all_open_tasks(read_text(tasks_file)) +open_tasks = open_todos_all[:limit] +web_open_tasks = web_open_todos_all[:limit] +global_open_tasks = global_open_tasks_all[:limit] +extension_repos = [ + repo_snapshot(root.parent / "kill-life-studio"), + repo_snapshot(root.parent / "kill-life-mesh"), + repo_snapshot(root.parent / "kill-life-operator"), +] + +status = "degraded" if open_todos_all or web_open_todos_all or global_open_tasks_all else "done" +degraded_reasons = [] +if open_todos_all: + degraded_reasons.append("open-intelligence-todos") +if web_open_todos_all: + degraded_reasons.append("open-web-eda-todos") +if global_open_tasks_all: + degraded_reasons.append("open-global-tasks") +next_steps = (open_tasks + web_open_tasks + global_open_tasks)[:3] or [ + "No open tasks detected in docs/plans/22_todo_integration_intelligence_agentique.md and docs/plans/23_todo_yiacad_git_eda_platform.md." +] + +canonical_sources = [ + {"label": "README", "path": str(root / "README.md")}, + {"label": "Operator index", "path": str(root / "docs" / "index.md")}, + {"label": "Consolidation audit", "path": str(audit_file)}, + {"label": "Intelligence spec", "path": str(spec_file)}, + {"label": "Intelligence feature map", "path": str(feature_map_file)}, + {"label": "Plan 22", "path": str(plan_file)}, + {"label": "TODO 22", "path": str(todo_file)}, + {"label": "Web spec", "path": str(web_spec_file)}, + {"label": "Web platform doc", "path": str(web_doc_file)}, + {"label": "Plan 23", "path": str(web_plan_file)}, + {"label": "TODO 23", "path": str(web_todo_file)}, + {"label": "Web README", "path": str(web_readme_file)}, + {"label": "AI workflows", "path": str(workflows_file)}, + {"label": "Agent plan", "path": str(owners_file)}, + {"label": "OSS research", "path": str(research_file)}, + {"label": "Backlog", "path": str(tasks_file)}, +] + +payload = { + "contract_version": "cockpit-v1", + "component": "intelligence_tui", + "action": action, + "status": status, + "contract_status": "degraded" if status == "degraded" else "ok", + "generated_at": datetime.now().astimezone().isoformat(), + "log_file": str(run_log), + "artifacts": [str(run_log)], + "degraded_reasons": degraded_reasons, + "next_steps": next_steps, + "canonical_sources": canonical_sources, + "audit_doc": str(audit_file), + "spec_doc": str(spec_file), + "feature_map_doc": str(feature_map_file), + "plan_doc": str(plan_file), + "todo_doc": str(todo_file), + "web_spec_doc": str(web_spec_file), + "web_doc": str(web_doc_file), + "web_plan_doc": str(web_plan_file), + "web_todo_doc": str(web_todo_file), + "web_readme": str(web_readme_file), + "owners_doc": str(owners_file), + "research_doc": str(research_file), + "priority_lanes": priorities, + "open_task_count": len(open_todos_all) + len(web_open_todos_all) + len(global_open_tasks_all), + "open_tasks": open_tasks, + "intelligence_open_todo_count": len(open_todos_all), + "intelligence_open_todos": open_tasks, + "web_open_task_count": len(web_open_todos_all), + "web_open_tasks": web_open_tasks, + "global_open_task_count": len(global_open_tasks_all), + "global_open_tasks": global_open_tasks, + "owners": owners, + "research": research, + "extension_repos": extension_repos, + "kill_life_memory": { + "json": str(kill_life_memory_json) if kill_life_memory_json.exists() else "", + "markdown": str(kill_life_memory_md) if kill_life_memory_md.exists() else "", + }, + "intelligence_views": { + "scorecard": scorecard_view, + "comparison": comparison_view, + "recommendations": recommendations_view, + }, +} + +if action == "memory": + latest_json = artifact_dir / "latest.json" + latest_md = artifact_dir / "latest.md" + md_lines = [ + "# Intelligence program snapshot", + "", + f"- generated_at: {payload['generated_at']}", + f"- status: {payload['status']}", + f"- open_task_count: {payload['open_task_count']}", + "", + "## Priority lanes", + ] + for lane, items in priorities.items(): + md_lines.append(f"- {lane.upper()}: {', '.join(items) if items else 'none'}") + md_lines.extend(["", "## Open tasks"]) + if open_tasks: + md_lines.extend([f"- {item}" for item in open_tasks]) + else: + md_lines.append("- none") + md_lines.extend(["", "## Web Git EDA carry-over"]) + if web_open_tasks: + md_lines.extend([f"- {item}" for item in web_open_tasks]) + else: + md_lines.append("- none") + md_lines.extend(["", "## Global carry-over"]) + if global_open_tasks: + md_lines.extend([f"- {item}" for item in global_open_tasks]) + else: + md_lines.append("- none") + md_lines.extend(["", "## Extension repos"]) + for repo in extension_repos: + md_lines.append( + f"- {repo['name']}: branch={repo['branch'] or 'n/a'} dirty={repo['dirty_count']} version={repo['version'] or 'n/a'} terminal_app={repo['has_terminal_app']}" + ) + md_lines.extend(["", "## Derived views"]) + md_lines.append(f"- scorecard: {scorecard_view['json']} | {scorecard_view['markdown']}") + md_lines.append(f"- comparison: {comparison_view['json']} | {comparison_view['markdown']}") + md_lines.append(f"- recommendations: {recommendations_view['json']} | {recommendations_view['markdown']}") + payload["artifacts"].extend([str(latest_json), str(latest_md)]) + payload["memory_artifacts"] = { + "json": str(latest_json), + "markdown": str(latest_md), + "kill_life_json": str(kill_life_memory_json) if kill_life_memory_json.exists() else "", + "kill_life_markdown": str(kill_life_memory_md) if kill_life_memory_md.exists() else "", + } + latest_md.write_text("\n".join(md_lines) + "\n", encoding="utf-8") + latest_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + +print(json.dumps(payload, ensure_ascii=False)) +PY +} + +render_snapshot_text() { + python3 - "${ACTION}" "${1}" <<'PY' +from __future__ import annotations + +import json +import sys + +action = sys.argv[1] +payload = json.loads(sys.argv[2]) + +if action == "owners": + print("# Intelligence owners") + print() + for row in payload.get("owners", []): + print(f"- {row.get('Lead')}: {row.get('Sous-agents')} | {row.get('Compétences')} | backlog: {row.get('Backlog initial')}") + raise SystemExit(0) + +if action == "todo": + print("# Intelligence open tasks") + print() + for item in payload.get("open_tasks", []): + print(f"- {item}") + if payload.get("web_open_tasks"): + print() + print("## Web Git EDA carry-over") + print() + for item in payload.get("web_open_tasks", []): + print(f"- {item}") + if payload.get("global_open_tasks"): + print() + print("## Global carry-over") + print() + for item in payload.get("global_open_tasks", []): + print(f"- {item}") + raise SystemExit(0) + +if action == "research": + def pick(row: dict[str, str], *keys: str) -> str: + for key in keys: + value = row.get(key) + if value: + return value + return "n/a" + + print("# Intelligence research") + print() + for row in payload.get("research", []): + print( + f"- {pick(row, 'Source officielle', 'Pattern / source primaire', 'Projet / source')}: " + f"{pick(row, 'Signal verifie', 'Signal utile', 'Signal utile pour Kill_LIFE')} | " + f"decision: {pick(row, 'Decision Kill_LIFE', 'Adoption concrète', 'Decision', 'Decision ', 'Decision pour Kill_LIFE')}" + ) + raise SystemExit(0) + +if action == "memory": + print("# Intelligence memory") + print() + print(f"- status: {payload.get('status')}") + print(f"- open_task_count: {payload.get('open_task_count')}") + for key, value in (payload.get("memory_artifacts") or {}).items(): + print(f"- {key}: {value}") + kill_life = payload.get("kill_life_memory") or {} + print(f"- kill_life_json: {kill_life.get('json', '')}") + print(f"- kill_life_markdown: {kill_life.get('markdown', '')}") + for name, view in (payload.get("intelligence_views") or {}).items(): + print(f"- {name}: {view.get('json')} | {view.get('markdown')}") + raise SystemExit(0) + +if action == "next-actions": + print("# Intelligence next actions") + print() + for step in payload.get("next_steps", []): + print(f"- {step}") + raise SystemExit(0) + +print("# Intelligence status") +print() +print(f"- status: {payload.get('status')}") +print(f"- open_task_count: {payload.get('open_task_count')}") +print("- next_steps:") +for step in payload.get("next_steps", []): + print(f" - {step}") +print("- canonical_sources:") +for source in payload.get("canonical_sources", []): + print(f" - {source.get('label')}: {source.get('path')}") +print("- extension_repos:") +for repo in payload.get("extension_repos", []): + print( + f" - {repo.get('name')}: branch={repo.get('branch') or 'n/a'} dirty={repo.get('dirty_count')} version={repo.get('version') or 'n/a'} terminal_app={repo.get('has_terminal_app')}" + ) +print("- priorities:") +for lane, items in (payload.get("priority_lanes") or {}).items(): + print(f" - {lane.upper()}: {', '.join(items) if items else 'none'}") +PY +} + +emit_scorecard_json() { + python3 - "${ROOT_DIR}" "${1}" <<'PY' +from __future__ import annotations + +import json +import sys +from datetime import datetime +from pathlib import Path + +root = Path(sys.argv[1]) +payload = json.loads(sys.argv[2]) +views = payload.get("intelligence_views") or {} +scorecard_view = views.get("scorecard") or {} +scorecard_json_path = Path(scorecard_view.get("json") or root / "artifacts" / "cockpit" / "intelligence_program" / "scorecard_latest.json") +scorecard_md_path = Path(scorecard_view.get("markdown") or root / "artifacts" / "cockpit" / "intelligence_program" / "scorecard_latest.md") + + +def unique_paths(paths: list[Path]) -> list[Path]: + seen: set[Path] = set() + items: list[Path] = [] + for path in paths: + if path in seen: + continue + seen.add(path) + items.append(path) + return items + + +def count_markdown(paths: list[Path]) -> int: + count = 0 + for base in paths: + if base.is_file() and base.suffix == ".md": + count += 1 + elif base.is_dir(): + count += len(list(base.rglob("*.md"))) + return count + + +def status_from_score(score: int, *, inverse: bool = False) -> str: + if inverse: + if score <= 30: + return "ready" + if score <= 60: + return "degraded" + return "blocked" + if score >= 85: + return "ready" + if score >= 50: + return "degraded" + return "blocked" + + +def relative_path(path: Path) -> str: + try: + return str(path.resolve().relative_to(root)) + except Exception: + return str(path) + + +def compact(value: object, max_len: int = 140) -> str: + text = " ".join(str(value or "").split()) + if len(text) <= max_len: + return text + return text[: max_len - 3].rstrip() + "..." + + +entrypoint_paths = [ + root / "README.md", + root / "docs" / "index.md", + root / "specs" / "README.md", + root / "tools" / "cockpit" / "README.md", + root / "web" / "README.md", +] +reference_bundle_paths = [ + Path(payload.get("audit_doc") or root / "docs" / "KILL_LIFE_CONSOLIDATION_AUDIT_2026-03-21.md"), + Path(payload.get("spec_doc") or root / "specs" / "agentic_intelligence_integration_spec.md"), + Path(payload.get("feature_map_doc") or root / "docs" / "AGENTIC_INTELLIGENCE_FEATURE_MAP_2026-03-21.md"), + Path(payload.get("plan_doc") or root / "docs" / "plans" / "22_plan_integration_intelligence_agentique.md"), + Path(payload.get("todo_doc") or root / "docs" / "plans" / "22_todo_integration_intelligence_agentique.md"), + Path(payload.get("web_spec_doc") or root / "specs" / "yiacad_git_eda_platform_spec.md"), + Path(payload.get("web_doc") or root / "docs" / "YIACAD_GIT_EDA_PLATFORM_2026-03-22.md"), + Path(payload.get("web_plan_doc") or root / "docs" / "plans" / "23_plan_yiacad_git_eda_platform.md"), + Path(payload.get("web_todo_doc") or root / "docs" / "plans" / "23_todo_yiacad_git_eda_platform.md"), + Path(payload.get("web_readme") or root / "web" / "README.md"), + Path(payload.get("research_doc") or root / "docs" / "WEB_RESEARCH_OPEN_SOURCE_2026-03-20.md"), + root / "docs" / "AI_WORKFLOWS.md", +] +documentation_surface_paths = unique_paths( + [ + root / "README.md", + root / "docs", + root / "specs", + root / "tools" / "cockpit" / "README.md", + root / "web", + ] +) + +entrypoint_count = sum(1 for path in entrypoint_paths if path.exists()) +missing_entrypoints = len(entrypoint_paths) - entrypoint_count +reference_bundle_count = sum(1 for path in reference_bundle_paths if path.exists()) +documentation_surface_count = count_markdown(documentation_surface_paths) +fragmentation_score = min( + 100, + 10 + max(0, reference_bundle_count - entrypoint_count) * 6 + int(payload.get("intelligence_open_todo_count") or 0) * 8 + missing_entrypoints * 18, +) +fragmentation_status = status_from_score(fragmentation_score, inverse=True) + +lane_maturity = [] + + +def add_lane(lane: str, owner_agent: str, owner_subagent: str, signals: list[tuple[str, bool]]) -> None: + satisfied = [label for label, ok in signals if ok] + missing = [label for label, ok in signals if not ok] + score = round((len(satisfied) / len(signals)) * 100) if signals else 0 + lane_maturity.append( + { + "lane": lane, + "owner_agent": owner_agent, + "owner_subagent": owner_subagent, + "score": score, + "status": status_from_score(score), + "satisfied": satisfied, + "missing": missing, + } + ) + + +memory_json = root / "artifacts" / "cockpit" / "intelligence_program" / "latest.json" +memory_md = root / "artifacts" / "cockpit" / "intelligence_program" / "latest.md" +runtime_gateway_json = root / "artifacts" / "cockpit" / "runtime_ai_gateway" / "latest.json" +runtime_gateway_md = root / "artifacts" / "cockpit" / "runtime_ai_gateway" / "latest.md" +studio_root = root.parent / "kill-life-studio" +mesh_root = root.parent / "kill-life-mesh" +operator_root = root.parent / "kill-life-operator" +web_root = root / "web" + +add_lane( + "Program-Governance", + "PM-Mesh", + "Plan-Orchestrator", + [ + ("spec", Path(payload.get("spec_doc") or "").exists()), + ("plan", Path(payload.get("plan_doc") or "").exists()), + ("todo", Path(payload.get("todo_doc") or "").exists()), + ("memory-json", memory_json.exists()), + ("next-actions", bool(payload.get("next_steps"))), + ], +) +add_lane( + "Contracts", + "Mesh-Contracts", + "Contract-View", + [ + ("summary-short-schema", (root / "specs" / "contracts" / "summary_short.schema.json").exists()), + ("runtime-gateway-schema", (root / "specs" / "contracts" / "runtime_mcp_ia_gateway.schema.json").exists()), + ("intelligence-tui", (root / "tools" / "cockpit" / "intelligence_tui.sh").exists()), + ("runtime-gateway", (root / "tools" / "cockpit" / "runtime_ai_gateway.sh").exists()), + ], +) +add_lane( + "Runtime-Gateway", + "Runtime-Companion", + "MCP-Health", + [ + ("gateway-script", (root / "tools" / "cockpit" / "runtime_ai_gateway.sh").exists()), + ("gateway-json", runtime_gateway_json.exists()), + ("gateway-markdown", runtime_gateway_md.exists()), + ("mascarade-health", (root / "tools" / "cockpit" / "mascarade_runtime_health.sh").exists()), + ], +) +add_lane( + "Docs-Continuity", + "Docs-Research", + "Runbook-Editor", + [ + ("readme", (root / "README.md").exists()), + ("docs-index", (root / "docs" / "index.md").exists()), + ("cockpit-readme", (root / "tools" / "cockpit" / "README.md").exists()), + ("ai-workflows", (root / "docs" / "AI_WORKFLOWS.md").exists()), + ("web-readme", (root / "web" / "README.md").exists()), + ("fragmentation<=30", fragmentation_score <= 30), + ], +) +add_lane( + "QA-Compliance", + "QA-Compliance", + "Shell-Harness", + [ + ("intelligence-contract-test", (root / "test" / "test_intelligence_tui_contract.py").exists()), + ("runtime-gateway-test", (root / "test" / "test_runtime_ai_gateway_contract.py").exists()), + ("stable-suite-runner", (root / "tools" / "test_python.sh").exists()), + ("legacy-wrapper", (root / "tools" / "cockpit" / "intelligence_program_tui.sh").exists()), + ], +) +add_lane( + "Extensions-Consumption", + "Studio-Product", + "Context-Builder", + [ + ("studio-governance", (studio_root / "src" / "governance.ts").exists()), + ("mesh-governance", (mesh_root / "src" / "governance.ts").exists()), + ("operator-governance", (operator_root / "src" / "governance.ts").exists()), + ("studio-cli", (studio_root / "src" / "cli.ts").exists()), + ("mesh-cli", (mesh_root / "src" / "cli.ts").exists()), + ("operator-cli", (operator_root / "src" / "cli.ts").exists()), + ], +) +add_lane( + "Web-Git-EDA", + "Web-CAD-Platform", + "Realtime-Collab", + [ + ("web-spec", Path(payload.get("web_spec_doc") or "").exists()), + ("web-plan", Path(payload.get("web_plan_doc") or "").exists()), + ("web-todo", Path(payload.get("web_todo_doc") or "").exists()), + ("web-readme", Path(payload.get("web_readme") or "").exists()), + ("web-app", (web_root / "app" / "page.tsx").exists()), + ("graphql-route", (web_root / "app" / "api" / "graphql" / "route.ts").exists()), + ("realtime-server", (web_root / "realtime" / "server.mjs").exists()), + ("eda-worker", (web_root / "workers" / "eda-worker.mjs").exists()), + ], +) + +overall_maturity_score = round(sum(item["score"] for item in lane_maturity) / len(lane_maturity)) if lane_maturity else 0 +overall_maturity_status = status_from_score(overall_maturity_score) +status = "done" if fragmentation_status == "ready" and overall_maturity_status == "ready" else "degraded" +degraded_reasons = [] +if fragmentation_status != "ready": + degraded_reasons.append(f"documentation-fragmentation={fragmentation_score}") +for lane in lane_maturity: + if lane["status"] != "ready": + degraded_reasons.append(f"{lane['lane'].lower()}={lane['status']}") + +summary_short = compact( + f"fragmentation={fragmentation_score}/100 ({fragmentation_status}) overall_maturity={overall_maturity_score}/100 " + f"lanes_ready={sum(1 for item in lane_maturity if item['status'] == 'ready')}/{len(lane_maturity)}" +) +scorecard_payload = { + "contract_version": "cockpit-v1", + "component": "intelligence_tui", + "action": "scorecard", + "status": status, + "contract_status": "ok" if status == "done" else "degraded", + "generated_at": payload.get("generated_at") or datetime.now().astimezone().isoformat(), + "log_file": payload.get("log_file"), + "artifacts": [payload.get("log_file"), str(scorecard_json_path), str(scorecard_md_path)], + "degraded_reasons": degraded_reasons, + "next_steps": list(payload.get("next_steps") or [])[:3], + "fragmentation_score": fragmentation_score, + "fragmentation_scale": "0=coherent 100=fragmented", + "fragmentation_status": fragmentation_status, + "entrypoint_count": entrypoint_count, + "reference_bundle_count": reference_bundle_count, + "documentation_surface_count": documentation_surface_count, + "overall_maturity_score": overall_maturity_score, + "overall_maturity_status": overall_maturity_status, + "lane_maturity": lane_maturity, + "summary_short": summary_short, +} + +scorecard_json_path.parent.mkdir(parents=True, exist_ok=True) +scorecard_json_path.write_text(json.dumps(scorecard_payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") +lines = [ + "# Intelligence scorecard", + "", + f"- generated_at: {scorecard_payload['generated_at']}", + f"- fragmentation_score: {fragmentation_score}/100 ({fragmentation_status})", + f"- overall_maturity_score: {overall_maturity_score}/100 ({overall_maturity_status})", + f"- documentation_surface_count: {documentation_surface_count}", + f"- entrypoint_count: {entrypoint_count}", + f"- reference_bundle_count: {reference_bundle_count}", + "", + "## Lane maturity", + "", + "| Lane | Owner | Score | Status | Missing |", + "| --- | --- | --- | --- | --- |", +] +for lane in lane_maturity: + missing = ", ".join(lane["missing"]) if lane["missing"] else "none" + lines.append( + f"| {lane['lane']} | {lane['owner_agent']}/{lane['owner_subagent']} | {lane['score']} | {lane['status']} | {missing} |" + ) +scorecard_md_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + +print(json.dumps(scorecard_payload, ensure_ascii=False)) +PY +} + +render_scorecard_text() { + python3 - "${1}" <<'PY' +from __future__ import annotations + +import json +import sys + +payload = json.loads(sys.argv[1]) +print("# Intelligence scorecard") +print() +print(f"- fragmentation_score: {payload.get('fragmentation_score')}/100 ({payload.get('fragmentation_status')})") +print(f"- overall_maturity_score: {payload.get('overall_maturity_score')}/100 ({payload.get('overall_maturity_status')})") +print(f"- documentation_surface_count: {payload.get('documentation_surface_count')}") +print("- lane_maturity:") +for lane in payload.get("lane_maturity", []): + missing = ", ".join(lane.get("missing") or []) or "none" + print(f" - {lane.get('lane')}: score={lane.get('score')} status={lane.get('status')} owner={lane.get('owner_agent')}/{lane.get('owner_subagent')} missing={missing}") +PY +} + +emit_comparison_json() { + python3 - "${ROOT_DIR}" "${1}" <<'PY' +from __future__ import annotations + +import json +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +root = Path(sys.argv[1]) +payload = json.loads(sys.argv[2]) +views = payload.get("intelligence_views") or {} +comparison_view = views.get("comparison") or {} +comparison_json_path = Path(comparison_view.get("json") or root / "artifacts" / "cockpit" / "intelligence_program" / "repo_comparison_latest.json") +comparison_md_path = Path(comparison_view.get("markdown") or root / "artifacts" / "cockpit" / "intelligence_program" / "repo_comparison_latest.md") + + +def compact(value: object, max_len: int = 160) -> str: + text = " ".join(str(value or "").split()) + if len(text) <= max_len: + return text + return text[: max_len - 3].rstrip() + "..." + + +def status_from_score(score: int) -> str: + if score >= 85: + return "ready" + if score >= 50: + return "degraded" + return "blocked" + + +def relative_path(path: Path) -> str: + try: + return str(path.resolve().relative_to(root)) + except Exception: + return str(path) + + +def git_capture(repo_path: Path, args: list[str]) -> str | None: + proc = subprocess.run( + ["git", "-C", str(repo_path), *args], + check=False, + capture_output=True, + text=True, + ) + if proc.returncode != 0: + return None + return proc.stdout.strip() + + +def repo_record(name: str, path: Path, role: str, kind: str, expected: list[str], governance_signal: str) -> dict[str, object]: + package_file = path / "package.json" + package_data = {} + if package_file.exists(): + try: + package_data = json.loads(package_file.read_text(encoding="utf-8")) + except json.JSONDecodeError: + package_data = {} + capabilities = { + "readme": (path / "README.md").exists(), + "docs_index": (path / "docs" / "index.md").exists(), + "specs_readme": (path / "specs" / "README.md").exists(), + "web_stack": (path / "web" / "package.json").exists(), + "cockpit": (path / "tools" / "cockpit" / "intelligence_tui.sh").exists(), + "runtime_gateway": (path / "tools" / "cockpit" / "runtime_ai_gateway.sh").exists(), + "package": package_file.exists(), + "cli": (path / "src" / "cli.ts").exists(), + "terminal_app": (path / "src" / "terminalApp.ts").exists(), + "governance": (path / "src" / "governance.ts").exists(), + "tests": (path / "test").exists() or bool(list((path / "src" / "test" / "suite").glob("*.ts"))) if (path / "src" / "test" / "suite").exists() else False, + "workspace_fixture": bool(list(path.rglob("*.code-workspace"))), + "memory": (path / "artifacts" / "cockpit" / "intelligence_program" / "latest.json").exists(), + } + branch = git_capture(path, ["branch", "--show-current"]) + dirty_output = git_capture(path, ["status", "--short"]) or "" + dirty_count = len([line for line in dirty_output.splitlines() if line.strip()]) + version = package_data.get("version") + fit_hits = sum(1 for key in expected if capabilities.get(key)) + fit_for_role_score = round((fit_hits / len(expected)) * 100) if expected else 100 + fit_status = status_from_score(fit_for_role_score) + enabled_capabilities = [key for key, value in capabilities.items() if value] + summary_short = compact( + f"{role}; fit={fit_for_role_score}/100 ({fit_status}); branch={branch or 'n/a'}; dirty={dirty_count}; " + f"capabilities={','.join(enabled_capabilities[:5]) or 'none'}" + ) + return { + "name": name, + "path": str(path), + "relative_path": relative_path(path), + "exists": path.exists(), + "role": role, + "kind": kind, + "governance_signal": governance_signal, + "branch": branch, + "dirty_count": dirty_count, + "version": version, + "fit_for_role_score": fit_for_role_score, + "fit_status": fit_status, + "capabilities": capabilities, + "enabled_capabilities": enabled_capabilities, + "summary_short": summary_short, + } + + +repos = [ + repo_record( + "Kill_LIFE", + root, + "control plane public, docs, cockpit, contracts", + "control-plane", + ["readme", "docs_index", "specs_readme", "cockpit", "runtime_gateway", "tests", "memory"], + "producer", + ), + repo_record( + "ai-agentic-embedded-base", + root / "ai-agentic-embedded-base", + "baseline spec-first embedded companion", + "knowledge-base", + ["readme", "specs_readme"], + "documented-companion", + ), + repo_record( + "kill-life-studio", + root.parent / "kill-life-studio", + "product pilot extension", + "vscode-extension", + ["readme", "package", "cli", "terminal_app", "governance", "tests", "workspace_fixture"], + "consumer", + ), + repo_record( + "kill-life-mesh", + root.parent / "kill-life-mesh", + "orchestration extension", + "vscode-extension", + ["readme", "package", "cli", "terminal_app", "governance", "tests"], + "consumer", + ), + repo_record( + "kill-life-operator", + root.parent / "kill-life-operator", + "operator evidence extension", + "vscode-extension", + ["readme", "package", "cli", "terminal_app", "governance", "tests"], + "consumer", + ), +] + +highlights = [ + "Kill_LIFE reste l'unique producteur du cockpit canonique, de la memoire intelligence et de la gateway runtime/MCP/IA.", + "ai-agentic-embedded-base sert de compagnon spec-first et non d'extension VS Code; il reste compare comme reference de contenu, pas comme surface produit.", + "Studio, Mesh et Operator partagent maintenant le meme socle governance + terminal app, puis se differencient par leur promesse produit.", +] +missing_repos = [repo["name"] for repo in repos if not repo["exists"]] +status = "done" if not missing_repos else "degraded" +comparison_payload = { + "contract_version": "cockpit-v1", + "component": "intelligence_tui", + "action": "comparison", + "status": status, + "contract_status": "ok" if status == "done" else "degraded", + "generated_at": payload.get("generated_at") or datetime.now().astimezone().isoformat(), + "log_file": payload.get("log_file"), + "artifacts": [payload.get("log_file"), str(comparison_json_path), str(comparison_md_path)], + "degraded_reasons": [f"missing-repo:{name}" for name in missing_repos], + "next_steps": list(payload.get("next_steps") or [])[:3], + "comparison_axes": [ + "role", + "governance_signal", + "fit_for_role_score", + "dirty_count", + "version", + "enabled_capabilities", + ], + "repos": repos, + "highlights": highlights, + "summary_short": compact( + f"{len(repos)} repos compares; producer=Kill_LIFE; companions=ai-agentic-embedded-base; consumers=studio/mesh/operator." + ), +} + +comparison_json_path.parent.mkdir(parents=True, exist_ok=True) +comparison_json_path.write_text(json.dumps(comparison_payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") +lines = [ + "# Intelligence repo comparison", + "", + f"- generated_at: {comparison_payload['generated_at']}", + "", + "| Repo | Kind | Governance | Fit | Dirty | Version | Key capabilities |", + "| --- | --- | --- | --- | --- | --- | --- |", +] +for repo in repos: + capabilities = ", ".join(repo["enabled_capabilities"][:5]) or "none" + lines.append( + f"| {repo['name']} | {repo['kind']} | {repo['governance_signal']} | {repo['fit_for_role_score']} ({repo['fit_status']}) | " + f"{repo['dirty_count']} | {repo['version'] or 'n/a'} | {capabilities} |" + ) +lines.extend(["", "## Highlights", ""]) +lines.extend([f"- {item}" for item in highlights]) +comparison_md_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + +print(json.dumps(comparison_payload, ensure_ascii=False)) +PY +} + +render_comparison_text() { + python3 - "${1}" <<'PY' +from __future__ import annotations + +import json +import sys + +payload = json.loads(sys.argv[1]) +print("# Intelligence repo comparison") +print() +for repo in payload.get("repos", []): + caps = ", ".join(repo.get("enabled_capabilities", [])[:5]) or "none" + print( + f"- {repo.get('name')}: role={repo.get('role')} governance={repo.get('governance_signal')} " + f"fit={repo.get('fit_for_role_score')}/100 ({repo.get('fit_status')}) dirty={repo.get('dirty_count')} " + f"version={repo.get('version') or 'n/a'} caps={caps}" + ) +print() +print("## Highlights") +print() +for item in payload.get("highlights", []): + print(f"- {item}") +PY +} + +emit_recommendations_json() { + python3 - "${ROOT_DIR}" "${1}" <<'PY' +from __future__ import annotations + +import json +import sys +from datetime import datetime +from pathlib import Path + +root = Path(sys.argv[1]) +payload = json.loads(sys.argv[2]) +views = payload.get("intelligence_views") or {} +recommendation_view = views.get("recommendations") or {} +recommendation_json_path = Path(recommendation_view.get("json") or root / "artifacts" / "cockpit" / "intelligence_program" / "recommendation_queue_latest.json") +recommendation_md_path = Path(recommendation_view.get("markdown") or root / "artifacts" / "cockpit" / "intelligence_program" / "recommendation_queue_latest.md") + + +def compact(value: object, max_len: int = 220) -> str: + text = " ".join(str(value or "").split()) + if len(text) <= max_len: + return text + return text[: max_len - 3].rstrip() + "..." + + +priority_rank = {"P0": 0, "P1": 1, "P2": 2} +global_open_tasks = list(payload.get("global_open_tasks") or []) +audit_source = str(Path(payload.get("audit_doc") or root / "docs" / "KILL_LIFE_CONSOLIDATION_AUDIT_2026-03-21.md")) +research_source = str(Path(payload.get("research_doc") or root / "docs" / "WEB_RESEARCH_OPEN_SOURCE_2026-03-20.md")) +web_plan_source = str(Path(payload.get("web_plan_doc") or root / "docs" / "plans" / "23_plan_yiacad_git_eda_platform.md")) +web_spec_source = str(Path(payload.get("web_spec_doc") or root / "specs" / "yiacad_git_eda_platform_spec.md")) + +queue = [ + { + "id": "AI-RQ-101", + "priority": "P0", + "status": "active", + "mode": "piloté", + "title": "Remplacer le faux save local par un read model Git reel pour le produit web", + "owner_agent": "Web-CAD-Platform", + "owner_subagent": "Project-Service", + "why_now": "Le shell web reste une demo locale tant que le save, la review et la PR ne derivent pas d'un vrai etat Git.", + "write_set": [ + "web/lib/project-store.ts", + "web/lib/graphql/schema.ts", + "web/app/api/graphql/route.ts", + "docs/plans/23_todo_yiacad_git_eda_platform.md", + ], + "sources": [ + audit_source, + web_plan_source, + web_spec_source, + "https://www.gitea.com/", + ], + "next_step": "Ajouter un read model Git minimal pour branche courante, fichiers modifies, PR review et save snapshot sans ecriture filesystem opaque.", + }, + { + "id": "AI-RQ-102", + "priority": "P0", + "status": "active", + "mode": "piloté", + "title": "Fermer la boucle CI/artifacts entre worker, GraphQL et UI", + "owner_agent": "Web-CAD-Platform", + "owner_subagent": "EDA-CI-Orchestrator", + "why_now": "Le worker ecrit des statuts et artifacts, mais l'UI ne consomme pas encore de surfaces web-servables ni de statuts live.", + "write_set": [ + "web/workers/eda-worker.mjs", + "web/lib/graphql/schema.ts", + "web/components/pcb-workbench.tsx", + "web/components/pr-review-shell.tsx", + ], + "sources": [ + audit_source, + web_plan_source, + web_spec_source, + "https://docs.bullmq.io/", + ], + "next_step": "Servir les artifacts via une route web dediee, exposer des URLs relatives et ajouter au minimum un polling simple cote UI.", + }, + { + "id": "AI-RQ-103", + "priority": "P1", + "status": "proposed", + "mode": "piloté", + "title": "Binder Excalidraw a Yjs sans casser la source de verite Git", + "owner_agent": "Web-CAD-Platform", + "owner_subagent": "Realtime-Collab", + "why_now": "La presence et le transport Yjs existent deja, mais la scene Excalidraw reste locale et coupee du systeme de collab.", + "write_set": [ + "web/components/excalidraw-canvas.tsx", + "web/components/project-shell.tsx", + "web/realtime/*", + "docs/plans/23_todo_yiacad_git_eda_platform.md", + ], + "sources": [ + research_source, + web_plan_source, + "https://docs.yjs.dev/", + "https://github.com/excalidraw/excalidraw", + ], + "next_step": "Ajouter un adaptateur Yjs pour la scene et conserver le save manuel comme checkpoint Git explicite.", + }, + { + "id": "AI-RQ-104", + "priority": "P1", + "status": "proposed", + "mode": "piloté", + "title": "Ouvrir un bridge lecture seule entre le produit web et la lane intelligence", + "owner_agent": "Runtime-Companion", + "owner_subagent": "Review-Assist", + "why_now": "Le produit web ne consomme encore ni health MCP, ni ops summary, ni hints de review issus des fichiers modifies et des sorties ERC/DRC.", + "write_set": [ + "web/lib/graphql/schema.ts", + "web/components/pr-review-shell.tsx", + "web/components/dashboard-shell.tsx", + "docs/AI_WORKFLOWS.md", + ], + "sources": [ + audit_source, + research_source, + "https://developers.openai.com/api/docs/guides/tools", + "https://modelcontextprotocol.io/docs/getting-started/intro", + ], + "next_step": "Exposer un `opsSummary` ou un `reviewHints` minimal en lecture seule avant toute tentative d'action outillee.", + }, + { + "id": "AI-RQ-105", + "priority": "P2", + "status": "deferred", + "mode": "assisté", + "title": "Formaliser le boundary MCP/service-first pour parts, CI, artifacts et review assist", + "owner_agent": "Runtime-Companion", + "owner_subagent": "MCP-Health", + "why_now": "Le produit a besoin d'outils clairs, mais pas d'un orchestrateur stateful avant la fermeture des gaps Git, CI et realtime.", + "write_set": [ + "docs/WEB_RESEARCH_OPEN_SOURCE_2026-03-22.md", + "docs/plans/22_plan_integration_intelligence_agentique.md", + "docs/plans/23_plan_yiacad_git_eda_platform.md", + ], + "sources": [ + research_source, + "https://modelcontextprotocol.io/docs/getting-started/intro", + "https://openai.github.io/openai-agents-python/", + "https://docs.langchain.com/oss/python/langgraph/overview", + ], + "next_step": "Garder LangGraph/Agents SDK comme overlays optionnels tant que le boundary outille n'est pas formellement ecrit.", + }, +] +queue.sort(key=lambda item: (priority_rank[item["priority"]], item["id"])) +active_priorities = [item["priority"] for item in queue if item["status"] in {"active", "proposed"}] +status = "degraded" if active_priorities else "done" +summary_short = compact( + f"Top recommendation={queue[0]['id']} {queue[0]['priority']} {queue[0]['title']} | total={len(queue)}" +) +recommendation_payload = { + "contract_version": "cockpit-v1", + "component": "intelligence_tui", + "action": "recommendations", + "status": status, + "contract_status": "degraded" if status != "done" else "ok", + "generated_at": payload.get("generated_at") or datetime.now().astimezone().isoformat(), + "log_file": payload.get("log_file"), + "artifacts": [payload.get("log_file"), str(recommendation_json_path), str(recommendation_md_path)], + "degraded_reasons": [f"open-recommendation:{item['id']}" for item in queue if item["status"] in {"active", "proposed"}][:3], + "next_steps": [item["next_step"] for item in queue[:3]], + "queue": queue, + "summary_short": summary_short, +} + +recommendation_json_path.parent.mkdir(parents=True, exist_ok=True) +recommendation_json_path.write_text(json.dumps(recommendation_payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") +lines = [ + "# AI recommendation queue", + "", + f"- generated_at: {recommendation_payload['generated_at']}", + f"- status: {recommendation_payload['status']}", + "", + "| Priority | Id | Status | Owner | Recommendation |", + "| --- | --- | --- | --- | --- |", +] +for item in queue: + lines.append( + f"| {item['priority']} | {item['id']} | {item['status']} | {item['owner_agent']}/{item['owner_subagent']} | {item['title']} |" + ) +lines.extend(["", "## Next steps", ""]) +lines.extend([f"- {item['next_step']}" for item in queue[:5]]) +recommendation_md_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + +print(json.dumps(recommendation_payload, ensure_ascii=False)) +PY +} + +render_recommendations_text() { + python3 - "${1}" <<'PY' +from __future__ import annotations + +import json +import sys + +payload = json.loads(sys.argv[1]) +print("# AI recommendation queue") +print() +for item in payload.get("queue", []): + print( + f"- {item.get('priority')} {item.get('id')}: {item.get('title')} " + f"[{item.get('status')}] owner={item.get('owner_agent')}/{item.get('owner_subagent')}" + ) + print(f" next: {item.get('next_step')}") +PY +} + +emit_summary_short_json() { + python3 - "${ROOT_DIR}" "${1}" <<'PY' +from __future__ import annotations + +import json +import sys +from pathlib import Path + +root = Path(sys.argv[1]) +payload = json.loads(sys.argv[2]) + + +def compact(value: object, max_len: int = 120) -> 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 normalize_status(value: object) -> str: + text = str(value or "").strip().lower() + if text in {"ready", "done", "ok", "success"}: + return "ready" + if text in {"blocked", "error", "failed", "fail"}: + return "blocked" + return "degraded" + + +def relative_path(value: object) -> str: + path = Path(str(value or "")) + if not path: + return "missing" + try: + return str(path.resolve().relative_to(root)) + except Exception: + return str(path) + + +priority_lane = "none" +priority_item = "none" +for lane in ("p0", "p1", "p2"): + items = (payload.get("priority_lanes") or {}).get(lane) or [] + if items: + priority_lane = lane.upper() + priority_item = compact(items[0], 96) + break + +next_steps = list(payload.get("next_steps") or [])[:3] +degraded_reasons = [compact(item, 96) for item in list(payload.get("degraded_reasons") or [])[:3]] +if not degraded_reasons: + degraded_reasons = ["none"] +first_next_step = compact(next_steps[0] if next_steps else "none", 120) +open_task_count = int(payload.get("open_task_count") or 0) +intelligence_open_todo_count = int(payload.get("intelligence_open_todo_count") or 0) +global_open_task_count = int(payload.get("global_open_task_count") or 0) +status = normalize_status(payload.get("status")) +owner_repo = "Kill_LIFE" +owner_agent = "PM-Mesh" +owner_subagent = "Plan-Orchestrator" +write_set = [ + "specs/agentic_intelligence_integration_spec.md", + "docs/plans/22_plan_integration_intelligence_agentique.md", + "docs/plans/22_todo_integration_intelligence_agentique.md", + "docs/AI_WORKFLOWS.md", + "tools/cockpit/intelligence_tui.sh", +] +memory_artifacts = payload.get("memory_artifacts") or {} +evidence = [] +for item in [ + memory_artifacts.get("json") or str(root / "artifacts" / "cockpit" / "intelligence_program" / "latest.json"), + memory_artifacts.get("markdown") or str(root / "artifacts" / "cockpit" / "intelligence_program" / "latest.md"), + payload.get("log_file"), + payload.get("spec_doc"), + payload.get("plan_doc"), +]: + if item: + value = relative_path(item) + if value not in evidence: + evidence.append(value) + +goal = "Stabiliser la gouvernance intelligence canonique et la memoire courte exploitable." +state = ( + f"status={status} open_tasks={open_task_count} intelligence_open={intelligence_open_todo_count} " + f"global_open={global_open_task_count} priority={priority_lane}:{priority_item}" +) +blockers = ", ".join(degraded_reasons[:2]) +next_label = ", ".join(compact(step, 96) for step in next_steps[:2]) if next_steps else "none" +summary_short = compact( + f"goal={goal} | state={state} | blockers={blockers} | next={next_label} " + f"| owner={owner_agent}/{owner_subagent} | evidence={evidence[0] if evidence else 'missing'}", + 320, +) + +summary_payload = { + "contract_version": "summary-short/v1", + "component": payload.get("component", "intelligence_tui"), + "action": "summary-short", + "status": status, + "generated_at": payload.get("generated_at"), + "lot_id": "intelligence-governance", + "owner_repo": owner_repo, + "owner_agent": owner_agent, + "owner_subagent": owner_subagent, + "write_set": write_set, + "summary_short": summary_short, + "evidence": evidence, + "degraded_reasons": list(payload.get("degraded_reasons") or []), + "next_steps": next_steps, + "goal": goal, + "state": state, + "blockers": degraded_reasons, + "next": next_steps, + "owner": f"{owner_agent}/{owner_subagent}", + "open_task_count": open_task_count, + "intelligence_open_todo_count": intelligence_open_todo_count, + "global_open_task_count": global_open_task_count, + "first_next_step": first_next_step, + "first_priority_lane": priority_lane, + "first_priority": priority_item, + "log_file": payload.get("log_file"), + "artifacts": payload.get("artifacts") or [], +} + +print(json.dumps(summary_payload, ensure_ascii=False)) +PY +} + +render_summary_short_text() { + python3 - "${1}" <<'PY' +from __future__ import annotations + +import json +import sys + +payload = json.loads(sys.argv[1]) + +for key in ( + "contract_version", + "component", + "action", + "owner_repo", + "owner_agent", + "owner_subagent", + "status", + "summary_short", + "open_task_count", + "intelligence_open_todo_count", + "global_open_task_count", + "first_priority_lane", + "first_priority", + "first_next_step", +): + print(f"{key}={payload.get(key, 'none')}") +print(f"evidence={','.join(payload.get('evidence') or [])}") +PY +} + +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}" +} + +resolve_latest_doc() { + local pattern="$1" + local fallback="$2" + python3 - "${ROOT_DIR}" "${pattern}" "${fallback}" <<'PY' +from pathlib import Path +import sys + +root = Path(sys.argv[1]) +pattern = sys.argv[2] +fallback = root / sys.argv[3] +matches = sorted(root.glob(pattern)) +print(matches[-1] if matches else fallback) +PY +} + +emit_logs_summary_json() { + local latest_file="" + local count=0 + local stale=0 + if find "${ARTIFACT_DIR}" -type f -name 'intelligence_tui-*.log' -print -quit | grep -q .; then + latest_file="$(find "${ARTIFACT_DIR}" -type f -name 'intelligence_tui-*.log' | sort | tail -n 1)" + count="$(find "${ARTIFACT_DIR}" -type f -name 'intelligence_tui-*.log' | wc -l | tr -d ' ')" + stale="$(find "${ARTIFACT_DIR}" -type f -name 'intelligence_tui-*.log' -mtime +"${RETENTION_DAYS}" | wc -l | tr -d ' ')" + fi + + printf '{\n' + printf ' "contract_version": "cockpit-v1",\n' + printf ' "component": "%s",\n' "${COMPONENT}" + printf ' "action": "logs-summary",\n' + printf ' "status": "%s",\n' "$([ "${stale}" -gt 0 ] && printf 'degraded' || printf 'done')" + printf ' "contract_status": "%s",\n' "$([ "${stale}" -gt 0 ] && printf 'degraded' || printf 'ok')" + printf ' "log_file": "%s",\n' "${RUN_LOG}" + printf ' "artifacts": %s,\n' "$(json_contract_array_from_args "${RUN_LOG}" "${latest_file}")" + printf ' "degraded_reasons": %s,\n' "$(json_contract_array_from_args $([ "${stale}" -gt 0 ] && printf '%s' "stale-logs-detected"))" + printf ' "next_steps": %s,\n' "$(json_contract_array_from_args "bash tools/cockpit/intelligence_tui.sh --action purge-logs --days ${RETENTION_DAYS} --apply --json")" + printf ' "count": %s,\n' "${count}" + printf ' "stale": %s,\n' "${stale}" + if [ -n "${latest_file}" ]; then + printf ' "latest_log": "%s"\n' "${latest_file}" + else + printf ' "latest_log": null\n' + fi + printf '}\n' +} + +render_logs_latest() { + local latest_file="" + latest_file="$(find "${ARTIFACT_DIR}" -type f -name 'intelligence_tui-*.log' | sort | tail -n 1 || true)" + if [ -z "${latest_file}" ]; then + printf 'No intelligence_tui logs found in %s\n' "${ARTIFACT_DIR}" + return 0 + fi + + printf '# Latest intelligence log\n\n' + printf -- '- file: %s\n\n' "${latest_file}" + tail -n 80 "${latest_file}" +} + +emit_logs_list_json() { + local files=() + while IFS= read -r file; do + [ -n "${file}" ] || continue + files+=("${file}") + done < <(find "${ARTIFACT_DIR}" -type f -name 'intelligence_tui-*.log' | sort) + + printf '{\n' + printf ' "contract_version": "cockpit-v1",\n' + printf ' "component": "%s",\n' "${COMPONENT}" + printf ' "action": "logs-list",\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}" "${files[@]}")" + printf ' "degraded_reasons": [],\n' + printf ' "next_steps": [],\n' + printf ' "logs": %s\n' "$(json_contract_array_from_args "${files[@]}")" + printf '}\n' +} + +emit_purge_logs_json() { + local stale_files=() + while IFS= read -r file; do + [ -n "${file}" ] || continue + stale_files+=("${file}") + done < <(find "${ARTIFACT_DIR}" -type f -name 'intelligence_tui-*.log' -mtime +"${RETENTION_DAYS}" -print | sort) + + local deleted_count="${#stale_files[@]}" + if [ "${deleted_count}" -gt 0 ] && [ "${APPLY}" -eq 1 ]; then + find "${ARTIFACT_DIR}" -type f -name 'intelligence_tui-*.log' -mtime +"${RETENTION_DAYS}" -delete + fi + + printf '{\n' + printf ' "contract_version": "cockpit-v1",\n' + printf ' "component": "%s",\n' "${COMPONENT}" + printf ' "action": "purge-logs",\n' + printf ' "status": "%s",\n' "$([ "${deleted_count}" -gt 0 ] && [ "${APPLY}" -eq 0 ] && printf 'degraded' || printf 'done')" + printf ' "contract_status": "%s",\n' "$([ "${deleted_count}" -gt 0 ] && [ "${APPLY}" -eq 0 ] && printf 'degraded' || printf 'ok')" + printf ' "log_file": "%s",\n' "${RUN_LOG}" + printf ' "artifacts": %s,\n' "$(json_contract_array_from_args "${RUN_LOG}" "${ARTIFACT_DIR}")" + printf ' "degraded_reasons": %s,\n' "$(json_contract_array_from_args $([ "${deleted_count}" -gt 0 ] && [ "${APPLY}" -eq 0 ] && printf '%s' "dry-run-purge-pending"))" + printf ' "next_steps": %s,\n' "$(json_contract_array_from_args "bash tools/cockpit/intelligence_tui.sh --action purge-logs --days ${RETENTION_DAYS} --apply --json")" + printf ' "retention_days": %s,\n' "${RETENTION_DAYS}" + printf ' "deleted_count": %s,\n' "${deleted_count}" + printf ' "apply": %s\n' "${APPLY}" + printf '}\n' +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --action) + ACTION="${2:-}" + shift 2 + ;; + --json) + JSON=1 + shift + ;; + --apply) + APPLY=1 + shift + ;; + --days) + RETENTION_DAYS="${2:-14}" + shift 2 + ;; + --limit) + LIMIT="${2:-8}" + 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 + +case "${ACTION}" in + status|summary-short|scorecard|comparison|recommendations|owners|todo|research|memory|next-actions) + if [ "${ACTION}" = "summary-short" ]; then + ensure_run_log + else + log_line "INFO" "action=${ACTION}" + fi + SNAPSHOT_JSON="$(build_snapshot_json)" + if [ "${ACTION}" = "summary-short" ]; then + SUMMARY_SHORT_JSON="$(emit_summary_short_json "${SNAPSHOT_JSON}")" + if [ "${JSON}" -eq 1 ]; then + printf '%s\n' "${SUMMARY_SHORT_JSON}" + else + render_summary_short_text "${SUMMARY_SHORT_JSON}" + fi + elif [ "${ACTION}" = "scorecard" ]; then + SCORECARD_JSON="$(emit_scorecard_json "${SNAPSHOT_JSON}")" + if [ "${JSON}" -eq 1 ]; then + printf '%s\n' "${SCORECARD_JSON}" + else + render_scorecard_text "${SCORECARD_JSON}" + fi + elif [ "${ACTION}" = "comparison" ]; then + COMPARISON_JSON="$(emit_comparison_json "${SNAPSHOT_JSON}")" + if [ "${JSON}" -eq 1 ]; then + printf '%s\n' "${COMPARISON_JSON}" + else + render_comparison_text "${COMPARISON_JSON}" + fi + elif [ "${ACTION}" = "recommendations" ]; then + RECOMMENDATIONS_JSON="$(emit_recommendations_json "${SNAPSHOT_JSON}")" + if [ "${JSON}" -eq 1 ]; then + printf '%s\n' "${RECOMMENDATIONS_JSON}" + else + render_recommendations_text "${RECOMMENDATIONS_JSON}" + fi + elif [ "${JSON}" -eq 1 ]; then + printf '%s\n' "${SNAPSHOT_JSON}" + else + render_snapshot_text "${SNAPSHOT_JSON}" + fi + ;; + audit) + log_line "INFO" "action=audit" + show_file "$(resolve_latest_doc "docs/KILL_LIFE_CONSOLIDATION_AUDIT_*.md" "docs/KILL_LIFE_CONSOLIDATION_AUDIT_2026-03-21.md")" + ;; + feature-map) + log_line "INFO" "action=feature-map" + show_file "$(resolve_latest_doc "docs/AGENTIC_INTELLIGENCE_FEATURE_MAP_*.md" "docs/AGENTIC_INTELLIGENCE_FEATURE_MAP_2026-03-21.md")" + ;; + spec) + log_line "INFO" "action=spec" + show_file "${ROOT_DIR}/specs/agentic_intelligence_integration_spec.md" + ;; + plan) + log_line "INFO" "action=plan" + show_file "${ROOT_DIR}/docs/plans/22_plan_integration_intelligence_agentique.md" + ;; + logs-summary) + log_line "INFO" "action=logs-summary" + if [ "${JSON}" -eq 1 ]; then + emit_logs_summary_json + else + LOGS_JSON="$(emit_logs_summary_json)" + python3 - "${LOGS_JSON}" <<'PY' +import json +import sys +payload = json.loads(sys.argv[1]) +print("# Intelligence logs summary") +print() +print(f"- count: {payload.get('count')}") +print(f"- stale: {payload.get('stale')}") +print(f"- latest_log: {payload.get('latest_log') or 'none'}") +PY + fi + ;; + logs-list) + log_line "INFO" "action=logs-list" + if [ "${JSON}" -eq 1 ]; then + emit_logs_list_json + else + printf '# Intelligence logs list\n\n' + find "${ARTIFACT_DIR}" -type f -name 'intelligence_tui-*.log' | sort + fi + ;; + logs-latest) + log_line "INFO" "action=logs-latest" + if [ "${JSON}" -eq 1 ]; then + latest_file="$(find "${ARTIFACT_DIR}" -type f -name 'intelligence_tui-*.log' | sort | tail -n 1 || true)" + printf '{\n' + printf ' "contract_version": "cockpit-v1",\n' + printf ' "component": "%s",\n' "${COMPONENT}" + printf ' "action": "logs-latest",\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}" "${latest_file}")" + printf ' "degraded_reasons": [],\n' + printf ' "next_steps": [],\n' + if [ -n "${latest_file}" ]; then + printf ' "latest_log": "%s"\n' "${latest_file}" + else + printf ' "latest_log": null\n' + fi + printf '}\n' + else + render_logs_latest + fi + ;; + purge-logs) + log_line "INFO" "action=purge-logs days=${RETENTION_DAYS} apply=${APPLY}" + if [ "${JSON}" -eq 1 ]; then + emit_purge_logs_json + else + PURGE_JSON="$(emit_purge_logs_json)" + python3 - "${PURGE_JSON}" <<'PY' +import json +import sys +payload = json.loads(sys.argv[1]) +print("# Intelligence log purge") +print() +print(f"- deleted_count: {payload.get('deleted_count')}") +print(f"- retention_days: {payload.get('retention_days')}") +print(f"- apply: {payload.get('apply')}") +PY + fi + ;; + *) + printf 'Unsupported action: %s\n' "${ACTION}" >&2 + usage >&2 + exit 2 + ;; +esac diff --git a/tools/cockpit/json_contract.sh b/tools/cockpit/json_contract.sh new file mode 100644 index 0000000..558cd38 --- /dev/null +++ b/tools/cockpit/json_contract.sh @@ -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 +} diff --git a/tools/cockpit/kill_life_mistral_governance_sync.sh b/tools/cockpit/kill_life_mistral_governance_sync.sh new file mode 100755 index 0000000..80790ad --- /dev/null +++ b/tools/cockpit/kill_life_mistral_governance_sync.sh @@ -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 diff --git a/tools/cockpit/load_mistral_governance_env.sh b/tools/cockpit/load_mistral_governance_env.sh new file mode 100755 index 0000000..857d051 --- /dev/null +++ b/tools/cockpit/load_mistral_governance_env.sh @@ -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 diff --git a/tools/cockpit/log_ops.sh b/tools/cockpit/log_ops.sh new file mode 100755 index 0000000..4bce4b8 --- /dev/null +++ b/tools/cockpit/log_ops.sh @@ -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 <&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 <"${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 } diff --git a/tools/cockpit/machine_registry.sh b/tools/cockpit/machine_registry.sh new file mode 100644 index 0000000..132e667 --- /dev/null +++ b/tools/cockpit/machine_registry.sh @@ -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 Action to run + --machine Filter a single machine id + --json Emit JSON when available + --days 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 diff --git a/tools/cockpit/mascarade_dispatch_mesh.sh b/tools/cockpit/mascarade_dispatch_mesh.sh new file mode 100755 index 0000000..b7d9dc7 --- /dev/null +++ b/tools/cockpit/mascarade_dispatch_mesh.sh @@ -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 diff --git a/tools/cockpit/mascarade_incident_registry.sh b/tools/cockpit/mascarade_incident_registry.sh new file mode 100644 index 0000000..1470d17 --- /dev/null +++ b/tools/cockpit/mascarade_incident_registry.sh @@ -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 < [--lines N] [--json] + +Options: + --action summary|brief|registry|queue|daily|watch + --lines 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 diff --git a/tools/cockpit/mascarade_logs_tui.sh b/tools/cockpit/mascarade_logs_tui.sh new file mode 100755 index 0000000..3ad468d --- /dev/null +++ b/tools/cockpit/mascarade_logs_tui.sh @@ -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 [options] + +Options: + --action summary|list|latest|purge + --days Retention window for purge/list analysis (default: 14) + --lines 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 diff --git a/tools/cockpit/mascarade_mesh_env_sync.sh b/tools/cockpit/mascarade_mesh_env_sync.sh new file mode 100755 index 0000000..77f379f --- /dev/null +++ b/tools/cockpit/mascarade_mesh_env_sync.sh @@ -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 <&2 + usage >&2 + exit 2 + ;; +esac diff --git a/tools/cockpit/mascarade_models_tui.sh b/tools/cockpit/mascarade_models_tui.sh new file mode 100644 index 0000000..7ef0b9c --- /dev/null +++ b/tools/cockpit/mascarade_models_tui.sh @@ -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 Action to run + --profile Profile id for show/env/prompt + --json Emit JSON when available + --days 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 diff --git a/tools/cockpit/mascarade_runtime_health.sh b/tools/cockpit/mascarade_runtime_health.sh new file mode 100755 index 0000000..088b2d9 --- /dev/null +++ b/tools/cockpit/mascarade_runtime_health.sh @@ -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 Remote host to inspect (default: kxkm@kxkm-ai) + --agent Agent used for low-cost smoke (default: kxkm-fallback-safe) + --log-dir 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 <&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 diff --git a/tools/cockpit/mesh_health_check.sh b/tools/cockpit/mesh_health_check.sh new file mode 100755 index 0000000..d01c340 --- /dev/null +++ b/tools/cockpit/mesh_health_check.sh @@ -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 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 < + Définit la stratégie de charge P2P. + - tower-first (défaut): clems -> kxkm -> cils -> local -> root (réserve) + - photon-safe: même ordre, mais CILS est totalement hors charge pour les préchecks non-SSH. + Ce mode évite les services non essentiels sur CILS et maintient les checks critiques + sur les hôtes de priorité plus haute/contrôlable. + Le runner calcule une charge relative par hôte (load/cpu) et allège + les vérifications non critiques sur les hôtes en surcharge. + Priorité métier: `tower (clems)` → `kxkm` → `cils (déchargé)` → `local` → `root (réserve reprise)`. + --photon-safe Alias de --load-profile photon-safe. + -h, --help Show this help message. + +This script snapshots the tri-repo mesh state across the local workspace and the +known SSH machines, then reports whether the observed state is `ready`, +`degraded`, or `blocked`. +USAGE +} + +json_escape() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +trim_spaces() { + printf '%s' "$1" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' +} + +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 +} + +run_ssh_command() { + local host="$1" + local port="$2" + shift 2 + + local script + local tmp_script + local output_file + local pid=0 + local waited=0 + local rc=124 + + script="$(cat)" + tmp_script="$(mktemp)" + output_file="$(mktemp)" + printf '%s\n' "${script}" > "${tmp_script}" + + ( + ssh -o BatchMode=yes \ + -o ConnectTimeout="${SSH_CONNECT_TIMEOUT}" \ + -o ServerAliveInterval="${SSH_KEEPALIVE_INTERVAL}" \ + -o ServerAliveCountMax="${SSH_KEEPALIVE_COUNT}" \ + -o StrictHostKeyChecking=accept-new \ + -p "${port}" \ + "${host}" \ + bash -s -- "$@" < "${tmp_script}" > "${output_file}" 2>&1 + ) & + pid=$! + + while (( waited < SSH_REMOTE_COMMAND_TIMEOUT )); do + if kill -0 "${pid}" 2>/dev/null; then + sleep 1 + waited=$((waited + 1)) + else + break + fi + done + + if kill -0 "${pid}" 2>/dev/null; then + kill -TERM "${pid}" 2>/dev/null || true + sleep 1 + kill -KILL "${pid}" 2>/dev/null || true + wait "${pid}" 2>/dev/null || true + else + wait "${pid}" && rc=0 || rc=$? + fi + + local output + output="$(cat "${output_file}")" + rm -f "${tmp_script}" "${output_file}" + printf '%s\n' "${output}" + return "${rc}" +} + +run_ssh_command_direct() { + local host="$1" + local port="$2" + shift 2 + + local script + local tmp_script + local output + local rc=0 + + script="$(cat)" + tmp_script="$(mktemp)" + printf '%s\n' "${script}" > "${tmp_script}" + + output="$( + ssh -o BatchMode=yes \ + -o ConnectTimeout="${SSH_CONNECT_TIMEOUT}" \ + -o ServerAliveInterval="${SSH_KEEPALIVE_INTERVAL}" \ + -o ServerAliveCountMax="${SSH_KEEPALIVE_COUNT}" \ + -o StrictHostKeyChecking=accept-new \ + -p "${port}" \ + "${host}" \ + bash -s -- "$@" < "${tmp_script}" 2>&1 + )" || rc=$? + + rm -f "${tmp_script}" + printf '%s\n' "${output}" + return "${rc}" +} + +resolve_optional_local_path() { + local candidate="" + for candidate in "$@"; do + [[ -n "${candidate}" ]] || continue + if [[ -d "${candidate}" ]]; then + (cd "${candidate}" >/dev/null 2>&1 && pwd) + return 0 + fi + done + return 1 +} + +load_machine_registry() { + local raw_registry="" + local id="" + local target="" + local port="" + local role="" + local priority="" + local placement="" + local profiles="" + local critical_repos="" + local non_essential_policy="" + local reserve_only="" + local load_order_bias="" + + if [[ ! -f "${REGISTRY_FILE}" ]]; then + REGISTRY_STATUS="missing" + log_line "WARN" "Machine registry missing (${REGISTRY_FILE}); using embedded defaults" + return 0 + fi + + if ! raw_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")) +for item in data.get("targets", []): + fields = [ + item.get("id", ""), + item.get("target", ""), + str(item.get("port", 0)), + item.get("role", ""), + str(item.get("priority", 0)), + item.get("placement", ""), + ",".join(item.get("enabled_profiles", [])), + ",".join(item.get("critical_repos", [])), + item.get("non_essential_policy", ""), + "true" if item.get("reserve_only") else "false", + str(item.get("load_order_bias", 0)), + ] + print("|".join(value.replace("|", "/") for value in fields)) +PY + )"; then + REGISTRY_STATUS="invalid" + log_line "WARN" "Machine registry unreadable (${REGISTRY_FILE}); using embedded defaults" + return 0 + fi + + if [[ -z "${raw_registry}" ]]; then + REGISTRY_STATUS="empty" + log_line "WARN" "Machine registry empty (${REGISTRY_FILE}); using embedded defaults" + return 0 + fi + + while IFS='|' read -r id target port role priority placement profiles critical_repos non_essential_policy reserve_only load_order_bias; do + [[ -n "${id}" && -n "${target}" ]] || continue + HOST_ID_BY_TARGET["${target}"]="${id}" + HOST_PORT_BY_TARGET["${target}"]="${port}" + HOST_ROLE_BY_TARGET["${target}"]="${role}" + HOST_PRIORITY_BY_TARGET["${target}"]="${priority}" + HOST_PLACEMENT_BY_TARGET["${target}"]="${placement}" + HOST_ENABLED_PROFILES_BY_TARGET["${target}"]="${profiles}" + HOST_CRITICAL_REPOS_BY_TARGET["${target}"]="${critical_repos}" + HOST_NON_ESSENTIAL_POLICY["${target}"]="${non_essential_policy}" + HOST_RESERVE_ONLY["${target}"]="${reserve_only}" + HOST_LOAD_BIAS["${target}"]="${load_order_bias}" + + case "${id}" in + tower) TOWER_HOST="${target}" ;; + kxkm) KXKM_HOST="${target}" ;; + cils) CILS_HOST="${target}" ;; + local) LOCAL_HOST_LABEL="${target}" ;; + root-reserve) ROOT_HOST="${target}" ;; + esac + done <<< "${raw_registry}" + + if [[ "${#HOST_ID_BY_TARGET[@]}" -eq 0 ]]; then + REGISTRY_STATUS="empty" + log_line "WARN" "Machine registry has no usable targets (${REGISTRY_FILE}); using embedded defaults" + return 0 + fi + + REGISTRY_STATUS="loaded" +} + +host_base_priority() { + local host="$1" + local priority="${HOST_PRIORITY_BY_TARGET["${host}"]:-}" + if [[ "${priority}" =~ ^[0-9]+$ ]] && [[ "${priority}" -gt 0 ]]; then + echo "${priority}" + return 0 + fi + case "${host}" in + "${TOWER_HOST}") echo 1 ;; + "${KXKM_HOST}") echo 2 ;; + "${CILS_HOST}") echo 3 ;; + "${LOCAL_HOST_LABEL}") echo 4 ;; + "${ROOT_HOST}") echo 5 ;; + *) echo 10 ;; + esac +} + +host_static_score() { + local host="$1" + local load_bias="${HOST_LOAD_BIAS["${host}"]:-}" + if [[ "${load_bias}" =~ ^[0-9]+$ ]] && [[ "${load_bias}" -gt 0 ]]; then + echo "${load_bias}" + return 0 + fi + echo $(( $(host_base_priority "${host}") * 1000 )) +} + +host_is_reserve_only() { + local host="$1" + if [[ "${HOST_RESERVE_ONLY["${host}"]:-false}" == "true" ]]; then + return 0 + fi + [[ "${host}" == "${ROOT_HOST}" ]] +} + +host_role_or_default() { + local host="$1" + local fallback="$2" + local role="${HOST_ROLE_BY_TARGET["${host}"]:-}" + if [[ -n "${role}" ]]; then + printf '%s\n' "${role}" + else + printf '%s\n' "${fallback}" + fi +} + +host_port_or_default() { + local host="$1" + local fallback="$2" + local port="${HOST_PORT_BY_TARGET["${host}"]:-}" + if [[ "${port}" =~ ^[0-9]+$ ]]; then + printf '%s\n' "${port}" + else + printf '%s\n' "${fallback}" + fi +} + +is_critical_repo() { + local host="$1" + local repo_name="$2" + local critical + local critical_repos="${HOST_CRITICAL_REPOS_BY_TARGET["${host}"]:-}" + local critical_items=() + + if [[ -n "${critical_repos}" ]]; then + IFS=',' read -r -a critical_items <<< "${critical_repos}" + else + critical_items=("${CRITICAL_REPOS[@]}") + fi + + for critical in "${critical_items[@]}"; do + if [[ "${repo_name}" == "${critical}" ]]; then + return 0 + fi + done + return 1 +} + +host_load_is_overloaded() { + local host="$1" + local ratio="${HOST_LOAD_RATIO["${host}"]:-0}" + if [[ "${HOST_CONNECT_STATE["${host}"]:-down}" != "ok" ]]; then + return 1 + fi + awk -v ratio="${ratio}" -v limit="${OVERLOAD_LOAD_RATIO_LIMIT}" 'BEGIN{exit (ratio > limit ? 0 : 1)}' +} + +collect_local_load_profile() { + local host="$1" + local base_score + local load_value="0" + local cpu_count="1" + local load_ratio="0" + local load_penalty="0" + local score + + if command -v nproc >/dev/null 2>&1; then + cpu_count="$(nproc 2>/dev/null || printf '%s' "${cpu_count}")" + elif command -v getconf >/dev/null 2>&1; then + cpu_count="$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf '%s' "${cpu_count}")" + fi + if command -v uptime >/dev/null 2>&1; then + load_value="$(uptime | sed -n 's/.*load average: //p' | awk -F',' '{print $1}' | tr -d ' ')" + fi + [[ "${load_value}" =~ ^[0-9]+(\.[0-9]+)?$ ]] || load_value="0" + if [[ "${cpu_count}" -le 0 ]]; then + cpu_count=1 + fi + load_ratio="$(awk -v load="${load_value}" -v cpus="${cpu_count}" 'BEGIN{ratio=load/cpus; if (ratio < 0 || cpus <= 0) ratio = 0; printf "%.3f", ratio}')" + load_penalty="$(awk -v ratio="${load_ratio}" 'BEGIN{printf "%d", ratio * 1000}')" + base_score="$(host_static_score "${host}")" + if host_is_reserve_only "${host}"; then + score=$((base_score + load_penalty + ROOT_RESERVE_SCORE_BONUS)) + else + score=$((base_score + load_penalty)) + fi + HOST_BASE_SCORE["${host}"]="${score}" + HOST_LOAD_RATIO["${host}"]="${load_ratio}" + HOST_LOAD_PENALTY["${host}"]="${load_penalty}" + HOST_LOAD_REASON["${host}"]="ready" + HOST_CONNECT_STATE["${host}"]="ok" +} + +collect_remote_load_profile() { + local host="$1" + local port="$2" + local result + local rc=0 + local load_value="0" + local cpu_count="1" + local load_ratio="0" + local load_penalty="0" + local base_score + local score + local output_line + local parsed_ok=1 + + if ! result="$( + run_ssh_command "${host}" "${port}" <<'EOF' +load_value="0" +cpu_count="1" +if [ -f /proc/loadavg ]; then + load_value=$(awk '{print $1}' /proc/loadavg) +elif command -v uptime >/dev/null 2>&1; then + load_value=$(uptime | sed -n 's/.*load average: //p' | awk -F',' '{print $1}' | tr -d ' ') +fi +if [ -z "${load_value}" ]; then + load_value="0" +fi +if command -v getconf >/dev/null 2>&1; then + cpu_count=$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf '%s' "${cpu_count}") +elif command -v nproc >/dev/null 2>&1; then + cpu_count=$(nproc 2>/dev/null || printf '%s' "${cpu_count}") +fi +if [ -z "${cpu_count}" ] || [ "${cpu_count}" -le 0 ]; then + cpu_count=1 +fi +printf '%s|%s\n' "${load_value}" "${cpu_count}" +EOF + )"; then + rc=$? + fi + if [[ "${rc}" -ne 0 ]]; then + if host_is_reserve_only "${host}"; then + HOST_BASE_SCORE["${host}"]=$((99999 + ROOT_RESERVE_SCORE_BONUS)) + else + HOST_BASE_SCORE["${host}"]=99999 + fi + HOST_LOAD_RATIO["${host}"]="999" + HOST_LOAD_PENALTY["${host}"]="999" + HOST_LOAD_REASON["${host}"]="unreachable" + HOST_CONNECT_STATE["${host}"]="unreachable" + return 0 + fi + + output_line="$(printf '%s' "${result}" | tr -d '\r' | sed '/^$/d' | tail -n 1 || true)" + if [[ -z "${output_line}" ]]; then + if host_is_reserve_only "${host}"; then + HOST_BASE_SCORE["${host}"]=$((99999 + ROOT_RESERVE_SCORE_BONUS)) + else + HOST_BASE_SCORE["${host}"]=99999 + fi + HOST_LOAD_RATIO["${host}"]="999" + HOST_LOAD_PENALTY["${host}"]="999" + HOST_LOAD_REASON["${host}"]="unreachable:empty-load-output" + HOST_CONNECT_STATE["${host}"]="unreachable" + return 0 + fi + + output_line="" + while IFS= read -r parsed_line; do + if [[ "${parsed_line}" =~ ^[[:space:]]*[0-9]+(\.[0-9]+)?[[:space:]]*\|[[:space:]]*[0-9]+[[:space:]]*$ ]]; then + output_line="$(printf '%s' "${parsed_line}" | tr -d '[:space:]')" + fi + done <<< "$(printf '%s' "${result}" | tr -d '\r' | awk '/\|/ { print }')" + if [[ -z "${output_line}" ]]; then + base_score="$(host_static_score "${host}")" + if host_is_reserve_only "${host}"; then + HOST_BASE_SCORE["${host}"]=$((base_score + 40 + ROOT_RESERVE_SCORE_BONUS)) + else + HOST_BASE_SCORE["${host}"]=$((base_score + 40)) + fi + HOST_LOAD_RATIO["${host}"]="0.000" + HOST_LOAD_PENALTY["${host}"]="40" + HOST_LOAD_REASON["${host}"]="degraded:invalid-load-output" + HOST_CONNECT_STATE["${host}"]="degraded" + return 0 + fi + output_line="$(printf '%s' "${output_line}" | sed 's/[[:space:]]\+/ /g' | sed 's/^ *//;s/ *$//')" + cpu_count="$(printf '%s' "${output_line##*|}" | tr -d '[:space:]')" + load_value="$(printf '%s' "${output_line%%|*}" | tr -d '[:space:]')" + [[ "${load_value}" =~ ^[0-9]+(\.[0-9]+)?$ ]] || load_value="0" + if ! [[ "${cpu_count}" =~ ^[0-9]+$ ]]; then + parsed_ok=0 + cpu_count="1" + fi + + if [[ "${cpu_count}" -le 0 ]]; then + cpu_count=1 + fi + if [[ "${parsed_ok}" -eq 0 ]]; then + log_line "WARN" "Invalid cpu_count for ${host}; forcing safe default: '${cpu_count}'" + fi + load_ratio="$(awk -v load="${load_value}" -v cpus="${cpu_count}" 'BEGIN{ratio=load/cpus; if (ratio < 0 || cpus <= 0) ratio = 0; printf "%.3f", ratio}')" + load_penalty="$(awk -v ratio="${load_ratio}" 'BEGIN{printf "%d", ratio * 1000}')" + if awk -v ratio="${load_ratio}" -v limit="${OVERLOAD_LOAD_RATIO_LIMIT}" 'BEGIN{exit (ratio > limit ? 0 : 1)}'; then + HOST_LOAD_REASON["${host}"]="overload" + else + HOST_LOAD_REASON["${host}"]="ready" + fi + base_score="$(host_static_score "${host}")" + if host_is_reserve_only "${host}"; then + score=$((base_score + load_penalty + ROOT_RESERVE_SCORE_BONUS)) + else + score=$((base_score + load_penalty)) + fi + HOST_BASE_SCORE["${host}"]="${score}" + HOST_LOAD_RATIO["${host}"]="${load_ratio}" + HOST_LOAD_PENALTY["${host}"]="${load_penalty}" + HOST_CONNECT_STATE["${host}"]="ok" +} + +build_host_profiles() { + local host + local port + local entry + declare -A seen_hosts=() + + for entry in "${TARGETS[@]}"; do + IFS='|' read -r host port _ <<< "${entry}" + if [[ -n "${seen_hosts["${host}"]+x}" ]]; then + continue + fi + seen_hosts["${host}"]=1 + if [[ "${LOAD_PROFILE}" == "photon-safe" ]] && [[ "${HOST_ID_BY_TARGET["${host}"]:-}" == "cils" ]]; then + HOST_BASE_SCORE["${host}"]=99999 + HOST_LOAD_RATIO["${host}"]="999" + HOST_LOAD_PENALTY["${host}"]="999" + HOST_LOAD_REASON["${host}"]="cils-load-check-skipped-photon-safe" + HOST_CONNECT_STATE["${host}"]="skipped" + continue + fi + if [[ "${host}" == "${LOCAL_HOST_LABEL}" ]]; then + collect_local_load_profile "${host}" + else + collect_remote_load_profile "${host}" "${port}" + fi + done + + HOST_ORDER=() + while IFS='|' read -r _score host; do + [[ -n "${host}" ]] || continue + HOST_ORDER+=("${host}") + done < <(for host in "${!HOST_BASE_SCORE[@]}"; do + printf '%s|%s\n' "${HOST_BASE_SCORE["${host}"]}" "${host}" + done | sort -t'|' -k1,1n) + + if [[ "${#HOST_ORDER[@]}" -eq 0 ]]; then + log_line "WARN" "No host found for profiling; fallback to static order." + TARGETS_ORDERED=("${TARGETS[@]}") + fi +} + +build_targets_ordered() { + local host + local entry + TARGETS_ORDERED=() + for host in "${HOST_ORDER[@]}"; do + for entry in "${TARGETS[@]}"; do + local target_host + IFS='|' read -r target_host _ <<< "${entry}" + if [[ "${target_host}" == "${host}" ]]; then + TARGETS_ORDERED+=("${entry}") + fi + done + done + + if [[ "${#TARGETS_ORDERED[@]}" -eq 0 ]]; then + TARGETS_ORDERED=("${TARGETS[@]}") + fi +} + +append_load_skip_entry() { + local repo_name="$1" + local target_host="$2" + local role="$3" + local repo_path="$4" + local reason="$5" + append_result \ + "${repo_name}" "${target_host}" "${role}" "${repo_path}" "degraded" "" "load-skipped:${reason}" "" "1" "${reason}" "no" +} + +build_targets() { + local local_kill_life="" + local local_mascarade="" + local local_crazy="" + + local_kill_life="$( + resolve_optional_local_path \ + "${PROJECT_DIR}/../Github_Repos/Perso/Kill_LIFE-main" \ + "${PROJECT_DIR}/../Kill_LIFE-main" \ + "${PROJECT_DIR}" \ + || true + )" + + local_mascarade="$( + resolve_optional_local_path \ + "${PROJECT_DIR}/../mascarade-main" \ + "${PROJECT_DIR}/../mascarade" \ + "${PROJECT_DIR}/../Github_Repos/Perso/mascarade-main" \ + "${PROJECT_DIR}/../mascarade-github" \ + "${PROJECT_DIR}/../Github_Repos/Perso/mascarade" \ + "${PROJECT_DIR}/../Github_Repos/mascarade" \ + || true + )" + if [[ -n "${local_mascarade}" ]]; then + TARGETS+=("local|0|Workspace companion|mascarade|${local_mascarade}|docker-compose.yml") + fi + + local_crazy="$( + resolve_optional_local_path \ + "${PROJECT_DIR}/../crazy_life-main" \ + "${PROJECT_DIR}/../crazy_life" \ + "${PROJECT_DIR}/../crazy-life" \ + "${PROJECT_DIR}/../Github_Repos/Perso/crazy_life-main" \ + "${PROJECT_DIR}/../Github_Repos/Perso/crazy_life" \ + "${PROJECT_DIR}/../Github_Repos/Perso/crazy-life" \ + || true + )" + TARGETS+=( + "${TOWER_HOST}|$(host_port_or_default "${TOWER_HOST}" 22)|$(host_role_or_default "${TOWER_HOST}" "Machine de pilotage / orchestration locale")|Kill_LIFE|/home/clems/Kill_LIFE-main|tools/repo_state/repo_refresh.sh" + "${TOWER_HOST}|$(host_port_or_default "${TOWER_HOST}" 22)|$(host_role_or_default "${TOWER_HOST}" "Machine de pilotage / orchestration locale")|mascarade|/home/clems/mascarade-main|docker-compose.yml" + "${TOWER_HOST}|$(host_port_or_default "${TOWER_HOST}" 22)|$(host_role_or_default "${TOWER_HOST}" "Machine de pilotage / orchestration locale")|crazy_life|/home/clems/crazy_life-main|" + "${KXKM_HOST}|$(host_port_or_default "${KXKM_HOST}" 22)|$(host_role_or_default "${KXKM_HOST}" "Mac opérateur")|Kill_LIFE|/home/kxkm/Kill_LIFE-main|tools/repo_state/repo_refresh.sh" + "${KXKM_HOST}|$(host_port_or_default "${KXKM_HOST}" 22)|$(host_role_or_default "${KXKM_HOST}" "Mac opérateur")|mascarade|/home/kxkm/mascarade-main|docker-compose.yml" + "${KXKM_HOST}|$(host_port_or_default "${KXKM_HOST}" 22)|$(host_role_or_default "${KXKM_HOST}" "Mac opérateur")|crazy_life|/home/kxkm/crazy_life-main|" + "${CILS_HOST}|$(host_port_or_default "${CILS_HOST}" 22)|$(host_role_or_default "${CILS_HOST}" "Mac opérateur secondaire")|Kill_LIFE|/Users/cils/Kill_LIFE-main|tools/repo_state/repo_refresh.sh" + "${CILS_HOST}|$(host_port_or_default "${CILS_HOST}" 22)|$(host_role_or_default "${CILS_HOST}" "Mac opérateur secondaire")|mascarade|/Users/cils/mascarade-main|docker-compose.yml" + "${CILS_HOST}|$(host_port_or_default "${CILS_HOST}" 22)|$(host_role_or_default "${CILS_HOST}" "Mac opérateur secondaire")|crazy_life|/Users/cils/crazy_life-main|" + "${ROOT_HOST}|$(host_port_or_default "${ROOT_HOST}" 22)|$(host_role_or_default "${ROOT_HOST}" "Serveur système / exécution matérielle")|Kill_LIFE|/root/Kill_LIFE-main|tools/repo_state/repo_refresh.sh" + "${ROOT_HOST}|$(host_port_or_default "${ROOT_HOST}" 22)|$(host_role_or_default "${ROOT_HOST}" "Serveur système / exécution matérielle")|mascarade|/root/mascarade-main|docker-compose.yml" + "${ROOT_HOST}|$(host_port_or_default "${ROOT_HOST}" 22)|$(host_role_or_default "${ROOT_HOST}" "Serveur système / exécution matérielle")|crazy_life|/root/crazy_life-main|" + ) + + if [[ -n "${local_kill_life}" ]]; then + TARGETS+=("${LOCAL_HOST_LABEL}|0|Workspace local|Kill_LIFE|${local_kill_life}|tools/repo_state/repo_refresh.sh") + fi + if [[ -n "${local_mascarade}" ]]; then + TARGETS+=("${LOCAL_HOST_LABEL}|0|Workspace companion|mascarade|${local_mascarade}|docker-compose.yml") + fi + if [[ -n "${local_crazy}" ]]; then + TARGETS+=("${LOCAL_HOST_LABEL}|0|Workspace companion|crazy_life|${local_crazy}|") + fi +} + +should_skip_entry() { + local host="$1" + local repo_name="$2" + local reason="" + local host_id="${HOST_ID_BY_TARGET["${host}"]:-}" + + if [[ "${LOAD_PROFILE}" == "photon-safe" ]] && [[ "${host_id}" == "cils" ]]; then + reason="cils-lockdown-photon-safe" + echo "${reason}" + return 0 + fi + + if [[ "${host_id}" == "cils" ]] && ! is_critical_repo "${host}" "${repo_name}"; then + reason="cils-lockdown" + if [[ "${LOAD_PROFILE}" == "photon-safe" ]]; then + reason="cils-lockdown-photon-safe" + fi + echo "${reason}" + return 0 + fi + + if [[ "${host}" != "${LOCAL_HOST_LABEL}" ]] && host_load_is_overloaded "${host}" && ! is_critical_repo "${host}" "${repo_name}"; then + reason="overload-${HOST_LOAD_RATIO["${host}"]}" + echo "${reason}" + return 0 + fi + + return 1 +} + +append_result() { + local repo_name="$1" + local target_host="$2" + local role="$3" + local repo_path="$4" + local status="$5" + local branch="$6" + local sha="$7" + local remote="$8" + local dirty_count="$9" + local dirty_sample="${10}" + local script_ok="${11}" + + RESULTS+=("${repo_name}|${target_host}|${role}|${repo_path}|${status}|${branch}|${sha}|${remote}|${dirty_count}|${dirty_sample}|${script_ok}") +} + +companion_preflight_script_for_repo() { + local repo_name="$1" + case "${repo_name}" in + mascarade) printf '%s\n' "scripts/mesh_runtime_preflight.sh" ;; + crazy_life) printf '%s\n' "scripts/mesh_web_preflight.sh" ;; + *) return 1 ;; + esac +} + +snapshot_kv_lines_from_output() { + local parsed_line="" + local key="" + local value="" + + while IFS= read -r parsed_line; do + parsed_line="$(trim_spaces "${parsed_line}")" + [[ -z "${parsed_line}" ]] && continue + + if [[ "${parsed_line}" == *"="* ]]; then + printf '%s\n' "${parsed_line}" + continue + fi + + case "${parsed_line}" in + \"status\"*|\"branch\"*|\"sha\"*|\"remote\"*|\"dirty_count\"*|\"dirty_sample\"*|\"script_ok\"*|\"required_ok\"*) + key="$(printf '%s\n' "${parsed_line}" | sed -n 's/^[[:space:]]*"\([^"]*\)".*/\1/p')" + value="$(printf '%s\n' "${parsed_line}" | sed -n 's/^[[:space:]]*"[^"]*":[[:space:]]*//p')" + value="$(printf '%s\n' "${value}" | sed 's/,[[:space:]]*$//')" + value="$(trim_spaces "${value}")" + value="${value#\"}" + value="${value%\"}" + [[ "${key}" == "required_ok" ]] && key="script_ok" + if [[ -n "${key}" ]]; then + printf '%s=%s\n' "${key}" "${value}" + fi + ;; + esac + done +} + +extract_snapshot_json_field() { + local json_payload="$1" + local field_name="$2" + printf '%s\n' "${json_payload}" | sed -n "s/.*\"${field_name}\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" | head -n 1 +} + +extract_snapshot_log_kv_field() { + local log_payload="$1" + local field_name="$2" + printf '%s\n' "${log_payload}" | sed -n "s/.*${field_name}=\\([^[:space:]]*\\).*/\\1/p" | head -n 1 +} + +extract_snapshot_log_branch() { + local log_payload="$1" + printf '%s\n' "${log_payload}" | sed -n 's/.* branch=\([^[:space:]]*\).*/\1/p' | head -n 1 +} + +extract_snapshot_log_sha() { + local log_payload="$1" + printf '%s\n' "${log_payload}" | sed -n 's/.* sha=\([^[:space:]]*\).*/\1/p' | head -n 1 +} + +extract_snapshot_log_dirty_count() { + local log_payload="$1" + printf '%s\n' "${log_payload}" | sed -n 's/.* dirty=\([0-9][0-9]*\).*/\1/p' | head -n 1 +} + +resolve_remote_repo_path() { + local target_host="$1" + local port="$2" + local candidate_paths="$3" + local resolved_path="" + + if ! resolved_path="$( + run_ssh_command "${target_host}" "${port}" "${candidate_paths}" <<'EOF' +candidate_paths="$1" +selected="" +IFS=',' read -r -a candidates <<< "${candidate_paths}" +for candidate in "${candidates[@]}"; do + if [ -d "${candidate}/.git" ]; then + printf '%s\n' "${candidate}" + exit 0 + fi +done +for candidate in "${candidates[@]}"; do + if [ -d "${candidate}" ]; then + printf '%s\n' "${candidate}" + exit 0 + fi +done +printf '%s\n' "${candidates[0]}" +EOF + )"; then + log_line "WARN" "resolve_remote_repo_path timeout/unreachable host ${target_host}:${port}" + fi + + if [[ -n "${resolved_path}" ]]; then + printf '%s\n' "${resolved_path}" + else + printf '%s\n' "${candidate_paths%%,*}" + fi +} + +snapshot_repo_local() { + local repo_name="$1" + local target_host="$2" + local role="$3" + local repo_path="$4" + local required_path="$5" + local status="ready" + local branch="" + local sha="" + local remote="" + local dirty_count="0" + local dirty_sample="" + local script_ok="no" + local dirty_output="" + + if [[ ! -d "${repo_path}" ]]; then + status="blocked" + elif ! git -C "${repo_path}" rev-parse --git-dir >/dev/null 2>&1; then + status="blocked" + else + sha="$(git -C "${repo_path}" rev-parse HEAD 2>/dev/null || true)" + branch="$(git -C "${repo_path}" branch --show-current 2>/dev/null || true)" + remote="$(git -C "${repo_path}" remote get-url origin 2>/dev/null || true)" + dirty_output="$(git -C "${repo_path}" status --short 2>/dev/null || true)" + dirty_count="$(printf '%s\n' "${dirty_output}" | sed '/^$/d' | wc -l | tr -d ' ')" + dirty_sample="$(printf '%s\n' "${dirty_output}" | sed -n '1,3p' | tr '\n' ';' | sed 's/;*$//' | tr '|' '/')" + if [[ -z "${required_path}" || -e "${repo_path}/${required_path}" ]]; then + script_ok="yes" + fi + if [[ "${script_ok}" != "yes" ]]; then + status="degraded" + fi + fi + + append_result \ + "${repo_name}" "${target_host}" "${role}" "${repo_path}" "${status}" "${branch}" "${sha}" "${remote}" "${dirty_count}" "${dirty_sample}" "${script_ok}" +} + +snapshot_repo_remote() { + local target_host="$1" + local port="$2" + local role="$3" + local repo_name="$4" + local repo_path_field="$5" + local required_path="$6" + local remote_payload_script="" + local repo_path="" + local output="" + local rc=0 + local status="" + local branch="" + local sha="" + local remote="" + local dirty_count="0" + local dirty_sample="" + local script_ok="no" + local parsed_line="" + local normalized_output="" + local key="" + local value="" + local parsed_values=0 + local fallback_script="" + + repo_path="${repo_path_field}" + remote_payload_script="$(cat <<'EOF' +repo_path="$1" +required_path="$2" +repo_status="ready" +branch="" +sha="" +remote="" +dirty_count="0" +dirty_sample="" +script_ok="no" +if [ ! -d "${repo_path}" ]; then + repo_status="blocked" +elif ! git -C "${repo_path}" rev-parse --git-dir >/dev/null 2>&1; then + repo_status="blocked" +else + sha=$(git -C "${repo_path}" rev-parse HEAD 2>/dev/null || true) + branch=$(git -C "${repo_path}" branch --show-current 2>/dev/null || true) + remote=$(git -C "${repo_path}" remote get-url origin 2>/dev/null || true) + dirty_output=$(git -C "${repo_path}" status --short 2>/dev/null || true) + dirty_count=$(printf '%s\n' "${dirty_output}" | sed '/^$/d' | wc -l | tr -d ' ') + dirty_sample=$(printf '%s\n' "${dirty_output}" | sed -n '1,3p' | tr '\n' ';' | sed 's/;*$//' | tr '|' '/') + if [ -z "${required_path}" ] || [ -e "${repo_path}/${required_path}" ]; then + script_ok="yes" + fi +if [ "${script_ok}" != "yes" ]; then + repo_status="degraded" +fi +fi +printf 'status=%s\nbranch=%s\nsha=%s\nremote=%s\ndirty_count=%s\ndirty_sample=%s\nscript_ok=%s\n' \ + "${repo_status}" "${branch}" "${sha}" "${remote}" "${dirty_count}" "${dirty_sample}" "${script_ok}" +EOF +)" + if ! output="$(run_ssh_command "${target_host}" "${port}" "${repo_path}" "${required_path}" <<< "${remote_payload_script}")"; then + rc=$? + fi + + normalized_output="$(printf '%s' "${output}" | tr -d '\r')" + while IFS='=' read -r key value; do + key="$(trim_spaces "${key}")" + value="$(trim_spaces "${value}")" + + case "${key}" in + status) status="${value}"; parsed_values=$((parsed_values + 1)) ;; + branch) branch="${value}" ; parsed_values=$((parsed_values + 1)) ;; + sha) sha="${value}" ; parsed_values=$((parsed_values + 1)) ;; + remote) remote="${value}" ; parsed_values=$((parsed_values + 1)) ;; + dirty_count) dirty_count="${value}" ; parsed_values=$((parsed_values + 1)) ;; + dirty_sample) dirty_sample="${value}" ; parsed_values=$((parsed_values + 1)) ;; + script_ok) script_ok="${value}" ; parsed_values=$((parsed_values + 1)) ;; + esac + done < <(printf '%s\n' "${normalized_output}" | snapshot_kv_lines_from_output) + + if [[ "${parsed_values}" -eq 0 ]]; then + log_line "INFO" "Remote snapshot retry host=${target_host} repo=${repo_name} mode=generic" + output="" + rc=0 + if ! output="$(run_ssh_command "${target_host}" "${port}" "${repo_path}" "${required_path}" <<< "${remote_payload_script}")"; then + rc=$? + fi + + normalized_output="$(printf '%s' "${output}" | tr -d '\r')" + while IFS='=' read -r key value; do + key="$(trim_spaces "${key}")" + value="$(trim_spaces "${value}")" + + case "${key}" in + status) status="${value}"; parsed_values=$((parsed_values + 1)) ;; + branch) branch="${value}" ; parsed_values=$((parsed_values + 1)) ;; + sha) sha="${value}" ; parsed_values=$((parsed_values + 1)) ;; + remote) remote="${value}" ; parsed_values=$((parsed_values + 1)) ;; + dirty_count) dirty_count="${value}" ; parsed_values=$((parsed_values + 1)) ;; + dirty_sample) dirty_sample="${value}" ; parsed_values=$((parsed_values + 1)) ;; + script_ok) script_ok="${value}" ; parsed_values=$((parsed_values + 1)) ;; + esac + done < <(printf '%s\n' "${normalized_output}" | snapshot_kv_lines_from_output) + fi + + if [[ "${parsed_values}" -eq 0 ]]; then + fallback_script="$(companion_preflight_script_for_repo "${repo_name}" || true)" + if [[ -n "${fallback_script}" ]]; then + log_line "INFO" "Remote snapshot fallback host=${target_host} repo=${repo_name} via=${fallback_script}" + output="" + rc=0 + if ! output="$(run_ssh_command_direct "${target_host}" "${port}" "${repo_path}" "${fallback_script}" <<'EOF' +repo_path="$1" +fallback_script="$2" + +if [ -d "${repo_path}" ] && [ -x "${repo_path}/${fallback_script}" ]; then + cd "${repo_path}" >/dev/null 2>&1 || exit 1 + "${repo_path}/${fallback_script}" --json 2>/dev/null || exit 1 +fi +EOF + )"; then + rc=$? + fi + + normalized_output="$(printf '%s' "${output}" | tr -d '\r')" + while IFS='=' read -r key value; do + key="$(trim_spaces "${key}")" + value="$(trim_spaces "${value}")" + + case "${key}" in + status) status="${value}"; parsed_values=$((parsed_values + 1)) ;; + branch) branch="${value}" ; parsed_values=$((parsed_values + 1)) ;; + sha) sha="${value}" ; parsed_values=$((parsed_values + 1)) ;; + remote) remote="${value}" ; parsed_values=$((parsed_values + 1)) ;; + dirty_count) dirty_count="${value}" ; parsed_values=$((parsed_values + 1)) ;; + dirty_sample) dirty_sample="${value}" ; parsed_values=$((parsed_values + 1)) ;; + script_ok) script_ok="${value}" ; parsed_values=$((parsed_values + 1)) ;; + esac + done < <(printf '%s\n' "${normalized_output}" | snapshot_kv_lines_from_output) + + if [[ "${parsed_values}" -eq 0 ]]; then + log_line "INFO" "Remote snapshot fallback retry host=${target_host} repo=${repo_name} via=${fallback_script}" + output="" + rc=0 + if ! output="$(run_ssh_command_direct "${target_host}" "${port}" "${repo_path}" "${fallback_script}" <<'EOF' +repo_path="$1" +fallback_script="$2" + +if [ -d "${repo_path}" ] && [ -x "${repo_path}/${fallback_script}" ]; then + cd "${repo_path}" >/dev/null 2>&1 || exit 1 + "${repo_path}/${fallback_script}" --json 2>/dev/null || exit 1 +fi +EOF + )"; then + rc=$? + fi + + normalized_output="$(printf '%s' "${output}" | tr -d '\r')" + while IFS='=' read -r key value; do + key="$(trim_spaces "${key}")" + value="$(trim_spaces "${value}")" + + case "${key}" in + status) status="${value}"; parsed_values=$((parsed_values + 1)) ;; + branch) branch="${value}" ; parsed_values=$((parsed_values + 1)) ;; + sha) sha="${value}" ; parsed_values=$((parsed_values + 1)) ;; + remote) remote="${value}" ; parsed_values=$((parsed_values + 1)) ;; + dirty_count) dirty_count="${value}" ; parsed_values=$((parsed_values + 1)) ;; + dirty_sample) dirty_sample="${value}" ; parsed_values=$((parsed_values + 1)) ;; + script_ok) script_ok="${value}" ; parsed_values=$((parsed_values + 1)) ;; + esac + done < <(printf '%s\n' "${normalized_output}" | snapshot_kv_lines_from_output) + fi + + if [[ "${parsed_values}" -eq 0 ]]; then + log_line "INFO" "Remote snapshot fallback direct-exec host=${target_host} repo=${repo_name} via=${fallback_script}" + output="$( + ssh -o BatchMode=yes \ + -o ConnectTimeout="${SSH_CONNECT_TIMEOUT}" \ + -o ServerAliveInterval="${SSH_KEEPALIVE_INTERVAL}" \ + -o ServerAliveCountMax="${SSH_KEEPALIVE_COUNT}" \ + -o StrictHostKeyChecking=accept-new \ + -p "${port}" \ + "${target_host}" \ + "if [ -x \"${repo_path}/${fallback_script}\" ]; then bash \"${repo_path}/${fallback_script}\" --json 2>/dev/null; fi" 2>&1 + )" || rc=$? + + normalized_output="$(printf '%s' "${output}" | tr -d '\r')" + fi + + if [[ "${parsed_values}" -eq 0 ]]; then + status="$(extract_snapshot_json_field "${normalized_output}" "status")" + branch="$(extract_snapshot_json_field "${normalized_output}" "branch")" + sha="$(extract_snapshot_json_field "${normalized_output}" "sha")" + remote="$(extract_snapshot_json_field "${normalized_output}" "remote")" + dirty_count="$(extract_snapshot_json_field "${normalized_output}" "dirty_count")" + dirty_sample="$(extract_snapshot_json_field "${normalized_output}" "dirty_sample")" + script_ok="$(extract_snapshot_json_field "${normalized_output}" "script_ok")" + if [[ -z "${script_ok}" ]]; then + script_ok="$(extract_snapshot_json_field "${normalized_output}" "required_ok")" + fi + if [[ -n "${status}${branch}${sha}${remote}${dirty_count}${dirty_sample}${script_ok}" ]]; then + parsed_values=7 + fi + fi + + if [[ "${parsed_values}" -eq 0 ]]; then + status="$(extract_snapshot_log_kv_field "${normalized_output}" "runtime_mesh_status")" + if [[ -z "${status}" ]]; then + status="$(extract_snapshot_log_kv_field "${normalized_output}" "web_mesh_status")" + fi + branch="$(extract_snapshot_log_branch "${normalized_output}")" + sha="$(extract_snapshot_log_sha "${normalized_output}")" + dirty_count="$(extract_snapshot_log_dirty_count "${normalized_output}")" + script_ok="$(extract_snapshot_log_kv_field "${normalized_output}" "required_ok")" + if [[ -z "${script_ok}" ]]; then + script_ok="$(extract_snapshot_log_kv_field "${normalized_output}" "contract_ok")" + fi + if [[ -n "${status}${branch}${sha}${dirty_count}${script_ok}" ]]; then + parsed_values=7 + fi + fi + fi + fi + + if [[ "${status}" != "ready" && "${status}" != "degraded" && "${status}" != "blocked" ]]; then + status="degraded" + fi + if [[ -z "${status}" ]]; then + if [[ "${rc}" -ne 0 && -z "${normalized_output}" ]]; then + status="blocked" + else + status="degraded" + fi + elif [[ "${status}" == "blocked" && "${parsed_values}" -eq 0 ]]; then + status="degraded" + fi + if [[ "${status}" == "ready" && "${script_ok}" != "yes" ]]; then + status="degraded" + fi + + log_line "DEBUG" "Remote snapshot host=${target_host} repo=${repo_name} status=${status} rc=${rc} fields_parsed=${parsed_values}" + + append_result \ + "${repo_name}" "${target_host}" "${role}" "${repo_path}" "${status}" "${branch}" "${sha}" "${remote}" "${dirty_count}" "${dirty_sample}" "${script_ok}" +} + +repo_has_entries() { + local repo_name="$1" + local entry="" + for entry in "${RESULTS[@]}"; do + IFS='|' read -r current_repo _rest <<< "${entry}" + if [[ "${current_repo}" == "${repo_name}" ]]; then + return 0 + fi + done + return 1 +} + +repo_convergence_status() { + local repo_name="$1" + local entry="" + local seen=0 + local baseline="" + local status="ready" + + for entry in "${RESULTS[@]}"; do + local current_repo="" + local current_status="" + local branch="" + local sha="" + local remote="" + local dirty_count="" + local dirty_sample="" + local script_ok="" + IFS='|' read -r current_repo _target _role _path current_status branch sha remote dirty_count dirty_sample script_ok <<< "${entry}" + [[ "${current_repo}" == "${repo_name}" ]] || continue + seen=1 + if [[ "${current_status}" == "blocked" ]]; then + status="blocked" + break + fi + local signature="${current_status}|${branch}|${sha}|${remote}|${dirty_count}|${dirty_sample}|${script_ok}" + if [[ -z "${baseline}" ]]; then + baseline="${signature}" + elif [[ "${signature}" != "${baseline}" ]]; then + status="degraded" + fi + done + + if [[ "${seen}" -eq 0 ]]; then + status="blocked" + fi + printf '%s' "${status}" +} + +overall_mesh_status() { + local repo_name="" + local repo_status="" + local overall="ready" + for repo_name in Kill_LIFE mascarade crazy_life; do + if ! repo_has_entries "${repo_name}"; then + continue + fi + repo_status="$(repo_convergence_status "${repo_name}")" + if [[ "${repo_status}" == "blocked" ]]; then + overall="blocked" + break + fi + if [[ "${repo_status}" == "degraded" ]]; then + overall="degraded" + fi + done + printf '%s' "${overall}" +} + +emit_json() { + local overall="$1" + local generated_at + local repo_name="" + local entry="" + local first=1 + + generated_at="$(date '+%Y-%m-%d %H:%M:%S %z')" + printf '{\n' + printf ' "generated_at": "%s",\n' "$(json_escape "${generated_at}")" + printf ' "registry_file": "%s",\n' "$(json_escape "${REGISTRY_FILE}")" + printf ' "registry_status": "%s",\n' "$(json_escape "${REGISTRY_STATUS}")" + printf ' "load_profile": "%s",\n' "$(json_escape "${LOAD_PROFILE}")" + printf ' "load_overload_limit": "%s",\n' "$(json_escape "${OVERLOAD_LOAD_RATIO_LIMIT}")" + printf ' "host_order": [\n' + first=1 + for host in "${HOST_ORDER[@]}"; do + if [[ "${first}" -eq 0 ]]; then + printf ',\n' + fi + printf ' "%s"' "$(json_escape "${host}")" + first=0 + done + printf '\n ],\n' + printf ' "host_profiles": [\n' + first=1 + for host in "${HOST_ORDER[@]}"; do + if [[ "${first}" -eq 0 ]]; then + printf ',\n' + fi + printf ' {"id":"%s","target":"%s","role":"%s","placement":"%s","reserve_only":"%s","score":"%s","ratio":"%s","reason":"%s","state":"%s"}' \ + "$(json_escape "${HOST_ID_BY_TARGET["${host}"]:-unknown}")" \ + "$(json_escape "${host}")" \ + "$(json_escape "$(host_role_or_default "${host}" "")")" \ + "$(json_escape "${HOST_PLACEMENT_BY_TARGET["${host}"]:-unknown}")" \ + "$(json_escape "${HOST_RESERVE_ONLY["${host}"]:-false}")" \ + "$(json_escape "${HOST_BASE_SCORE["${host}"]}")" \ + "$(json_escape "${HOST_LOAD_RATIO["${host}"]}")" \ + "$(json_escape "${HOST_LOAD_REASON["${host}"]}")" \ + "$(json_escape "${HOST_CONNECT_STATE["${host}"]}")" + first=0 + done + printf '\n ],\n' + printf ' "mesh_status": "%s",\n' "$(json_escape "${overall}")" + printf ' "repo_statuses": [\n' + first=1 + for repo_name in Kill_LIFE mascarade crazy_life; do + if ! repo_has_entries "${repo_name}"; then + continue + fi + if [[ "${first}" -eq 0 ]]; then + printf ',\n' + fi + printf ' {"repo":"%s","status":"%s"}' \ + "$(json_escape "${repo_name}")" \ + "$(json_escape "$(repo_convergence_status "${repo_name}")")" + first=0 + done + printf '\n ],\n' + printf ' "snapshots": [\n' + first=1 + for entry in "${RESULTS[@]}"; do + local repo="" + local target="" + local role="" + local path="" + local status="" + local branch="" + local sha="" + local remote="" + local dirty_count="" + local dirty_sample="" + local script_ok="" + IFS='|' read -r repo target role path status branch sha remote dirty_count dirty_sample script_ok <<< "${entry}" + if [[ "${first}" -eq 0 ]]; then + printf ',\n' + fi + printf ' {"repo":"%s","target":"%s","role":"%s","path":"%s","status":"%s","branch":"%s","sha":"%s","remote":"%s","dirty_count":"%s","dirty_sample":"%s","script_ok":"%s"}' \ + "$(json_escape "${repo}")" \ + "$(json_escape "${target}")" \ + "$(json_escape "${role}")" \ + "$(json_escape "${path}")" \ + "$(json_escape "${status}")" \ + "$(json_escape "${branch}")" \ + "$(json_escape "${sha}")" \ + "$(json_escape "${remote}")" \ + "$(json_escape "${dirty_count}")" \ + "$(json_escape "${dirty_sample}")" \ + "$(json_escape "${script_ok}")" + first=0 + done + printf '\n ]\n' + printf '}\n' +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --json) + JSON_OUTPUT=1 + ;; + --no-log) + LOG_FILE="" + ;; + --verbose) + VERBOSE=1 + ;; + --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 + ;; + --photon-safe) + LOAD_PROFILE="photon-safe" + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage >&2 + exit 2 + ;; + esac + shift +done + +if [[ -n "${LOG_FILE}" ]]; then + mkdir -p "${LOG_DIR}" + log_line "INFO" "Log file: ${LOG_FILE}" +fi + +load_machine_registry +log_line "INFO" "Machine registry: ${REGISTRY_FILE} status=${REGISTRY_STATUS}" +build_targets +log_line "INFO" "Starting tri-repo mesh sync preflight" +log_line "INFO" "Load profile: ${LOAD_PROFILE}" +build_host_profiles +build_targets_ordered +log_line "INFO" "Load-aware target order:" +for host in "${HOST_ORDER[@]}"; do + log_line "INFO" " - ${host}: score=${HOST_BASE_SCORE["${host}"]} ratio=${HOST_LOAD_RATIO["${host}"]} reason=${HOST_LOAD_REASON["${host}"]} state=${HOST_CONNECT_STATE["${host}"]}" +done + +for entry in "${TARGETS_ORDERED[@]}"; do + IFS='|' read -r target_host port role repo_name repo_path required_path <<< "${entry}" + skip_reason="$(should_skip_entry "${target_host}" "${repo_name}" || true)" + if [[ -n "${skip_reason}" ]]; then + log_line "WARN" "Load-aware skip (${skip_reason}): ${repo_name} on ${target_host}" + append_load_skip_entry "${repo_name}" "${target_host}" "${role}" "${repo_path}" "${skip_reason}" + continue + fi + if [[ "${target_host}" == "local" ]]; then + snapshot_repo_local "${repo_name}" "${target_host}" "${role}" "${repo_path}" "${required_path}" + else + snapshot_repo_remote "${target_host}" "${port}" "${role}" "${repo_name}" "${repo_path}" "${required_path}" + fi +done + +overall_status="blocked" +overall_status="$(overall_mesh_status || printf '%s' "${overall_status}")" +log_line "INFO" "mesh_status=${overall_status}" + +for repo_name in Kill_LIFE mascarade crazy_life; do + if repo_has_entries "${repo_name}"; then + log_line "INFO" "repo=${repo_name} convergence=$(repo_convergence_status "${repo_name}")" + fi +done + +for entry in "${RESULTS[@]}"; do + IFS='|' read -r repo_name target_host role repo_path status branch sha remote dirty_count dirty_sample script_ok <<< "${entry}" + log_line "INFO" "snapshot repo=${repo_name} target=${target_host} status=${status} branch=${branch:-unknown} sha=${sha:-missing} dirty=${dirty_count} script_ok=${script_ok} path=${repo_path}" +done + +if [[ "${JSON_OUTPUT}" -eq 1 ]]; then + emit_json "${overall_status}" +fi + +if [[ "${overall_status}" == "blocked" ]]; then + log_line "WARN" "Mesh sync preflight blocked: operator-controlled sync required" + exit 1 +fi + +if [[ "${overall_status}" == "degraded" ]]; then + log_line "WARN" "Mesh sync preflight degraded: downgrade to controlled lots for affected repos" +else + log_line "INFO" "Mesh sync preflight ready" +fi + +exit 0 diff --git a/tools/cockpit/mistral_agents_tui.sh b/tools/cockpit/mistral_agents_tui.sh new file mode 100644 index 0000000..67cba65 --- /dev/null +++ b/tools/cockpit/mistral_agents_tui.sh @@ -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 <&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 diff --git a/tools/cockpit/mistral_studio_tui.sh b/tools/cockpit/mistral_studio_tui.sh new file mode 100644 index 0000000..dc06688 --- /dev/null +++ b/tools/cockpit/mistral_studio_tui.sh @@ -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 </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 diff --git a/tools/cockpit/mistral_workspace_guard.sh b/tools/cockpit/mistral_workspace_guard.sh new file mode 100755 index 0000000..a8fc8a7 --- /dev/null +++ b/tools/cockpit/mistral_workspace_guard.sh @@ -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 < "${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 diff --git a/tools/cockpit/ops_erp_registry_tui.sh b/tools/cockpit/ops_erp_registry_tui.sh new file mode 100755 index 0000000..28d4f89 --- /dev/null +++ b/tools/cockpit/ops_erp_registry_tui.sh @@ -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 diff --git a/tools/cockpit/pcb_ai_fab_tui.sh b/tools/cockpit/pcb_ai_fab_tui.sh new file mode 100755 index 0000000..55af7d7 --- /dev/null +++ b/tools/cockpit/pcb_ai_fab_tui.sh @@ -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 <&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 diff --git a/tools/cockpit/product_contract_audit.sh b/tools/cockpit/product_contract_audit.sh new file mode 100644 index 0000000..655abc2 --- /dev/null +++ b/tools/cockpit/product_contract_audit.sh @@ -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 diff --git a/tools/cockpit/refonte_tui.sh b/tools/cockpit/refonte_tui.sh new file mode 100755 index 0000000..9fe71a2 --- /dev/null +++ b/tools/cockpit/refonte_tui.sh @@ -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 [options] + +Options: + --action + Exécuter un lot directement au lieu d'un menu interactif. + --days Nombre de jours pour la rétention de logs (clean-logs). Défaut: 14. + --mesh-load-profile + 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 "$@" diff --git a/tools/cockpit/render_daily_operator_summary.sh b/tools/cockpit/render_daily_operator_summary.sh new file mode 100644 index 0000000..b5a72ef --- /dev/null +++ b/tools/cockpit/render_daily_operator_summary.sh @@ -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 <&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 <&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 <&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 <&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 <&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 diff --git a/tools/cockpit/render_weekly_refonte_summary.sh b/tools/cockpit/render_weekly_refonte_summary.sh new file mode 100755 index 0000000..fe72151 --- /dev/null +++ b/tools/cockpit/render_weekly_refonte_summary.sh @@ -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" <&2 + usage + exit 2 + ;; + esac +done + +ensure_dirs +write_summary +cat "$OUTPUT_FILE" diff --git a/tools/cockpit/run_alignment_daily.sh b/tools/cockpit/run_alignment_daily.sh new file mode 100644 index 0000000..554df68 --- /dev/null +++ b/tools/cockpit/run_alignment_daily.sh @@ -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 + 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 diff --git a/tools/cockpit/runtime_ai_gateway.sh b/tools/cockpit/runtime_ai_gateway.sh new file mode 100755 index 0000000..53831b0 --- /dev/null +++ b/tools/cockpit/runtime_ai_gateway.sh @@ -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 + --json + --refresh + --load-profile + --intelligence-report + --mesh-report + --mascarade-report + -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 diff --git a/tools/cockpit/sentinelle_cron.sh b/tools/cockpit/sentinelle_cron.sh new file mode 100644 index 0000000..be4e312 --- /dev/null +++ b/tools/cockpit/sentinelle_cron.sh @@ -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 < "$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 <> /var/log/sentinelle-cron.log 2>&1" + ;; + *) + main + ;; +esac diff --git a/tools/cockpit/ssh_healthcheck.sh b/tools/cockpit/ssh_healthcheck.sh new file mode 100755 index 0000000..af0b183 --- /dev/null +++ b/tools/cockpit/ssh_healthcheck.sh @@ -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 diff --git a/tools/cockpit/write_kill_life_memory_entry.sh b/tools/cockpit/write_kill_life_memory_entry.sh new file mode 100644 index 0000000..aa616f0 --- /dev/null +++ b/tools/cockpit/write_kill_life_memory_entry.sh @@ -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 diff --git a/tools/cockpit/yiacad_backend_proof.sh b/tools/cockpit/yiacad_backend_proof.sh new file mode 100755 index 0000000..c75a41b --- /dev/null +++ b/tools/cockpit/yiacad_backend_proof.sh @@ -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 [options] + +Options: + --action Action to run + --days Retention window for purge-logs (default: 14) + --lines 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 - </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 diff --git a/tools/cockpit/yiacad_backend_service_tui.sh b/tools/cockpit/yiacad_backend_service_tui.sh new file mode 100644 index 0000000..791a7c7 --- /dev/null +++ b/tools/cockpit/yiacad_backend_service_tui.sh @@ -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 [options] + +Options: + --action Action to run + --days Retention window for purge-logs (default: 14) + --lines 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 </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 - </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 diff --git a/tools/cockpit/yiacad_logs_tui.sh b/tools/cockpit/yiacad_logs_tui.sh new file mode 100755 index 0000000..8714244 --- /dev/null +++ b/tools/cockpit/yiacad_logs_tui.sh @@ -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 [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 < "${tmp}" + count="$(wc -l < "${tmp}" | tr -d ' ')" + latest="$(tail -n 1 "${tmp}")" + if [[ "${JSON}" == "true" ]]; then + cat <&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 diff --git a/tools/cockpit/yiacad_operator_index.sh b/tools/cockpit/yiacad_operator_index.sh new file mode 100644 index 0000000..5d97622 --- /dev/null +++ b/tools/cockpit/yiacad_operator_index.sh @@ -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 [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 Action to run + --days Retention window for purge-logs (default: 14) + --lines 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 < 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 </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 - </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 diff --git a/tools/cockpit/yiacad_proofs_tui.sh b/tools/cockpit/yiacad_proofs_tui.sh new file mode 100755 index 0000000..14ea2c4 --- /dev/null +++ b/tools/cockpit/yiacad_proofs_tui.sh @@ -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 [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 <&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 diff --git a/tools/cockpit/yiacad_refonte_tui.sh b/tools/cockpit/yiacad_refonte_tui.sh new file mode 100755 index 0000000..c38cd09 --- /dev/null +++ b/tools/cockpit/yiacad_refonte_tui.sh @@ -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 [options] + +Options: + --action Action to run + --days Retention window for purge-logs (default: 14) + --lines 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 </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 - </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 diff --git a/tools/cockpit/yiacad_uiux_tui.sh b/tools/cockpit/yiacad_uiux_tui.sh new file mode 100755 index 0000000..fcb57d7 --- /dev/null +++ b/tools/cockpit/yiacad_uiux_tui.sh @@ -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 [options] + +Options: + --action Action to run + --days Retention window for purge-logs (default: 14) + --lines 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 </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 - </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 diff --git a/tools/evals/README.md b/tools/evals/README.md new file mode 100644 index 0000000..6ea3e39 --- /dev/null +++ b/tools/evals/README.md @@ -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-..." +``` diff --git a/tools/evals/benchmark_providers.py b/tools/evals/benchmark_providers.py new file mode 100755 index 0000000..9b5cb72 --- /dev/null +++ b/tools/evals/benchmark_providers.py @@ -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() diff --git a/tools/evals/prompts/metier_100_template.jsonl b/tools/evals/prompts/metier_100_template.jsonl new file mode 100644 index 0000000..67d8ac5 --- /dev/null +++ b/tools/evals/prompts/metier_100_template.jsonl @@ -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"} diff --git a/tools/github_dispatch_mcp.py b/tools/github_dispatch_mcp.py index 6ed2821..723e617 100755 --- a/tools/github_dispatch_mcp.py +++ b/tools/github_dispatch_mcp.py @@ -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 = [ diff --git a/tools/hw/cad_stack.sh b/tools/hw/cad_stack.sh index 0ce12b0..4884d3d 100755 --- a/tools/hw/cad_stack.sh +++ b/tools/hw/cad_stack.sh @@ -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() { diff --git a/tools/hw/hw_diff.py b/tools/hw/hw_diff.py index 6b5b7d2..5b5b7b6 100644 --- a/tools/hw/hw_diff.py +++ b/tools/hw/hw_diff.py @@ -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 ", 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} ", 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()) diff --git a/tools/hw/kicad_host_mcp_smoke.py b/tools/hw/kicad_host_mcp_smoke.py index b462253..031f506 100755 --- a/tools/hw/kicad_host_mcp_smoke.py +++ b/tools/hw/kicad_host_mcp_smoke.py @@ -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') diff --git a/tools/hw/run_kicad_mcp.sh b/tools/hw/run_kicad_mcp.sh index c956e5a..4377117 100755 --- a/tools/hw/run_kicad_mcp.sh +++ b/tools/hw/run_kicad_mcp.sh @@ -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 diff --git a/tools/knowledge_base_mcp.py b/tools/knowledge_base_mcp.py index 076f38c..a3424d6 100755 --- a/tools/knowledge_base_mcp.py +++ b/tools/knowledge_base_mcp.py @@ -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(), diff --git a/tools/lib/runtime_home.sh b/tools/lib/runtime_home.sh index 6079575..dbc1c4c 100644 --- a/tools/lib/runtime_home.sh +++ b/tools/lib/runtime_home.sh @@ -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 diff --git a/tools/mistral/finetune/configs/kicad_small.yaml b/tools/mistral/finetune/configs/kicad_small.yaml new file mode 100644 index 0000000..4e56272 --- /dev/null +++ b/tools/mistral/finetune/configs/kicad_small.yaml @@ -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 diff --git a/tools/mistral/finetune/configs/spice_codestral.yaml b/tools/mistral/finetune/configs/spice_codestral.yaml new file mode 100644 index 0000000..dfdda08 --- /dev/null +++ b/tools/mistral/finetune/configs/spice_codestral.yaml @@ -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 diff --git a/tools/mistral/finetune/prepare_and_validate.sh b/tools/mistral/finetune/prepare_and_validate.sh new file mode 100755 index 0000000..cb8b2b8 --- /dev/null +++ b/tools/mistral/finetune/prepare_and_validate.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# prepare_and_validate.sh — Préparation et validation datasets fine-tune +# Usage: ./prepare_and_validate.sh +# Prérequis: mistral-finetune cloné dans $HOME/mistral-finetune +# +# T-MA-016 (KiCad): ./prepare_and_validate.sh configs/kicad_small.yaml +# T-MA-017 (SPICE): ./prepare_and_validate.sh configs/spice_codestral.yaml + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +FINETUNE_DIR="${HOME}/mistral-finetune" +CONFIG="${1:?Usage: $0 }" + +# Couleurs +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +echo -e "${YELLOW}=== Mistral Fine-tune — Préparation ===${NC}" +echo "Config: ${CONFIG}" + +# 1. Vérifier que mistral-finetune est cloné +if [ ! -d "${FINETUNE_DIR}" ]; then + echo -e "${YELLOW}[1/5] Clonage mistral-finetune...${NC}" + cd "${HOME}" && git clone --depth 1 https://github.com/mistralai/mistral-finetune.git + cd "${FINETUNE_DIR}" && pip install -r requirements.txt +else + echo -e "${GREEN}[1/5] mistral-finetune déjà présent${NC}" +fi + +# 2. Extraire les chemins dataset depuis le YAML +TRAIN_DATA=$(python3 -c " +import yaml +with open('${SCRIPT_DIR}/${CONFIG}') as f: + cfg = yaml.safe_load(f) +print(cfg['data']['instruct_data']) +") +EVAL_DATA=$(python3 -c " +import yaml +with open('${SCRIPT_DIR}/${CONFIG}') as f: + cfg = yaml.safe_load(f) +print(cfg['data']['eval_instruct_data']) +") + +echo -e "${YELLOW}[2/5] Vérification datasets...${NC}" +echo " Train: ${TRAIN_DATA}" +echo " Eval: ${EVAL_DATA}" + +for f in "${TRAIN_DATA}" "${EVAL_DATA}"; do + if [ ! -f "$f" ]; then + echo -e "${RED} ERREUR: Fichier manquant: $f${NC}" + echo " -> Lancez d'abord le pipeline: python mistral_dataset_pipeline.py" + exit 1 + fi +done + +# 3. Compter les samples +TRAIN_COUNT=$(wc -l < "${TRAIN_DATA}") +EVAL_COUNT=$(wc -l < "${EVAL_DATA}") +echo -e "${GREEN} Train: ${TRAIN_COUNT} samples${NC}" +echo -e "${GREEN} Eval: ${EVAL_COUNT} samples${NC}" + +# 4. Valider le format avec l'outil mistral-finetune +echo -e "${YELLOW}[3/5] Validation du format JSONL...${NC}" +cd "${FINETUNE_DIR}" +python -m utils.validate_data --train_yaml "${SCRIPT_DIR}/${CONFIG}" 2>&1 | tee /tmp/validate_output.txt + +# 5. Vérifier les erreurs +ERROR_COUNT=$(grep -c "incorrectly formatted" /tmp/validate_output.txt 2>/dev/null || echo "0") +if [ "${ERROR_COUNT}" -gt 0 ]; then + echo -e "${YELLOW}[4/5] ${ERROR_COUNT} erreurs détectées — tentative de correction...${NC}" + python -m utils.reformat_data "${TRAIN_DATA}" + python -m utils.reformat_data "${EVAL_DATA}" + + echo -e "${YELLOW}[5/5] Re-validation après correction...${NC}" + python -m utils.validate_data --train_yaml "${SCRIPT_DIR}/${CONFIG}" +else + echo -e "${GREEN}[4/5] Aucune erreur de format${NC}" + echo -e "${GREEN}[5/5] Dataset prêt pour le fine-tune !${NC}" +fi + +echo "" +echo -e "${GREEN}=== Préparation terminée ===${NC}" +echo "Pour lancer le fine-tune :" +echo " cd ${FINETUNE_DIR}" +echo " torchrun --nproc-per-node --master_port \$RANDOM -m train ${SCRIPT_DIR}/${CONFIG}" diff --git a/tools/mistral/mistral_client.py b/tools/mistral/mistral_client.py index 556e008..c0998bc 100644 --- a/tools/mistral/mistral_client.py +++ b/tools/mistral/mistral_client.py @@ -9,14 +9,37 @@ from __future__ import annotations import os from typing import Any, Dict +from pathlib import Path from mistralai import Mistral +def _load_governance_key_from_file() -> str | None: + secret_file = Path( + os.environ.get( + "KILL_LIFE_MISTRAL_ENV_FILE", + str(Path.home() / ".kill-life" / "mistral.env"), + ) + ).expanduser() + if not secret_file.exists(): + return None + + for line in secret_file.read_text(encoding="utf-8").splitlines(): + if line.startswith("MISTRAL_GOVERNANCE_API_KEY="): + value = line.split("=", 1)[1].strip() + if value: + return value + return None + + def get_client() -> Mistral: - api_key = os.environ.get("MISTRAL_API_KEY") + api_key = ( + os.environ.get("MISTRAL_GOVERNANCE_API_KEY") + or os.environ.get("MISTRAL_API_KEY") + or _load_governance_key_from_file() + ) if not api_key: - raise RuntimeError("MISTRAL_API_KEY is not set.") + raise RuntimeError("MISTRAL_GOVERNANCE_API_KEY or MISTRAL_API_KEY is not set.") api_base = os.environ.get("MISTRAL_API_BASE") # e.g. https://codestral.mistral.ai/v1 # SDK supports server_url in recent versions; we pass it if available. diff --git a/tools/ops/deploy_mascarade_tower_runtime.sh b/tools/ops/deploy_mascarade_tower_runtime.sh new file mode 100755 index 0000000..9866a18 --- /dev/null +++ b/tools/ops/deploy_mascarade_tower_runtime.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +REMOTE_HOST="clems@192.168.0.120" +REMOTE_DIR="/home/clems/mascarade-main" +ACTION="status" +JSON_OUTPUT=0 +SEED_AGENTS=1 + +usage() { + cat <<'EOF' +Usage: bash tools/ops/deploy_mascarade_tower_runtime.sh [options] + +Options: + --action status|apply Default: status + --remote-host HOST Override tower SSH host + --remote-dir DIR Override Mascarade remote dir + --json Emit JSON + --skip-seed Do not seed tower-* agents on apply + --help Show this help +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --action) + ACTION="${2:-}" + shift 2 + ;; + --remote-host) + REMOTE_HOST="${2:-}" + shift 2 + ;; + --remote-dir) + REMOTE_DIR="${2:-}" + shift 2 + ;; + --json) + JSON_OUTPUT=1 + shift + ;; + --skip-seed) + SEED_AGENTS=0 + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ "${ACTION}" != "status" && "${ACTION}" != "apply" ]]; then + echo "Invalid --action: ${ACTION}" >&2 + exit 2 +fi + +runtime_status="$( + ssh -o BatchMode=yes -o ConnectTimeout=5 "${REMOTE_HOST}" "python3 - <<'PY' +import json +import subprocess +from pathlib import Path + +def run(cmd): + return subprocess.run(cmd, shell=True, text=True, capture_output=True) + +docker_ok = run('command -v docker >/dev/null 2>&1') +containers = [] +models = [] +if docker_ok.returncode == 0: + ps = run(\"docker ps --format '{{.Names}}|{{.Status}}|{{.Ports}}'\") + for raw in ps.stdout.splitlines(): + if raw.startswith('mascarade-'): + parts = raw.split('|', 2) + containers.append({ + 'name': parts[0], + 'status': parts[1] if len(parts) > 1 else '', + 'ports': parts[2] if len(parts) > 2 else '', + }) + listing = run(\"docker exec mascarade-ollama ollama list 2>/dev/null | sed -n '2,12p'\") + for raw in listing.stdout.splitlines(): + model = raw.strip().split() + if model: + models.append(model[0]) + +result = { + 'status': 'ok' if docker_ok.returncode == 0 else 'error', + 'remote_dir': '/home/clems/mascarade-main', + 'runtime_mode': 'normalize-existing-stack', + 'docker': docker_ok.returncode == 0, + 'remote_dir_exists': Path('/home/clems/mascarade-main').exists(), + 'containers': containers, + 'models': models, +} +print(json.dumps(result, ensure_ascii=True)) +PY" +)" + +seed_status='{"status":"skipped"}' +if [[ "${ACTION}" == "apply" && "${SEED_AGENTS}" -eq 1 ]]; then + seed_status="$(bash "${ROOT_DIR}/tools/ops/sync_mascarade_agents_tower.sh" --action sync --apply --json --remote-host "${REMOTE_HOST}" --remote-dir "${REMOTE_DIR}")" +fi + +python3 - "${runtime_status}" "${seed_status}" "${REMOTE_HOST}" "${REMOTE_DIR}" "${ACTION}" "${JSON_OUTPUT}" <<'PY' +import json +import sys + +runtime = json.loads(sys.argv[1]) +seed = json.loads(sys.argv[2]) +summary = { + "status": "ok" if runtime.get("status") == "ok" else "error", + "action": sys.argv[5], + "target_host": sys.argv[3], + "remote_dir": sys.argv[4], + "runtime_mode": runtime.get("runtime_mode", "normalize-existing-stack"), + "runtime_containers": runtime.get("containers", []), + "runtime_models": runtime.get("models", []), + "seed_status": seed.get("status", "skipped"), + "seed_created": seed.get("created", 0), + "seed_updated": seed.get("updated", 0), + "seed_agents": seed.get("agents", []), +} +if sys.argv[6] == "1": + print(json.dumps(summary, indent=2, ensure_ascii=True)) +else: + print("Tower Mascarade runtime") + print(f"- target: {summary['target_host']}") + print(f"- mode: {summary['runtime_mode']}") + print(f"- containers: {', '.join(item['name'] for item in summary['runtime_containers'])}") + print(f"- models: {', '.join(summary['runtime_models'][:4])}") + print(f"- seed_status: {summary['seed_status']}") +PY diff --git a/tools/ops/operator_live_provider_smoke.js b/tools/ops/operator_live_provider_smoke.js new file mode 100644 index 0000000..57ef720 --- /dev/null +++ b/tools/ops/operator_live_provider_smoke.js @@ -0,0 +1,330 @@ +#!/usr/bin/env node + +const DEFAULT_CHAT_URL = + process.env.MASCARADE_CHAT_URL || "http://localhost:3000/api/v1/chat/completions"; +const DEFAULT_PROVIDERS_URL = + process.env.MASCARADE_PROVIDERS_URL || "http://localhost:3000/api/agents/providers"; +const DEFAULT_OUTPUT = "artifacts/operator_lane/live_provider_result.json"; +const DEFAULT_PROMPT = "Summarise the current Kill_LIFE operator lane in one concise sentence."; +const DEFAULT_PROVIDER_PREFERENCE = ["apple-coreml", "openai", "ollama", "anthropic", "openrouter"]; +const DEFAULT_MODELS = { + "apple-coreml": "apple-coreml:qwen3.5-4b-onnx-q4f16", + openai: "openai:gpt-4.1-mini", + ollama: "ollama:qwen3.5:9b", + anthropic: "anthropic:claude-3-5-haiku-latest", + openrouter: "openrouter:openai/gpt-4.1-mini", +}; + +function utcNow() { + return new Date().toISOString(); +} + +function parseArgs(argv) { + const args = { + chatUrl: DEFAULT_CHAT_URL, + providersUrl: DEFAULT_PROVIDERS_URL, + provider: (process.env.MASCARADE_OPERATOR_PROVIDER || "").trim(), + model: (process.env.MASCARADE_OPERATOR_MODEL || "").trim(), + prompt: DEFAULT_PROMPT, + output: DEFAULT_OUTPUT, + timeout: Number.parseFloat(process.env.MASCARADE_OPERATOR_TIMEOUT || "30"), + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const next = argv[i + 1]; + if (!arg.startsWith("--")) { + continue; + } + if (next == null) { + break; + } + switch (arg) { + case "--chat-url": + args.chatUrl = next; + i += 1; + break; + case "--providers-url": + args.providersUrl = next; + i += 1; + break; + case "--provider": + args.provider = next; + i += 1; + break; + case "--model": + args.model = next; + i += 1; + break; + case "--prompt": + args.prompt = next; + i += 1; + break; + case "--output": + args.output = next; + i += 1; + break; + case "--timeout": + args.timeout = Number.parseFloat(next); + i += 1; + break; + default: + break; + } + } + if (!Number.isFinite(args.timeout) || args.timeout <= 0) { + args.timeout = 30; + } + return args; +} + +function authHeaders() { + const apiKey = + (process.env.MASCARADE_API_KEY || + process.env.CRAZY_LIFE_API_KEY || + process.env.KILL_LIFE_API_KEY || + "").trim(); + const headers = { "Content-Type": "application/json" }; + if (apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } + return headers; +} + +async function httpJson(url, options = {}) { + const controller = new AbortController(); + const timeoutMs = Math.round((options.timeout || 30) * 1000); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + method: options.method || "GET", + headers: authHeaders(), + body: options.body == null ? undefined : JSON.stringify(options.body), + signal: controller.signal, + }); + const text = await response.text(); + let parsed = null; + try { + parsed = JSON.parse(text); + } catch {} + return { status: response.status, parsed, text }; + } finally { + clearTimeout(timer); + } +} + +function chooseProvider(available, requested) { + if (requested) { + return requested; + } + const wanted = (process.env.MASCARADE_OPERATOR_PROVIDER_PREFERENCE || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + const preferred = wanted.length ? wanted : DEFAULT_PROVIDER_PREFERENCE; + for (const provider of preferred) { + if (available.includes(provider)) { + return provider; + } + } + return available[0] || ""; +} + +function chooseModel(provider, requested) { + if (requested) { + return requested; + } + const envDefault = (process.env.MASCARADE_DEFAULT_MODEL || "").trim(); + if (envDefault) { + if (provider && !envDefault.includes(":")) { + return `${provider}:${envDefault}`; + } + return envDefault; + } + return DEFAULT_MODELS[provider] || ""; +} + +function inferProviderFromModel(model) { + const normalized = (model || "").trim().toLowerCase(); + if (!normalized) { + return ""; + } + if (normalized.includes(":")) { + return normalized.split(":", 1)[0]; + } + if (normalized.startsWith("claude")) { + return "claude"; + } + if (normalized.startsWith("gpt") || normalized.startsWith("o1") || normalized.startsWith("o3")) { + return "openai"; + } + if (normalized.startsWith("gemini")) { + return "gemini"; + } + if (normalized.startsWith("mistral")) { + return "mistral"; + } + return ""; +} + +async function writeOutput(path, payload) { + const fs = await import("node:fs/promises"); + const pathModule = await import("node:path"); + await fs.mkdir(pathModule.dirname(path), { recursive: true }); + await fs.writeFile(path, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); +} + +function extractCompletion(body) { + if (!body || typeof body !== "object") { + return ""; + } + const choices = body.choices; + if (!Array.isArray(choices) || choices.length === 0) { + return ""; + } + const first = choices[0]; + if (!first || typeof first !== "object") { + return ""; + } + const message = first.message; + if (!message || typeof message !== "object") { + return ""; + } + return typeof message.content === "string" ? message.content : ""; +} + +async function finish(code, result, output) { + await writeOutput(output, result); + process.stdout.write(`${JSON.stringify(result)}\n`); + process.exit(code); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const result = { + generated_at: utcNow(), + status: "blocked", + execution_path: "live-provider", + chat_url: args.chatUrl, + providers_url: args.providersUrl, + prompt: args.prompt, + provider: "", + model: "", + available_providers: [], + completion: "", + summary: "", + error: null, + http_status: null, + usage: null, + }; + + let providersResponse; + try { + providersResponse = await httpJson(args.providersUrl, { timeout: args.timeout }); + } catch (error) { + result.status = "blocked"; + result.error = `providers lookup failed: ${error instanceof Error ? error.message : String(error)}`; + await finish(4, result, args.output); + } + + result.http_status = providersResponse.status; + const available = + providersResponse.parsed && + typeof providersResponse.parsed === "object" && + Array.isArray(providersResponse.parsed.providers) + ? providersResponse.parsed.providers.filter((item) => typeof item === "string" && item.trim()) + : []; + result.available_providers = available; + + const requestedProvider = args.provider.trim(); + const requestedModel = args.model.trim(); + let provider = requestedProvider || ""; + let model = requestedModel; + result.provider = provider; + result.model = model; + + if (providersResponse.status >= 400) { + result.status = "degraded"; + result.error = `providers endpoint returned HTTP ${providersResponse.status}: ${ + providersResponse.text.trim() || "empty response" + }`; + await finish(3, result, args.output); + } + if (!requestedProvider && !requestedModel && available.length === 0) { + result.status = "degraded"; + result.error = "no runtime provider is currently advertised by mascarade"; + await finish(3, result, args.output); + } + + if (!provider && requestedModel) { + provider = inferProviderFromModel(requestedModel); + result.provider = provider; + } + + let chatResponse; + try { + const body = { + system: "You are a concise operations copilot.", + messages: [ + { role: "user", content: args.prompt }, + ], + temperature: 0.2, + max_tokens: 160, + }; + if (requestedModel && model) { + body.model = model; + } + chatResponse = await httpJson(args.chatUrl, { + method: "POST", + body, + timeout: args.timeout, + }); + } catch (error) { + result.status = "blocked"; + result.error = `chat request failed: ${error instanceof Error ? error.message : String(error)}`; + await finish(4, result, args.output); + } + + result.http_status = chatResponse.status; + if (chatResponse.status >= 400) { + result.status = "degraded"; + result.error = chatResponse.text.trim() || `chat endpoint returned HTTP ${chatResponse.status}`; + await finish(3, result, args.output); + } + + const completion = extractCompletion(chatResponse.parsed); + if (!completion) { + result.status = "degraded"; + result.error = "chat response did not contain a completion message"; + await finish(3, result, args.output); + } + + result.status = "ready"; + result.completion = completion; + result.summary = completion.trim() ? completion.split(/\r?\n/, 1)[0].trim() : ""; + if (chatResponse.parsed && typeof chatResponse.parsed === "object") { + const bodyModel = typeof chatResponse.parsed.model === "string" ? chatResponse.parsed.model.trim() : ""; + if (bodyModel) { + result.model = bodyModel; + if (!result.provider) { + result.provider = inferProviderFromModel(bodyModel); + } + } + if (chatResponse.parsed.usage && typeof chatResponse.parsed.usage === "object") { + result.usage = chatResponse.parsed.usage; + } + } + + await finish(0, result, args.output); +} + +main().catch(async (error) => { + const result = { + generated_at: utcNow(), + status: "blocked", + execution_path: "live-provider", + error: error instanceof Error ? error.message : String(error), + }; + await writeOutput(DEFAULT_OUTPUT, result); + process.stdout.write(`${JSON.stringify(result)}\n`); + process.exit(4); +}); diff --git a/tools/ops/operator_live_provider_smoke.py b/tools/ops/operator_live_provider_smoke.py new file mode 100755 index 0000000..53c4b64 --- /dev/null +++ b/tools/ops/operator_live_provider_smoke.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import sys +from datetime import UTC, datetime +from pathlib import Path +from urllib import error, request + +DEFAULT_CHAT_URL = os.environ.get("MASCARADE_CHAT_URL", "http://localhost:3000/api/v1/chat/completions") +DEFAULT_PROVIDERS_URL = os.environ.get("MASCARADE_PROVIDERS_URL", "http://localhost:3000/api/agents/providers") +DEFAULT_OUTPUT = "artifacts/operator_lane/live_provider_result.json" +DEFAULT_CATALOG = str(Path(__file__).resolve().parents[2] / "specs" / "contracts" / "mascarade_model_profiles.kxkm_ai.json") +DEFAULT_PROMPT = "Summarise the current Kill_LIFE operator lane in one concise sentence." +DEFAULT_PROVIDER_PREFERENCE = ["apple-coreml", "openai", "ollama", "anthropic", "openrouter"] +DEFAULT_MODELS = { + "apple-coreml": "apple-coreml:qwen3.5-4b-onnx-q4f16", + "openai": "openai:gpt-4.1-mini", + "ollama": "ollama:qwen3.5:9b", + "anthropic": "anthropic:claude-3-5-haiku-latest", + "openrouter": "openrouter:openai/gpt-4.1-mini", +} + + +def utc_now() -> str: + return datetime.now(UTC).isoformat().replace("+00:00", "Z") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run the Full operator lane live-provider smoke.") + parser.add_argument("--chat-url", default=DEFAULT_CHAT_URL) + parser.add_argument("--providers-url", default=DEFAULT_PROVIDERS_URL) + parser.add_argument("--catalog", default=os.environ.get("MASCARADE_MODEL_CATALOG", DEFAULT_CATALOG)) + parser.add_argument("--profile", default=os.environ.get("MASCARADE_OPERATOR_PROFILE", "")) + parser.add_argument("--provider", default=os.environ.get("MASCARADE_OPERATOR_PROVIDER", "")) + parser.add_argument("--model", default=os.environ.get("MASCARADE_OPERATOR_MODEL", "")) + parser.add_argument("--prompt", default=DEFAULT_PROMPT) + parser.add_argument("--output", default=DEFAULT_OUTPUT) + parser.add_argument("--timeout", type=float, default=float(os.environ.get("MASCARADE_OPERATOR_TIMEOUT", "30"))) + return parser.parse_args() + + +def auth_headers() -> dict[str, str]: + api_key = ( + os.environ.get("MASCARADE_API_KEY") + or os.environ.get("CRAZY_LIFE_API_KEY") + or os.environ.get("KILL_LIFE_API_KEY") + or "" + ).strip() + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + return headers + + +def decode_json_bytes(payload: bytes) -> tuple[str, object | None]: + text = payload.decode("utf-8", errors="replace") + try: + return text, json.loads(text) + except json.JSONDecodeError: + return text, None + + +def http_json(url: str, *, method: str = "GET", body: object | None = None, timeout: float = 30.0) -> tuple[int, object | None, str]: + payload = None + if body is not None: + payload = json.dumps(body).encode("utf-8") + req = request.Request(url, data=payload, method=method, headers=auth_headers()) + try: + with request.urlopen(req, timeout=timeout) as response: + raw = response.read() + text, parsed = decode_json_bytes(raw) + return response.getcode(), parsed, text + except error.HTTPError as exc: + raw = exc.read() + text, parsed = decode_json_bytes(raw) + return exc.code, parsed, text + + +def load_catalog(path_str: str) -> dict[str, object]: + path = Path(path_str) + if not path.is_file(): + return {} + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {} + return payload if isinstance(payload, dict) else {} + + +def select_profile(catalog: dict[str, object], profile_id: str) -> dict[str, object]: + requested = profile_id.strip() + profiles = catalog.get("profiles") + if not isinstance(profiles, list): + return {} + for item in profiles: + if isinstance(item, dict) and str(item.get("id", "")).strip() == requested: + return item + return {} + + +def profile_preference(profile: dict[str, object]) -> list[str]: + raw = profile.get("provider_preference") + if not isinstance(raw, list): + return [] + return [item.strip() for item in raw if isinstance(item, str) and item.strip()] + + +def choose_provider(available: list[str], requested: str, preferred: list[str] | None = None) -> str: + if requested: + return requested + wanted = [item.strip() for item in os.environ.get("MASCARADE_OPERATOR_PROVIDER_PREFERENCE", "").split(",") if item.strip()] + preferred_order = preferred or wanted or DEFAULT_PROVIDER_PREFERENCE + for provider in preferred_order: + if provider in available: + return provider + return available[0] if available else "" + + +def choose_model(provider: str, requested: str) -> str: + if requested: + return requested + env_default = os.environ.get("MASCARADE_DEFAULT_MODEL", "").strip() + if env_default: + if provider and ":" not in env_default: + return f"{provider}:{env_default}" + return env_default + return DEFAULT_MODELS.get(provider, "") + + +def choose_profile_model(profile: dict[str, object], provider: str, requested: str) -> str: + if requested: + return requested + + candidates: list[str] = [] + default_model = profile.get("default_model") + if isinstance(default_model, str) and default_model.strip(): + candidates.append(default_model.strip()) + + fallback_models = profile.get("fallback_models") + if isinstance(fallback_models, list): + candidates.extend(item.strip() for item in fallback_models if isinstance(item, str) and item.strip()) + + if provider: + provider_prefix = f"{provider.lower()}:" + for candidate in candidates: + if candidate.lower().startswith(provider_prefix): + return candidate + + return candidates[0] if candidates else choose_model(provider, "") + + +def infer_provider_from_model(model: str) -> str: + normalized = model.strip().lower() + if not normalized: + return "" + if ":" in normalized: + return normalized.split(":", 1)[0] + if normalized.startswith("claude"): + return "claude" + if normalized.startswith("gpt") or normalized.startswith("o1") or normalized.startswith("o3"): + return "openai" + if normalized.startswith("gemini"): + return "gemini" + if normalized.startswith("mistral"): + return "mistral" + return "" + + +def write_output(path_str: str, payload: dict[str, object]) -> None: + path = Path(path_str) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, ensure_ascii=True) + "\n", encoding="utf-8") + + + +def extract_completion(body: object | None) -> str: + if not isinstance(body, dict): + return "" + choices = body.get("choices") + if not isinstance(choices, list) or not choices: + return "" + first = choices[0] + if not isinstance(first, dict): + return "" + message = first.get("message") + if not isinstance(message, dict): + return "" + content = message.get("content") + return content if isinstance(content, str) else "" + + + +def main() -> int: + args = parse_args() + result: dict[str, object] = { + "generated_at": utc_now(), + "status": "blocked", + "execution_path": "live-provider", + "chat_url": args.chat_url, + "providers_url": args.providers_url, + "catalog": args.catalog, + "profile": "", + "prompt": args.prompt, + "provider": "", + "model": "", + "available_providers": [], + "completion": "", + "summary": "", + "error": None, + "http_status": None, + "usage": None, + } + + catalog = load_catalog(args.catalog) + requested_profile = args.profile.strip() + selected_profile = select_profile(catalog, requested_profile) if requested_profile else {} + preferred_providers: list[str] = [] + if requested_profile and not selected_profile: + result["status"] = "blocked" + result["error"] = f"profile not found: {requested_profile}" + write_output(args.output, result) + print(json.dumps(result, ensure_ascii=True)) + return 2 + + if selected_profile: + result["profile"] = str(selected_profile.get("id", "")).strip() + preferred_providers = profile_preference(selected_profile) + if args.prompt == DEFAULT_PROMPT: + profile_prompt = str(selected_profile.get("prompt", "")).strip() + if profile_prompt: + args.prompt = profile_prompt + result["prompt"] = args.prompt + + try: + providers_status, providers_body, providers_text = http_json( + args.providers_url, + timeout=args.timeout, + ) + except error.URLError as exc: + result["status"] = "blocked" + result["error"] = f"providers lookup failed: {exc.reason}" + write_output(args.output, result) + print(json.dumps(result, ensure_ascii=True)) + return 4 + + result["http_status"] = providers_status + available: list[str] = [] + if isinstance(providers_body, dict): + providers = providers_body.get("providers") + if isinstance(providers, list): + available = [item for item in providers if isinstance(item, str) and item.strip()] + result["available_providers"] = available + + requested_provider = args.provider.strip() + requested_model = args.model.strip() + provider = requested_provider or str(selected_profile.get("default_provider", "")).strip() + model = requested_model + result["provider"] = provider + result["model"] = model + + if providers_status >= 400: + result["status"] = "degraded" + result["error"] = f"providers endpoint returned HTTP {providers_status}: {providers_text.strip() or 'empty response'}" + write_output(args.output, result) + print(json.dumps(result, ensure_ascii=True)) + return 3 + + if not requested_provider and not requested_model and not available: + result["status"] = "degraded" + result["error"] = "no runtime provider is currently advertised by mascarade" + write_output(args.output, result) + print(json.dumps(result, ensure_ascii=True)) + return 3 + + if selected_profile and not requested_provider and available and provider and provider not in available: + provider = choose_provider(available, "", preferred_providers) + result["provider"] = provider + + if not provider and requested_model: + provider = infer_provider_from_model(requested_model) + result["provider"] = provider + + if not provider and not requested_model and available: + provider = choose_provider(available, "", preferred_providers) + result["provider"] = provider + + payload = { + "system": "You are a concise operations copilot.", + "messages": [ + {"role": "user", "content": args.prompt}, + ], + "temperature": 0.2, + "max_tokens": 160, + } + if requested_model and model: + payload["model"] = model + + try: + chat_status, chat_body, chat_text = http_json( + args.chat_url, + method="POST", + body=payload, + timeout=args.timeout, + ) + except error.URLError as exc: + result["status"] = "blocked" + result["error"] = f"chat request failed: {exc.reason}" + write_output(args.output, result) + print(json.dumps(result, ensure_ascii=True)) + return 4 + + result["http_status"] = chat_status + if chat_status >= 400: + result["status"] = "degraded" + result["error"] = chat_text.strip() or f"chat endpoint returned HTTP {chat_status}" + write_output(args.output, result) + print(json.dumps(result, ensure_ascii=True)) + return 3 + + completion = extract_completion(chat_body) + if not completion: + result["status"] = "degraded" + result["error"] = "chat response did not contain a completion message" + write_output(args.output, result) + print(json.dumps(result, ensure_ascii=True)) + return 3 + + result["status"] = "ready" + result["completion"] = completion + result["summary"] = completion.splitlines()[0].strip() if completion.strip() else "" + if isinstance(chat_body, dict): + body_model = chat_body.get("model") + if isinstance(body_model, str) and body_model.strip(): + result["model"] = body_model.strip() + if not result["provider"]: + result["provider"] = infer_provider_from_model(body_model.strip()) + usage = chat_body.get("usage") + if isinstance(usage, dict): + result["usage"] = usage + + write_output(args.output, result) + print(json.dumps(result, ensure_ascii=True)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/ops/smoke_mascarade_agents_kxkm.sh b/tools/ops/smoke_mascarade_agents_kxkm.sh new file mode 100644 index 0000000..cbd2d43 --- /dev/null +++ b/tools/ops/smoke_mascarade_agents_kxkm.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +set -euo pipefail + +HOST="kxkm@kxkm-ai" +AGENTS_CSV="kxkm-code,kxkm-cad,kxkm-ops,kxkm-fallback-safe" +MESSAGE="Respond in one short sentence with your role name and current focus." +LOG_DIR="artifacts/ops/mascarade_agent_smoke" +JSON_OUTPUT=0 + +usage() { + cat <<'EOF' +Usage: bash tools/ops/smoke_mascarade_agents_kxkm.sh [options] + +Options: + --host SSH target (default: kxkm@kxkm-ai) + --agents Comma-separated agent names + --message Prompt sent to each agent + --log-dir Local artifact directory + --json Emit JSON only + --help Show this help +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --host) + HOST="${2:-}" + shift 2 + ;; + --agents) + AGENTS_CSV="${2:-}" + shift 2 + ;; + --message) + MESSAGE="${2:-}" + shift 2 + ;; + --log-dir) + LOG_DIR="${2:-}" + shift 2 + ;; + --json) + JSON_OUTPUT=1 + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + printf 'Unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [ -z "$HOST" ] || [ -z "$AGENTS_CSV" ] || [ -z "$MESSAGE" ] || [ -z "$LOG_DIR" ]; then + printf 'host, agents, message and log-dir must be non-empty\n' >&2 + exit 2 +fi + +mkdir -p "$LOG_DIR" + +timestamp="$(date '+%Y%m%dT%H%M%S%z')" +artifact_path="${LOG_DIR}/mascarade_agent_smoke_${timestamp}.json" +latest_path="${LOG_DIR}/latest.json" +tmp_output="$(mktemp)" +trap 'rm -f "$tmp_output"' EXIT + +cat <<'REMOTE' | ssh -o BatchMode=yes -o ConnectTimeout=5 "$HOST" bash -s -- "$AGENTS_CSV" "$MESSAGE" > "$tmp_output" +set -euo pipefail +agents_csv="$1" +message_text="$2" +API_KEY="$(docker inspect mascarade-api --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^MASCARADE_API_KEY=//p' | head -n1)" +API_KEY="$API_KEY" python3 - "$agents_csv" "$message_text" <<'PY' +import json +import os +import sys +import urllib.error +import urllib.request +from datetime import datetime, timezone + +agents = [item for item in sys.argv[1].split(",") if item] +message = sys.argv[2] +api_key = os.environ["API_KEY"] +api_url = "http://127.0.0.1:3100/api/agents" +headers = {"Authorization": f"Bearer {api_key}"} + +with urllib.request.urlopen(urllib.request.Request(api_url, headers=headers), timeout=10) as response: + registry_payload = json.loads(response.read().decode("utf-8")) + +registry_agents = registry_payload.get("agents", []) +registry_names = {agent.get("name") for agent in registry_agents if isinstance(agent, dict)} +results = [] + +for name in agents: + payload = json.dumps( + {"messages": [{"role": "user", "content": message}]} + ).encode("utf-8") + request = urllib.request.Request( + f"{api_url}/{name}/run", + data=payload, + headers={**headers, "Content-Type": "application/json"}, + method="POST", + ) + result = { + "name": name, + "registered": name in registry_names, + } + try: + with urllib.request.urlopen(request, timeout=120) as response: + body = json.loads(response.read().decode("utf-8")) + result.update( + { + "http_status": response.status, + "status": "ok" if response.status == 200 else "degraded", + "provider": body.get("provider"), + "model": body.get("model"), + "content": body.get("content"), + "error": body.get("error"), + } + ) + except urllib.error.HTTPError as exc: + try: + body = json.loads(exc.read().decode("utf-8")) + except Exception: + body = {"error": str(exc)} + result.update( + { + "http_status": exc.code, + "status": "error", + "provider": body.get("provider"), + "model": body.get("model"), + "content": body.get("content"), + "error": body.get("error", str(exc)), + } + ) + except Exception as exc: + result.update( + { + "http_status": None, + "status": "error", + "provider": None, + "model": None, + "content": None, + "error": str(exc), + } + ) + results.append(result) + +overall = "ok" if results and all(item["status"] == "ok" for item in results) else "degraded" +print( + json.dumps( + { + "status": overall, + "component": "mascarade-agent-smoke", + "action": "run", + "host": os.uname().nodename, + "ssh_target": os.environ.get("SSH_CONNECTION", "").split()[2] if os.environ.get("SSH_CONNECTION") else None, + "api_url": api_url, + "checked_at": datetime.now(timezone.utc).isoformat(), + "agents": results, + }, + ensure_ascii=True, + ) +) +PY +REMOTE + +cp "$tmp_output" "$artifact_path" +cp "$artifact_path" "$latest_path" + +if [ "$JSON_OUTPUT" -eq 1 ]; then + cat "$artifact_path" +else + python3 - "$artifact_path" <<'PY' +import json +import sys + +payload = json.loads(open(sys.argv[1], encoding="utf-8").read()) +print(f"status={payload.get('status')} artifact={sys.argv[1]}") +for item in payload.get("agents", []): + print( + "{name}: status={status} http={http_status} provider={provider} model={model}".format( + name=item.get("name"), + status=item.get("status"), + http_status=item.get("http_status"), + provider=item.get("provider"), + model=item.get("model"), + ) + ) +PY +fi + +python3 - "$artifact_path" <<'PY' +import json +import sys +payload = json.loads(open(sys.argv[1], encoding="utf-8").read()) +raise SystemExit(0 if payload.get("status") == "ok" else 1) +PY diff --git a/tools/ops/sync_mascarade_agents_kxkm.sh b/tools/ops/sync_mascarade_agents_kxkm.sh new file mode 100644 index 0000000..c291402 --- /dev/null +++ b/tools/ops/sync_mascarade_agents_kxkm.sh @@ -0,0 +1,332 @@ +#!/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" +REMOTE_HOST="kxkm@kxkm-ai" +REMOTE_DIR="/home/kxkm/mascarade-main" +LOG_DIR="${ROOT_DIR}/artifacts/cockpit" +mkdir -p "${LOG_DIR}" + +ACTION="plan" +APPLY=0 +JSON=0 +DAYS=14 +LOG_FILE="${LOG_DIR}/sync_mascarade_agents_kxkm_$(date '+%Y%m%d_%H%M%S').log" + +usage() { + cat <<'EOF' +Usage: bash tools/ops/sync_mascarade_agents_kxkm.sh [options] + +Options: + --action Action to run + --apply Apply remote write for sync + --json Emit JSON when available + --remote Remote host (default: kxkm@kxkm-ai) + --remote-dir Remote mascarade directory + --catalog Local catalog path + --days 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 plan sync 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}" >/dev/null +} + +require_catalog() { + if [[ ! -f "${CATALOG_FILE}" ]]; then + printf 'Missing catalog file: %s\n' "${CATALOG_FILE}" >&2 + exit 1 + fi +} + +agent_payload_json() { + python3 - "${CATALOG_FILE}" <<'PY' +import json +import sys +from pathlib import Path + +catalog_path = Path(sys.argv[1]) +data = json.loads(catalog_path.read_text(encoding="utf-8")) +strategy_map = { + "local-fast": "fastest", + "fallback-safe": "cheapest", +} +agents = [] +for item in data.get("profiles", []): + if not isinstance(item, dict): + continue + profile_id = str(item.get("id", "")).strip() + if not profile_id: + continue + label = str(item.get("label", "")).strip() or profile_id + category = str(item.get("category", "")).strip() or profile_id + intended = item.get("intended_tasks") + tasks = [entry.strip() for entry in intended if isinstance(entry, str) and entry.strip()] if isinstance(intended, list) else [] + summary = ", ".join(tasks[:3]) if tasks else category + 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", + "target_host": data.get("target_host", ""), + "catalog_file": str(catalog_path), + "agents": agents, +} +print(json.dumps(payload, ensure_ascii=False)) +PY +} + +emit_plan() { + local payload + payload="$(agent_payload_json)" + if [[ "${JSON}" -eq 1 ]]; then + printf '%s\n' "${payload}" + return 0 + fi + python3 - <<'PY' "${payload}" "${REMOTE_HOST}" "${REMOTE_DIR}" "${APPLY}" +import json +import sys + +payload = json.loads(sys.argv[1]) +remote_host = sys.argv[2] +remote_dir = sys.argv[3] +apply_mode = sys.argv[4] == "1" + +print("# Sync Mascarade agents -> kxkm-ai\n") +print(f"- remote_host: {remote_host}") +print(f"- remote_dir: {remote_dir}") +print(f"- apply: {apply_mode}") +print(f"- catalog_file: {payload.get('catalog_file', '')}") +print(f"- agent_count: {len(payload.get('agents', []))}") +for agent in payload.get("agents", []): + print( + f"- {agent['name']}: {agent.get('preferred_provider') or '-'} / " + f"{agent.get('preferred_model') or '-'} / strategy={agent.get('strategy')}" + ) +PY +} + +apply_sync() { + local payload + local payload_b64 + local response + payload="$(agent_payload_json)" + if [[ "${APPLY}" -ne 1 ]]; then + emit_plan + return 0 + fi + payload_b64="$(printf '%s' "${payload}" | base64 | tr -d '\n')" + + response="$( + ssh -o BatchMode=yes -o ConnectTimeout=5 "${REMOTE_HOST}" \ + "REMOTE_DIR='${REMOTE_DIR}' PAYLOAD_B64='${payload_b64}' python3 - <<'PY' +import base64 +import json +import os +import subprocess +from pathlib import Path + +payload = json.loads(base64.b64decode(os.environ['PAYLOAD_B64']).decode('utf-8')) +remote_dir = Path(os.environ['REMOTE_DIR']) +repo_target = remote_dir / 'data' / 'agents.json' +runtime_target = None +runtime_via_docker = False + +try: + inspect = subprocess.run( + ['docker', 'inspect', 'mascarade-core', '--format', '{{json .Mounts}}'], + check=True, + capture_output=True, + text=True, + ) + mounts = json.loads(inspect.stdout or '[]') + for mount in mounts: + if isinstance(mount, dict) and mount.get('Destination') == '/app/data': + runtime_target = 'docker://mascarade-core/app/data/agents.json' + runtime_via_docker = True + break +except Exception: + runtime_target = None + runtime_via_docker = False + +targets = [repo_target] +if runtime_target is not None and runtime_target not in targets: + targets.append(runtime_target) + +primary_target = runtime_target or repo_target +repo_target.parent.mkdir(parents=True, exist_ok=True) + +existing = [] +try: + if repo_target.exists(): + loaded = json.loads(repo_target.read_text(encoding='utf-8')) + if isinstance(loaded, list): + existing = [item for item in loaded if isinstance(item, dict)] +except json.JSONDecodeError: + existing = [] + +incoming = [item for item in payload.get('agents', []) if isinstance(item, dict)] +incoming_by_name = {} +for item in incoming: + name = str(item.get('name', '')).strip() + if name: + incoming_by_name[name] = item + +merged = [] +seen = set() +updated = [] +for item in existing: + name = str(item.get('name', '')).strip() + if name in incoming_by_name: + merged.append(incoming_by_name[name]) + updated.append(name) + seen.add(name) + else: + merged.append(item) + seen.add(name) + +created = [] +for name, item in incoming_by_name.items(): + if name not in seen: + merged.append(item) + created.append(name) + +serialized = json.dumps(merged, indent=2, ensure_ascii=False) + '\n' +repo_target.write_text(serialized, encoding='utf-8') + +if runtime_via_docker: + subprocess.run( + ['docker', 'exec', '-u', '0', '-i', 'mascarade-core', 'sh', '-lc', 'cat > /app/data/agents.json'], + input=serialized, + text=True, + check=True, + ) + +print(json.dumps({ + 'status': 'ok', + 'path': str(primary_target), + 'paths': [str(path) for path in targets], + 'created': created, + 'updated': updated, + 'count': len(merged), +})) +PY" + )" + + if [[ "${JSON}" -eq 1 ]]; then + printf '%s\n' "${response}" + else + python3 - "${response}" <<'PY' +import json +import sys + +payload = json.loads(sys.argv[1]) +print("# Sync applied\n") +print(f"- path: {payload.get('path', '')}") +print(f"- count: {payload.get('count', 0)}") +print(f"- created: {', '.join(payload.get('created', [])) or 'none'}") +print(f"- updated: {', '.join(payload.get('updated', [])) or 'none'}") +PY + fi +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --action) + ACTION="${2:-}" + shift 2 + ;; + --apply) + APPLY=1 + shift + ;; + --json) + JSON=1 + shift + ;; + --remote) + REMOTE_HOST="${2:-}" + shift 2 + ;; + --remote-dir) + REMOTE_DIR="${2:-}" + shift 2 + ;; + --catalog) + CATALOG_FILE="${2:-}" + shift 2 + ;; + --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 + : + fi +fi + +if ! [[ "${DAYS}" =~ ^[0-9]+$ ]]; then + printf -- '--days requires an integer\n' >&2 + exit 2 +fi + +require_catalog +log_line "INFO" "action=${ACTION} remote=${REMOTE_HOST} apply=${APPLY}" + +case "${ACTION}" in + plan) + emit_plan + ;; + sync) + apply_sync + ;; + clean-logs) + find "${LOG_DIR}" -type f -name 'sync_mascarade_agents_kxkm_*.log' -mtime +"${DAYS}" -delete + printf 'cleaned sync_mascarade_agents_kxkm logs older than %s days in %s\n' "${DAYS}" "${LOG_DIR}" + ;; + *) + printf 'Unknown action: %s\n' "${ACTION}" >&2 + usage >&2 + exit 2 + ;; +esac diff --git a/tools/ops/sync_mascarade_agents_tower.sh b/tools/ops/sync_mascarade_agents_tower.sh new file mode 100755 index 0000000..a78cf23 --- /dev/null +++ b/tools/ops/sync_mascarade_agents_tower.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CATALOG_FILE="${ROOT_DIR}/specs/contracts/mascarade_model_profiles.tower.json" +REMOTE_HOST="clems@192.168.0.120" +REMOTE_DIR="/home/clems/mascarade-main" +ACTION="plan" +APPLY=0 +JSON_OUTPUT=0 + +usage() { + cat <<'EOF' +Usage: bash tools/ops/sync_mascarade_agents_tower.sh [options] + +Options: + --action plan|sync Action to execute. Default: plan + --apply Apply remote merge into data/agents.json + --json Emit JSON + --catalog FILE Override catalog file + --remote-host HOST Override remote host + --remote-dir DIR Override remote Mascarade dir + --help Show this help +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --action) + ACTION="${2:-}" + shift 2 + ;; + --apply) + APPLY=1 + shift + ;; + --json) + JSON_OUTPUT=1 + shift + ;; + --catalog) + CATALOG_FILE="${2:-}" + shift 2 + ;; + --remote-host) + REMOTE_HOST="${2:-}" + shift 2 + ;; + --remote-dir) + REMOTE_DIR="${2:-}" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ "${ACTION}" != "plan" && "${ACTION}" != "sync" ]]; then + echo "Invalid --action: ${ACTION}" >&2 + exit 2 +fi + +if [[ ! -f "${CATALOG_FILE}" ]]; then + echo "Missing catalog file: ${CATALOG_FILE}" >&2 + exit 1 +fi + +catalog_payload="$( + python3 - "${CATALOG_FILE}" <<'PY' +import json +import sys +from pathlib import Path + +catalog = json.loads(Path(sys.argv[1]).read_text()) +profiles = catalog.get("profiles", []) +payload = [] +for profile in profiles: + payload.append( + { + "name": profile["name"], + "description": profile["description"], + "system_prompt": profile["system_prompt"], + "preferred_provider": profile["preferred_provider"], + "preferred_model": profile["preferred_model"], + "strategy": profile.get("strategy", "balanced"), + "temperature": profile.get("temperature", 0.2), + "max_tokens": profile.get("max_tokens", 4096), + } + ) +print(json.dumps({"catalog": catalog, "agents": payload}, ensure_ascii=True)) +PY +)" + +if [[ "${ACTION}" == "plan" || "${APPLY}" -eq 0 ]]; then + python3 - "${catalog_payload}" "${REMOTE_HOST}" "${REMOTE_DIR}" "${ACTION}" "${JSON_OUTPUT}" "${APPLY}" <<'PY' +import json +import sys + +blob = json.loads(sys.argv[1]) +catalog = blob["catalog"] +agents = blob["agents"] +summary = { + "status": "planned", + "action": sys.argv[4], + "apply": sys.argv[6] == "1", + "target_id": catalog.get("target_id", "tower"), + "target_host": sys.argv[2], + "remote_dir": sys.argv[3], + "catalog_file": sys.argv[1] if len(sys.argv) > 1 else "", + "agents": [agent["name"] for agent in agents], + "count": len(agents), +} +if sys.argv[5] == "1": + print(json.dumps(summary, indent=2, ensure_ascii=True)) +else: + print("Tower Mascarade agent sync plan") + print(f"- target: {summary['target_host']}") + print(f"- remote_dir: {summary['remote_dir']}") + print(f"- agents: {', '.join(summary['agents'])}") +PY + exit 0 +fi + +payload_b64="$(printf '%s' "${catalog_payload}" | base64 | tr -d '\n')" + +remote_result="$( + ssh -o BatchMode=yes -o ConnectTimeout=5 "${REMOTE_HOST}" \ + "mkdir -p '${REMOTE_DIR}/data' && PAYLOAD_B64='${payload_b64}' python3 - '${REMOTE_DIR}/data/agents.json'" <<'PY' +import base64 +import json +import os +import sys +from pathlib import Path + +path = Path(sys.argv[1]) +blob = json.loads(base64.b64decode(os.environ["PAYLOAD_B64"]).decode()) +agents = blob["agents"] +if path.exists(): + try: + existing = json.loads(path.read_text()) + except Exception: + existing = [] +else: + existing = [] +if not isinstance(existing, list): + existing = [] +by_name = {} +for item in existing: + if isinstance(item, dict) and item.get("name"): + by_name[item["name"]] = item +created = 0 +updated = 0 +for agent in agents: + name = agent["name"] + if name in by_name: + updated += 1 + else: + created += 1 + by_name[name] = agent +merged = [by_name[name] for name in sorted(by_name.keys())] +path.write_text(json.dumps(merged, indent=2, ensure_ascii=True) + "\n") +print(json.dumps({ + "status": "applied", + "target_file": str(path), + "created": created, + "updated": updated, + "count": len(merged), + "agents": [agent["name"] for agent in agents] +}, ensure_ascii=True)) +PY +)" + +python3 - "${remote_result}" "${REMOTE_HOST}" "${REMOTE_DIR}" "${JSON_OUTPUT}" <<'PY' +import json +import sys + +remote = json.loads(sys.argv[1]) +summary = { + "status": remote.get("status", "applied"), + "target_host": sys.argv[2], + "remote_dir": sys.argv[3], + "target_file": remote.get("target_file"), + "created": remote.get("created", 0), + "updated": remote.get("updated", 0), + "count": remote.get("count", 0), + "agents": remote.get("agents", []), +} +if sys.argv[4] == "1": + print(json.dumps(summary, indent=2, ensure_ascii=True)) +else: + print("Tower Mascarade agent sync applied") + print(f"- target: {summary['target_host']}") + print(f"- created: {summary['created']}") + print(f"- updated: {summary['updated']}") + print(f"- file: {summary['target_file']}") +PY diff --git a/tools/repo_state/lint_header_contract.py b/tools/repo_state/lint_header_contract.py index 8a3a051..dddf059 100755 --- a/tools/repo_state/lint_header_contract.py +++ b/tools/repo_state/lint_header_contract.py @@ -30,7 +30,7 @@ REQUIRED_JSON_KEYS = [ "pivot_changes", "impact_gates", ] -REQUIRED_REPOS = ["Kill_LIFE", "RTC_BL_PHONE", "le-mystere-professeur-zacus"] +REQUIRED_REPOS = ["Kill_LIFE"] def fail(msg: str) -> None: @@ -89,7 +89,11 @@ def main() -> int: fail(f"invalid header markers in {header_file}") for repo in REQUIRED_REPOS: - pattern = re.compile(rf"^{re.escape(repo)}\s+\| HEAD [0-9a-f]{{7,40}} \| pivots: .+ \| gates: .+$", re.MULTILINE) + pattern = re.compile( + rf"^{re.escape(repo)}\s*\|\s*HEAD [0-9a-f]{{7,40}}\s*\|" + rf"\s*pivots: .+\|\s*gates: .+$", + re.MULTILINE, + ) if not pattern.search(header_text): fail(f"missing repo line in header for {repo}") diff --git a/tools/repo_state/repo_refresh.sh b/tools/repo_state/repo_refresh.sh index 141b271..fab395b 100755 --- a/tools/repo_state/repo_refresh.sh +++ b/tools/repo_state/repo_refresh.sh @@ -3,18 +3,33 @@ set -euo pipefail usage() { cat < add an explicit target repo (can be repeated) + -h, --help print this help + +Target format: + - Name:Path pairs separated by ":". + - Example: --target "Kill_LIFE:/path/to/Kill_LIFE" + - Default target is the sibling Kill_LIFE path. USAGE } HEADER_ONLY=0 JSON_ONLY=0 NO_REFRESH=0 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +KILL_LIFE_REPO="$(cd "$SCRIPT_DIR/../.." && pwd)" +PARENT_DIR="$(cd "$KILL_LIFE_REPO/.." && pwd)" + +REPO_ROOT_KILL_LIFE="${REPO_ROOT_KILL_LIFE:-$PARENT_DIR/Kill_LIFE}" +DEFAULT_TARGETS=("Kill_LIFE:${REPO_ROOT_KILL_LIFE}") + +TARGETS=() while [[ $# -gt 0 ]]; do case "$1" in @@ -30,6 +45,14 @@ while [[ $# -gt 0 ]]; do NO_REFRESH=1 shift ;; + --target) + if [[ $# -lt 2 ]]; then + echo "[fail] --target requires Name:Path" >&2 + exit 1 + fi + TARGETS+=("$2") + shift 2 + ;; -h|--help) usage exit 0 @@ -47,13 +70,16 @@ if [[ "$HEADER_ONLY" == "1" && "$JSON_ONLY" == "1" ]]; then exit 1 fi -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -KILL_LIFE_REPO="$(cd "$SCRIPT_DIR/../.." && pwd)" -PARENT_DIR="$(cd "$KILL_LIFE_REPO/.." && pwd)" +if [[ -n "${REPO_STATE_TARGETS:-}" ]]; then + IFS=',' read -r -a env_targets <<< "${REPO_STATE_TARGETS}" + for target in "${env_targets[@]}"; do + [[ -n "${target// }" ]] && TARGETS+=("${target}") + done +fi -REPO_ROOT_KILL_LIFE="${REPO_ROOT_KILL_LIFE:-$PARENT_DIR/Kill_LIFE}" -REPO_ROOT_RTC_BL_PHONE="${REPO_ROOT_RTC_BL_PHONE:-$PARENT_DIR/RTC_BL_PHONE}" -REPO_ROOT_ZACUS="${REPO_ROOT_ZACUS:-$PARENT_DIR/le-mystere-professeur-zacus}" +if [[ ${#TARGETS[@]} -eq 0 ]]; then + TARGETS=("${DEFAULT_TARGETS[@]}") +fi ART_DIR="${REPO_STATE_ART_DIR:-$KILL_LIFE_REPO/artifacts/repo_state}" SUMMARY_FILE="$ART_DIR/global_summary.md" @@ -63,9 +89,18 @@ HEADER_FILE="$ART_DIR/header.latest.md" mkdir -p "$ART_DIR" refresh_one_repo() { - local repo_name="$1" - local repo_root="$2" + local target="$1" + local repo_name="${target%%:*}" + local repo_root="${target#*:}" + if [[ "$target" == "$repo_name" ]]; then + echo "[fail] invalid target format, expected Name:Path: $target" >&2 + return 1 + fi + if [[ -z "$repo_name" || -z "$repo_root" ]]; then + echo "[fail] target malformed: $target" >&2 + return 1 + fi if [[ ! -d "$repo_root/.git" ]]; then echo "[fail] missing repo for $repo_name at $repo_root" >&2 return 1 @@ -80,18 +115,25 @@ refresh_one_repo() { } if [[ "$NO_REFRESH" != "1" ]]; then - refresh_one_repo "Kill_LIFE" "$REPO_ROOT_KILL_LIFE" - refresh_one_repo "RTC_BL_PHONE" "$REPO_ROOT_RTC_BL_PHONE" - refresh_one_repo "le-mystere-professeur-zacus" "$REPO_ROOT_ZACUS" + for target in "${TARGETS[@]}"; do + refresh_one_repo "$target" + done fi -KILL_MD="$REPO_ROOT_KILL_LIFE/docs/REPO_STATE.md" -RTC_MD="$REPO_ROOT_RTC_BL_PHONE/docs/REPO_STATE.md" -ZACUS_MD="$REPO_ROOT_ZACUS/docs/REPO_STATE.md" +MD_FILES=() +for target in "${TARGETS[@]}"; do + repo_name="${target%%:*}" + repo_root="${target#*:}" + md_path="$repo_root/docs/REPO_STATE.md" + if [[ ! -f "$md_path" ]]; then + echo "[fail] missing docs/REPO_STATE.md for ${repo_name} at $repo_root" >&2 + exit 1 + fi + MD_FILES+=("$md_path") +done -python3 - "$SUMMARY_FILE" "$INDEX_JSON_FILE" "$HEADER_FILE" "$KILL_MD" "$RTC_MD" "$ZACUS_MD" <<'PY' +python3 - "$SUMMARY_FILE" "$INDEX_JSON_FILE" "$HEADER_FILE" "${MD_FILES[@]}" <<'PY' import json -import re import sys from datetime import datetime, timezone from pathlib import Path @@ -115,7 +157,6 @@ required_keys = [ def parse_md(path: Path) -> dict: if not path.exists(): raise SystemExit(f"[fail] missing file: {path}") - lines = path.read_text(encoding="utf-8").splitlines() if not lines or lines[0].strip() != "": raise SystemExit(f"[fail] invalid header marker in {path}") @@ -124,8 +165,8 @@ def parse_md(path: Path) -> dict: for line in lines: if ":" not in line: continue - k, v = line.split(":", 1) - data[k.strip()] = v.strip() + key, value = line.split(":", 1) + data[key.strip()] = value.strip() missing = [k for k in required_keys if k not in data] if missing: @@ -137,7 +178,6 @@ def parse_md(path: Path) -> dict: raise SystemExit(f"[fail] invalid PivotChanges JSON in {path}: {exc}") gates = [g.strip() for g in data["ImpactGates"].split(",") if g.strip()] - return { "repo": data["Repo"], "branch": data["Branch"], @@ -153,6 +193,7 @@ def parse_md(path: Path) -> dict: entries = [parse_md(p) for p in md_files] now_utc = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + def pivot_text(entry: dict) -> str: pivots = entry["pivot_changes"] if not pivots: @@ -165,6 +206,7 @@ def pivot_text(entry: dict) -> str: paths.append("...") return "; ".join(paths) + header_lines = [f"[REPO-STATE UTC: {now_utc}]"] for entry in entries: head_short = entry["head"][:8] @@ -172,8 +214,7 @@ for entry in entries: gates = ", ".join(entry["impact_gates"]) if entry["impact_gates"] else "general_change" header_lines.append(f"{entry['repo']:<28} | HEAD {head_short} | pivots: {pivots} | gates: {gates}") header_lines.append("[/REPO-STATE]") -header_text = "\n".join(header_lines) + "\n" -header_file.write_text(header_text, encoding="utf-8") +header_file.write_text("\n".join(header_lines) + "\n", encoding="utf-8") summary_lines = ["# Global Repo State", "", f"GeneratedAtUTC: `{now_utc}`", ""] for entry in entries: diff --git a/tools/run_github_dispatch_mcp.sh b/tools/run_github_dispatch_mcp.sh index 7c8e280..234e711 100755 --- a/tools/run_github_dispatch_mcp.sh +++ b/tools/run_github_dispatch_mcp.sh @@ -8,7 +8,7 @@ kill_life_runtime_home_init "$ROOT_DIR" "github-dispatch-mcp" MASCARADE_DIR="$( kill_life_resolve_mascarade_dir \ "$ROOT_DIR" \ - "core/mascarade/integrations/github_dispatch.py" + "core/mascarade" )" MASCARADE_ENV_FILE="${MASCARADE_ENV_FILE:-$MASCARADE_DIR/.env}" diff --git a/tools/run_knowledge_base_mcp.sh b/tools/run_knowledge_base_mcp.sh index 7e50ff1..861631a 100755 --- a/tools/run_knowledge_base_mcp.sh +++ b/tools/run_knowledge_base_mcp.sh @@ -8,7 +8,7 @@ kill_life_runtime_home_init "$ROOT_DIR" "knowledge-base-mcp" MASCARADE_DIR="$( kill_life_resolve_mascarade_dir \ "$ROOT_DIR" \ - "core/mascarade/integrations/knowledge_base.py" + "core/mascarade" )" MASCARADE_ENV_FILE="${MASCARADE_ENV_FILE:-$MASCARADE_DIR/.env}" diff --git a/tools/specs/mesh_contract_check.py b/tools/specs/mesh_contract_check.py new file mode 100755 index 0000000..70f9d06 --- /dev/null +++ b/tools/specs/mesh_contract_check.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + + +class ValidationError(Exception): + pass + + +def validate(schema: dict[str, Any], instance: Any, path: str = "$") -> None: + if "const" in schema and instance != schema["const"]: + raise ValidationError(f"{path}: expected const {schema['const']!r}, got {instance!r}") + if "enum" in schema and instance not in schema["enum"]: + raise ValidationError(f"{path}: expected one of {schema['enum']!r}, got {instance!r}") + + schema_type = schema.get("type") + if schema_type == "object": + if not isinstance(instance, dict): + raise ValidationError(f"{path}: expected object, got {type(instance).__name__}") + required = schema.get("required", []) + for key in required: + if key not in instance: + raise ValidationError(f"{path}: missing required key {key!r}") + properties = schema.get("properties", {}) + for key, value in instance.items(): + if key in properties: + validate(properties[key], value, f"{path}.{key}") + elif schema.get("additionalProperties", True) is False: + raise ValidationError(f"{path}: unexpected property {key!r}") + return + + if schema_type == "array": + if not isinstance(instance, list): + raise ValidationError(f"{path}: expected array, got {type(instance).__name__}") + min_items = schema.get("minItems") + if min_items is not None and len(instance) < min_items: + raise ValidationError(f"{path}: expected at least {min_items} items, got {len(instance)}") + item_schema = schema.get("items") + if item_schema is not None: + for idx, item in enumerate(instance): + validate(item_schema, item, f"{path}[{idx}]") + return + + if schema_type == "string": + if not isinstance(instance, str): + raise ValidationError(f"{path}: expected string, got {type(instance).__name__}") + min_length = schema.get("minLength") + if min_length is not None and len(instance) < min_length: + raise ValidationError(f"{path}: expected string length >= {min_length}, got {len(instance)}") + return + + if schema_type == "integer": + if not isinstance(instance, int) or isinstance(instance, bool): + raise ValidationError(f"{path}: expected integer, got {type(instance).__name__}") + return + + if schema_type == "boolean": + if not isinstance(instance, bool): + raise ValidationError(f"{path}: expected boolean, got {type(instance).__name__}") + return + + +def load_json(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Validate mesh contract JSON instances with the local lightweight checker." + ) + parser.add_argument("--schema", required=True, help="Path to the schema JSON file.") + parser.add_argument("--instance", required=True, help="Path to the JSON instance to validate.") + args = parser.parse_args() + + schema_path = Path(args.schema) + instance_path = Path(args.instance) + + schema = load_json(schema_path) + instance = load_json(instance_path) + validate(schema, instance) + + print(json.dumps({ + "status": "ok", + "schema": str(schema_path), + "instance": str(instance_path), + }, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/test_python.sh b/tools/test_python.sh index 3a9fcec..8cb39fd 100755 --- a/tools/test_python.sh +++ b/tools/test_python.sh @@ -47,6 +47,13 @@ stable: test/test_mcp_runtime_status.py test/test_openclaw_sanitizer.py test/test_apply_safe_patch.py + test/test_machine_registry_contract.py + test/test_log_ops_contract.py + test/test_intelligence_tui_contract.py + test/test_runtime_ai_gateway_contract.py + test/test_yiacad_uiux_tui_contract.py + test/test_yiacad_native_surface_contract.py + test/test_zeroclaw_n8n_workflow_contract.py test/test_auto_check_ci_cd.py test/test_firmware_evidence.py test/test_validate_specs.py @@ -137,6 +144,13 @@ if [[ "${SUITE}" == "stable" || "${SUITE}" == "all" ]]; then run_discover test 'test_mcp_runtime_status.py' run_discover test 'test_openclaw_sanitizer.py' run_discover test 'test_apply_safe_patch.py' + run_discover test 'test_machine_registry_contract.py' + run_discover test 'test_log_ops_contract.py' + run_discover test 'test_intelligence_tui_contract.py' + run_discover test 'test_runtime_ai_gateway_contract.py' + run_discover test 'test_yiacad_uiux_tui_contract.py' + run_discover test 'test_yiacad_native_surface_contract.py' + run_discover test 'test_zeroclaw_n8n_workflow_contract.py' run_discover test 'test_auto_check_ci_cd.py' run_discover test 'test_firmware_evidence.py' run_discover test 'test_validate_specs.py'