Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
91 changes: 91 additions & 0 deletions deploy/production-channel-roster-ops.sh
Original file line number Diff line number Diff line change
@@ -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
140 changes: 140 additions & 0 deletions deploy/production-file-deploy.sh
Original file line number Diff line number Diff line change
@@ -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 =="
43 changes: 43 additions & 0 deletions docs/production-file-deploy.md
Original file line number Diff line number Diff line change
@@ -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.
Loading