From bee560070b483f38c4d4e1fd716b709820fdddc6 Mon Sep 17 00:00:00 2001 From: Codex Worker Date: Wed, 24 Jun 2026 12:34:04 +0000 Subject: [PATCH] chore: add production file deploy tooling --- CHANGELOG.md | 6 + deploy/production-channel-roster-ops.sh | 91 +++++++++++++++ deploy/production-file-deploy.sh | 140 ++++++++++++++++++++++++ docs/production-file-deploy.md | 43 ++++++++ 4 files changed, 280 insertions(+) create mode 100755 deploy/production-channel-roster-ops.sh create mode 100755 deploy/production-file-deploy.sh create mode 100644 docs/production-file-deploy.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 61e1639..de8c205 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Production file-level deploy tooling** — `deploy/production-file-deploy.sh` + now builds gitignored `dist/` artifacts before copying the live-runtime + allowlist, includes Phase N core/MCP/channel roster files, and refuses to + deploy if channel/personality markers are missing. Added + `deploy/production-channel-roster-ops.sh` plus docs for the non-checkout + production tree. - **Phase N / N1 channel roster primitives** — `@murmurv2/core` now exposes `ChannelRosterStore` plus typed `ChannelRecord` / `ChannelMemberRecord` APIs. The roster keeps `channelId` distinct from legacy `conversationId`, stores `channels` / `channel_members` in a dedicated SQLite store, preserves existing message-history APIs, and reserves member-level `personaId`, `model`, `baseInstructionsHash`, and `eligibility` fields for N2 addressing and N3 personality binding. - **Phase N / N2 addressing policy primitive** — `ChannelRosterStore.evaluateAddressing()` returns a shared reject/append/wake decision for `channelId` + explicit addressee flows: legacy no-channel remains broadcast, non-members are rejected, addressed members wake, and observers append history while staying muted. - **Phase N / N3 personality binding** — `buildChannelThreadStartBinding()` projects a `ChannelMemberRecord` into Codex app-server `thread/start` overrides (`model`, `personality`, optional `baseInstructions`, and audit metadata). Daemon wiring is opt-in only (`channelRoster.enabled` or `MURMUR_CHANNEL_ROSTER=1`) and leaves legacy wake behavior unchanged by default. diff --git a/deploy/production-channel-roster-ops.sh b/deploy/production-channel-roster-ops.sh new file mode 100755 index 0000000..dae3b43 --- /dev/null +++ b/deploy/production-channel-roster-ops.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Enable/disable/status helper for Phase N channel roster personality binding. +# This edits only daemon agent-config.json files and restarts only Murmur daemons. +# It never restarts the shared NATS broker. +set -euo pipefail + +BOEVOY="${MURMUR_RUNTIME_DIR:-/opt/lifecoach/mur-mur-v2}" +JARVIS_CFG="${MURMUR_JARVIS_CONFIG:-$BOEVOY/.data/agent-config.json}" +CODEX_CFG="${MURMUR_CODEX_CONFIG:-$BOEVOY/.data-codex-volt/agent-config.json}" +JARVIS_DB="${MURMUR_JARVIS_ROSTER_DB:-$BOEVOY/.data/channel-roster.db}" +CODEX_DB="${MURMUR_CODEX_ROSTER_DB:-$BOEVOY/.data-codex-volt/channel-roster.db}" +SERVICES="${MURMUR_CHANNEL_ROSTER_SERVICES:-murmur-daemon-codex-volt murmur-daemon-jarvis}" + +set_channel_roster() { + local cfg="$1" enabled="$2" path="$3" + python3 - "$cfg" "$enabled" "$path" <<'PY' +import json +import os +import shutil +import sys + +cfg, enabled, db_path = sys.argv[1], sys.argv[2] == "true", sys.argv[3] +with open(cfg, encoding="utf-8") as f: + data = json.load(f) +backup = f"{cfg}.bak-channel-roster" +if not os.path.exists(backup): + shutil.copy2(cfg, backup) +roster = data.setdefault("channelRoster", {}) +roster["enabled"] = enabled +roster["path"] = db_path +with open(cfg, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + f.write("\n") +print(f" {cfg}: channelRoster.enabled={enabled}, path={db_path}") +PY +} + +show_channel_roster() { + local name="$1" cfg="$2" + python3 - "$name" "$cfg" <<'PY' +import json +import sys + +name, cfg = sys.argv[1], sys.argv[2] +try: + with open(cfg, encoding="utf-8") as f: + roster = json.load(f).get("channelRoster", {}) +except FileNotFoundError: + print(f"{name}: missing config {cfg}") + raise SystemExit(0) +print(f"{name}: enabled={roster.get('enabled')!r} path={roster.get('path')!r}") +PY +} + +restart_services() { + for svc in $SERVICES; do + systemctl restart "$svc" + sleep 3 + systemctl is-active --quiet "$svc" || { echo "FAIL: $svc not active"; exit 1; } + echo " $svc: active" + done +} + +if [ "$(id -u)" != "0" ]; then + echo "FAIL: must run as root" >&2 + exit 1 +fi + +case "${1:-status}" in + enable) + set_channel_roster "$CODEX_CFG" true "$CODEX_DB" + set_channel_roster "$JARVIS_CFG" true "$JARVIS_DB" + restart_services + ;; + disable) + set_channel_roster "$CODEX_CFG" false "$CODEX_DB" + set_channel_roster "$JARVIS_CFG" false "$JARVIS_DB" + restart_services + ;; + status) + show_channel_roster "codex-volt" "$CODEX_CFG" + show_channel_roster "jarvis" "$JARVIS_CFG" + for svc in $SERVICES; do + systemctl is-active "$svc" | sed "s/^/$svc: /" + done + ;; + *) + echo "usage: $0 {enable|disable|status}" >&2 + exit 2 + ;; +esac diff --git a/deploy/production-file-deploy.sh b/deploy/production-file-deploy.sh new file mode 100755 index 0000000..d47b2b3 --- /dev/null +++ b/deploy/production-file-deploy.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# File-level production deploy for the live /opt/lifecoach/mur-mur-v2 tree. +# +# The production tree intentionally keeps local state, secrets, node_modules, and +# site glue outside a normal git checkout. This script clones main, builds dist, +# runs the non-network regression gates, then copies only an audited allowlist of +# runtime files. It never restarts the shared NATS broker. +set -euo pipefail + +DST="${MURMUR_DEPLOY_DST:-/opt/lifecoach/mur-mur-v2}" +REPO="${MURMUR_DEPLOY_REPO:-https://github.com/alexfrmn/mur-mur-v2.git}" +REF="${MURMUR_DEPLOY_REF:-main}" +RUN_TESTS="${MURMUR_DEPLOY_TESTS:-1}" +RESTART_SERVICES="${MURMUR_DEPLOY_RESTART:-1}" +RESTART_ORDER="${MURMUR_DEPLOY_RESTART_ORDER:-murmur-daemon-codex-volt murmur-daemon-jarvis}" + +FILES=( + "package.json" + "package-lock.json" + "tsconfig.json" + "packages/core/package.json" + "packages/core/tsconfig.json" + "packages/core/src/index.ts" + "packages/core/src/channel.ts" + "packages/core/src/discovery.ts" + "packages/core/src/lease.ts" + "packages/core/dist/src/index.js" + "packages/core/dist/src/index.d.ts" + "packages/core/dist/src/channel.js" + "packages/core/dist/src/channel.d.ts" + "packages/core/dist/src/discovery.js" + "packages/core/dist/src/discovery.d.ts" + "packages/core/dist/src/lease.js" + "packages/core/dist/src/lease.d.ts" + "packages/broker-nats/package.json" + "packages/broker-nats/tsconfig.json" + "packages/broker-nats/src/index.ts" + "packages/broker-nats/dist/src/index.js" + "packages/broker-nats/dist/src/index.d.ts" + "packages/mcp-server/package.json" + "packages/mcp-server/tsconfig.json" + "packages/mcp-server/src/index.ts" + "packages/mcp-server/src/request-reply.ts" + "packages/mcp-server/dist/src/index.js" + "packages/mcp-server/dist/src/index.d.ts" + "packages/mcp-server/dist/src/request-reply.js" + "packages/mcp-server/dist/src/request-reply.d.ts" + "scripts/murmur-daemon.mjs" + "scripts/murmur-jetstream-advisory.mjs" + "scripts/codex-app-server-wake.mjs" + "scripts/wake-monitor.mjs" + "scripts/prometheus-exporter.mjs" +) + +require_root() { + if [ "$(id -u)" != "0" ]; then + echo "FAIL: must run as root" >&2 + exit 1 + fi +} + +copy_one() { + local src="$1" dst="$2" backup_dir="$3" + if [ ! -f "$src" ]; then + echo "FAIL: missing built source file: $src" >&2 + exit 1 + fi + mkdir -p "$(dirname "$dst")" "$(dirname "$backup_dir/$dst")" + if [ -f "$dst" ]; then + cp -a "$dst" "$backup_dir/$dst" + fi + install -o root -g root -m 0644 "$src" "$dst" + echo " deployed: ${dst#$DST/}" +} + +health() { + systemctl show "$1" -p ActiveState,SubState,MainPID,NRestarts --value | paste -sd' ' +} + +require_root +if [ ! -d "$DST" ]; then + echo "FAIL: destination does not exist: $DST" >&2 + exit 1 +fi + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +echo "== fetch ==" +git clone --quiet --depth 30 --branch "$REF" "$REPO" "$tmp/repo" +cd "$tmp/repo" +echo " source HEAD: $(git rev-parse HEAD)" + +echo "== install + build ==" +npm ci +npm run build + +if [ "$RUN_TESTS" = "1" ]; then + echo "== tests ==" + npm run test:unit + npm run test:core +fi + +echo "== sanity gates ==" +test -f packages/core/dist/src/channel.js || { echo "FAIL: core channel dist missing"; exit 1; } +grep -q "ChannelRosterStore" packages/core/dist/src/channel.js || { echo "FAIL: core dist missing ChannelRosterStore"; exit 1; } +grep -q "./channel.js" packages/core/dist/src/index.js || { echo "FAIL: core index missing channel export"; exit 1; } +grep -q "max_deliver" packages/broker-nats/dist/src/index.js || { echo "FAIL: broker missing JetStream max_deliver"; exit 1; } +grep -q "ChannelRosterStore" scripts/murmur-daemon.mjs || { echo "FAIL: daemon missing channel roster wiring"; exit 1; } +grep -q "buildChannelThreadStartBinding" scripts/codex-app-server-wake.mjs || { echo "FAIL: wake script missing N3 binding"; exit 1; } +if grep -q "payload\\??\\.threadStartBinding\\|payload\\[.*threadStartBinding" scripts/codex-app-server-wake.mjs; then + echo "FAIL: wake script must not trust remote payload threadStartBinding" >&2 + exit 1 +fi + +echo "== backup + copy allowlist ==" +ts="$(date -u +%Y%m%dT%H%M%SZ)" +backup_dir="/opt/lifecoach/backups/murmur-file-deploy/$ts" +mkdir -p "$backup_dir" +printf '%s\n' "$(git rev-parse HEAD)" > "$backup_dir/source-head.txt" +for file in "${FILES[@]}"; do + copy_one "$tmp/repo/$file" "$DST/$file" "$backup_dir" +done + +echo "== runtime import smoke ==" +(cd "$DST" && node --input-type=module -e "import { ChannelRosterStore, buildChannelThreadStartBinding } from '@murmurv2/core'; if (!ChannelRosterStore || !buildChannelThreadStartBinding) process.exit(1); console.log(' core channel exports ok');") + +if [ "$RESTART_SERVICES" = "1" ]; then + echo "== restart daemons (shared NATS untouched) ==" + for svc in $RESTART_ORDER; do + systemctl restart "$svc" + sleep 4 + echo " $svc: $(health "$svc")" + systemctl is-active --quiet "$svc" || { echo "FAIL: $svc not active after restart"; exit 1; } + done +else + echo "== restart skipped (MURMUR_DEPLOY_RESTART=0) ==" +fi + +echo "== DONE: production file deploy complete ==" diff --git a/docs/production-file-deploy.md b/docs/production-file-deploy.md new file mode 100644 index 0000000..7f89792 --- /dev/null +++ b/docs/production-file-deploy.md @@ -0,0 +1,43 @@ +# Production File-Level Deploy + +The live `/opt/lifecoach/mur-mur-v2` tree is not a plain git checkout. It keeps +local state, secrets, `node_modules`, and site-specific integration scripts that +must survive repo updates. Production deploys therefore copy an audited file +allowlist from a freshly built git clone instead of replacing the tree. + +Use `deploy/production-file-deploy.sh` as the source-controlled deploy contract. +It: + +- clones the selected ref (`MURMUR_DEPLOY_REF`, default `main`); +- runs `npm ci`, `npm run build`, `npm run test:unit`, and `npm run test:core`; +- verifies Phase N channel roster/personality markers in built core and wake + scripts; +- copies only the runtime allowlist into `/opt/lifecoach/mur-mur-v2`; +- backs up overwritten files under `/opt/lifecoach/backups/murmur-file-deploy/`; +- restarts only the Murmur daemons, never the shared NATS broker. + +For a build/copy gate without daemon restarts: + +```bash +sudo MURMUR_DEPLOY_RESTART=0 deploy/production-file-deploy.sh +``` + +For production rollout, keep the default canary order unless a runbook says +otherwise: + +```bash +sudo deploy/production-file-deploy.sh +``` + +Channel roster personality binding is a separate runtime flag. Use +`deploy/production-channel-roster-ops.sh`: + +```bash +sudo deploy/production-channel-roster-ops.sh status +sudo deploy/production-channel-roster-ops.sh enable +sudo deploy/production-channel-roster-ops.sh disable +``` + +The ops script edits only each daemon's `agent-config.json` `channelRoster` +section and restarts only the Murmur daemons. It does not touch message DBs, +keys, `.env`, `node_modules`, local ACP glue, or the shared NATS broker.