think
16px
820px

Monorepo Public/Internal Split — Plan B: Deploy + Cutover

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Take the 4-app monorepo from Plan A, build Docker images for each, wire up two isolated compose stacks (public + internal) on Server 2, run parity smoke tests against staging, and cut traffic on x056.ahu-demo.chatbot-neo.val.id from the legacy single-container deploy to the new split stack — with rollback ready.

Architecture: Server 1 builds all Docker images (CPU-only, fast). Images are pushed to Server 2 via docker save | ssh docker load (no external registry needed for POC scale). Two docker-compose files on Server 2 — compose.public.yaml (public-web + public-agent + Redis) and compose.internal.yaml (internal-web + internal-agent) — share a gateway-net bridge to reach vLLM/Milvus/embedding on the host's existing ahu-net. DNS/reverse-proxy flip on x056.ahu-demo.chatbot-neo.val.id is the one-shot cutover; rollback is a DNS/proxy revert.

Tech Stack: Docker Compose v2, docker save/load, ssh, nginx (existing reverse proxy on Server 1), no external container registry.

Spec: docs/superpowers/specs/2026-06-30-monorepo-public-internal-split-design.md
Prior plan: docs/superpowers/plans/2026-06-30-monorepo-split-plan-a-skeleton-and-refactor.md


Where this plan runs

  • Tasks 1-10 run on Server 1 (192.168.62.155, the 0-GPU host where source lives). All file edits and image builds happen here.
  • Tasks 11-14 run on Server 2 (192.168.83.20, GPU host) via SSH from Server 1. Image transfer, compose up, staging smoke.
  • Task 15 is out-of-band coordination (DNS TTL adjustment on whoever owns the DNS zone).
  • Tasks 16-22 span both hosts — cutover is orchestrated from Server 1's terminal but affects Server 2's containers and the reverse-proxy config.

Key infrastructure facts

  • vLLM (Qwen3.6-35B-A3B-FP8, container ahu-vllm) listens on 172.17.0.1:8000 on Server 2. Address the containers use: http://ahu-vllm:8000 on the ahu-net docker network.
  • Milvus + embedding model live on ahu-net on Server 2 as well.
  • Existing ai-ahu-rag service on Server 2 listens on port :8110 — public-web reaches it via http://ai-ahu-rag:8110 on ahu-net.
  • x056.ahu-demo.chatbot-neo.val.id currently DNS-routes to Server 1's nginx which proxies to ahu-chatbot-dev container on :8120. That's what gets flipped in Task 19.

File Structure (end state of Plan B)

ahu-ai-chatbot/
├── apps/
   ├── public-web/
      └── Dockerfile                    # NEW — Next.js standalone build
   ├── internal-web/
      └── Dockerfile                    # NEW — Next.js standalone build
   ├── public-agent/
      └── Dockerfile                    # MODIFIED — reads from monorepo root context
   └── internal-agent/
       └── Dockerfile                    # MODIFIED — same
├── infra/
   ├── compose.public.yaml               # NEW — public stack
   ├── compose.internal.yaml             # NEW — internal stack
   ├── compose.shared.yaml               # NEW — Redis + external ahu-net attach
   ├── env/
      ├── public.env.example            # NEW — env template for public stack
      ├── internal.env.example          # NEW — env template for internal stack
      └── shared.env.example            # NEW — Redis + secrets shared
   ├── nginx/
      └── chatbot-neo.conf.new          # NEW — replaces existing nginx site block
   └── deploy/
       ├── build-and-ship.sh             # NEW — build all 4 images + scp to Server 2
       ├── deploy-staging.sh             # NEW — pull images + docker compose up (staging URLs)
       ├── deploy-prod.sh                # NEW — production compose up
       └── rollback.sh                   # NEW — flip DNS back + docker compose down new stack
└── docs/superpowers/plans/
    └── 2026-07-01-monorepo-split-plan-b-completion-notes.md   # NEW at Task 22

Phase 2A — Dockerfiles

Task 1: Write apps/public-web/Dockerfile for Next.js standalone

Files:
- Create: apps/public-web/Dockerfile
- Modify: apps/public-web/next.config.ts — add output: "standalone" if not present

  • [ ] Step 1: Enable standalone output in next.config.ts

Read current apps/public-web/next.config.ts. It should look like:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // ...existing keys
};

export default nextConfig;

Add output: "standalone":

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "standalone",
  // ...existing keys
};

export default nextConfig;
  • [ ] Step 2: Write apps/public-web/Dockerfile
# syntax=docker/dockerfile:1.6

# ─── Stage 1: install workspace deps ─────────────────────────────
FROM node:20-alpine AS deps
WORKDIR /repo
RUN apk add --no-cache libc6-compat python3 make g++ \
  && corepack enable \
  && corepack prepare pnpm@11.1.1 --activate
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
COPY apps/public-web/package.json apps/public-web/
COPY packages/streams/package.json packages/streams/
COPY packages/orchestrator-types/package.json packages/orchestrator-types/
COPY packages/ui/package.json packages/ui/
COPY packages/queue/package.json packages/queue/
RUN pnpm install --frozen-lockfile

# ─── Stage 2: build ──────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /repo
RUN apk add --no-cache libc6-compat python3 make g++ \
  && corepack enable \
  && corepack prepare pnpm@11.1.1 --activate
COPY --from=deps /repo/node_modules ./node_modules
COPY --from=deps /repo/apps/public-web/node_modules ./apps/public-web/node_modules
COPY --from=deps /repo/packages ./packages
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
COPY packages/ ./packages/
COPY apps/public-web/ ./apps/public-web/
COPY infra/policy/ ./infra/policy/
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm --filter @ahu/public-web build

# ─── Stage 3: runtime ────────────────────────────────────────────
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
COPY --from=builder /repo/apps/public-web/.next/standalone ./
COPY --from=builder /repo/apps/public-web/.next/static ./apps/public-web/.next/static
COPY --from=builder /repo/apps/public-web/public ./apps/public-web/public
COPY --from=builder /repo/infra/policy /infra/policy
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "apps/public-web/server.js"]
  • [ ] Step 3: Build the image and verify it starts
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
docker build -t ahu-ai-chatbot-public:dev -f apps/public-web/Dockerfile .

Expected: build succeeds, outputs naming to docker.io/library/ahu-ai-chatbot-public:dev.

docker run --rm -d --name test-public -p 33000:3000 \
  -e MODEL_GATEWAY_URL=http://placeholder:8000 \
  -e LLM_PLANNING_MODEL=test \
  -e LLM_SYNTHESIS_MODEL=test \
  -e RAG_BASE_URL=http://placeholder:8110 \
  -e DATA_PUBLIC_BASE_URL=http://placeholder:8000 \
  -e DATA_PUBLIC_AGENT_ID=data-agent \
  -e ORCHESTRATOR_CONFIG_DB=/tmp/cfg.sqlite \
  -e ORCHESTRATOR_POLICY_DB=/tmp/policy.sqlite \
  -e POLICY_DEFAULTS_SHARED_PATH=/infra/policy/policy_defaults_shared.json \
  ahu-ai-chatbot-public:dev
sleep 3
curl -sf http://localhost:33000/api/health | tee /dev/stderr | grep -q '"ok":true'
docker rm -f test-public

Expected: curl prints {"ok":true,"surface":"public",...} and grep exits 0.

  • [ ] Step 4: Commit
git add apps/public-web/Dockerfile apps/public-web/next.config.ts
git commit -m "feat(public-web): Dockerfile with Next.js standalone build

Multi-stage build using pnpm workspace. Runtime image is ~150MB
(node:20-alpine + standalone .next + public/ + policy shared JSON).
Verified /api/health returns ok=true from placeholder env."

Task 2: Write apps/internal-web/Dockerfile

Files:
- Create: apps/internal-web/Dockerfile
- Modify: apps/internal-web/next.config.ts

  • [ ] Step 1: Enable standalone output

Same as Task 1 Step 1 but for apps/internal-web/next.config.ts.

  • [ ] Step 2: Write apps/internal-web/Dockerfile

Same shape as Task 1 with these substitutions:
- Replace every public-web with internal-web
- Replace CMD's apps/public-web/server.js with apps/internal-web/server.js

# syntax=docker/dockerfile:1.6

FROM node:20-alpine AS deps
WORKDIR /repo
RUN apk add --no-cache libc6-compat python3 make g++ \
  && corepack enable \
  && corepack prepare pnpm@11.1.1 --activate
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
COPY apps/internal-web/package.json apps/internal-web/
COPY packages/streams/package.json packages/streams/
COPY packages/orchestrator-types/package.json packages/orchestrator-types/
COPY packages/ui/package.json packages/ui/
COPY packages/queue/package.json packages/queue/
RUN pnpm install --frozen-lockfile

FROM node:20-alpine AS builder
WORKDIR /repo
RUN apk add --no-cache libc6-compat python3 make g++ \
  && corepack enable \
  && corepack prepare pnpm@11.1.1 --activate
COPY --from=deps /repo/node_modules ./node_modules
COPY --from=deps /repo/apps/internal-web/node_modules ./apps/internal-web/node_modules
COPY --from=deps /repo/packages ./packages
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
COPY packages/ ./packages/
COPY apps/internal-web/ ./apps/internal-web/
COPY infra/policy/ ./infra/policy/
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm --filter @ahu/internal-web build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
COPY --from=builder /repo/apps/internal-web/.next/standalone ./
COPY --from=builder /repo/apps/internal-web/.next/static ./apps/internal-web/.next/static
COPY --from=builder /repo/apps/internal-web/public ./apps/internal-web/public
COPY --from=builder /repo/infra/policy /infra/policy
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "apps/internal-web/server.js"]
  • [ ] Step 3: Build + smoke
docker build -t ahu-ai-chatbot-internal:dev -f apps/internal-web/Dockerfile .
docker run --rm -d --name test-internal -p 33001:3000 ahu-ai-chatbot-internal:dev
sleep 3
curl -sfI http://localhost:33001/ | head -1
docker rm -f test-internal

Expected: HTTP/1.1 307 Temporary Redirect (root redirects to /login for internal).

  • [ ] Step 4: Commit
git add apps/internal-web/Dockerfile apps/internal-web/next.config.ts
git commit -m "feat(internal-web): Dockerfile with Next.js standalone build"

Task 3: Update apps/public-agent/Dockerfile for monorepo context

Files:
- Modify: apps/public-agent/Dockerfile

  • [ ] Step 1: Read current Dockerfile
cat /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/apps/public-agent/Dockerfile

It was cloned from apps/internal-agent/Dockerfile in Plan A Task 31 and expects the build context to be the app dir. We need it to work with monorepo-root context so it can find packages/agno-base.

  • [ ] Step 2: Rewrite as multi-stage with root context

Replace the entire file content with:

# syntax=docker/dockerfile:1.6
FROM python:3.12-slim AS runner

WORKDIR /app

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1

RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential libpq-dev curl \
  && rm -rf /var/lib/apt/lists/*

# uv is preferred; falls back to pip if uv-install fails
RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
  && ln -s /root/.local/bin/uv /usr/local/bin/uv

# Copy workspace root + agno-base package + agent code
COPY pyproject.toml .python-version /repo/
COPY packages/agno-base /repo/packages/agno-base
COPY apps/public-agent /repo/apps/public-agent

WORKDIR /repo/apps/public-agent

# Install into a system venv
RUN uv sync --frozen --no-dev || uv sync --no-dev

RUN adduser --disabled-password --gecos "" --uid 1001 app \
  && chown -R app:app /repo
USER app

WORKDIR /repo/apps/public-agent

EXPOSE 8000
ENV SURFACE=public
ENV KNOWLEDGE_DIR=/knowledge

CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
  • [ ] Step 3: Build + smoke
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
docker build -t ahu-ai-agent-public:dev -f apps/public-agent/Dockerfile .

Expected: build succeeds. Runtime smoke test requires DB + vLLM, deferred to Task 12.

  • [ ] Step 4: Commit
git add apps/public-agent/Dockerfile
git commit -m "feat(public-agent): Dockerfile with monorepo-root build context

Uses uv sync from workspace root so agno-base is resolved via workspace
protocol. SURFACE=public + KNOWLEDGE_DIR=/knowledge baked in as defaults;
compose override sets the volume mount to infra/knowledge/public/."

Task 4: Update apps/internal-agent/Dockerfile

Files:
- Modify: apps/internal-agent/Dockerfile

  • [ ] Step 1: Same as Task 3 Step 2 but substitute internal for public

Replace apps/internal-agent/Dockerfile with:

# syntax=docker/dockerfile:1.6
FROM python:3.12-slim AS runner

WORKDIR /app

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1

RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential libpq-dev curl \
  && rm -rf /var/lib/apt/lists/*

RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
  && ln -s /root/.local/bin/uv /usr/local/bin/uv

COPY pyproject.toml .python-version /repo/
COPY packages/agno-base /repo/packages/agno-base
COPY apps/internal-agent /repo/apps/internal-agent

WORKDIR /repo/apps/internal-agent

RUN uv sync --frozen --no-dev || uv sync --no-dev

RUN adduser --disabled-password --gecos "" --uid 1001 app \
  && chown -R app:app /repo
USER app

WORKDIR /repo/apps/internal-agent

EXPOSE 8000
ENV SURFACE=internal
ENV KNOWLEDGE_DIR=/knowledge

CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
  • [ ] Step 2: Build
docker build -t ahu-ai-agent-internal:dev -f apps/internal-agent/Dockerfile .
  • [ ] Step 3: Commit
git add apps/internal-agent/Dockerfile
git commit -m "feat(internal-agent): Dockerfile with monorepo-root build context"

Phase 2B — Compose stacks

Task 5: Write infra/compose.shared.yaml

Files:
- Create: infra/compose.shared.yaml
- Create: infra/env/shared.env.example

  • [ ] Step 1: Write shared compose
# infra/compose.shared.yaml
# Runs once. Both public + internal stacks reach this via `gateway-net`.
#
# Provides: Redis (rate limits + anon sessions + policy SQLite path never
# leaves host FS but the volume is declared here so both stacks mount it).
services:
  ahu-redis:
    image: redis:7-alpine
    container_name: ahu-redis-shared
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - redis-data:/data
    networks:
      - gateway-net
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3

volumes:
  redis-data:
    driver: local
  # SQLite shared between public-web (reader) and internal-web (writer)
  # for orchestrator policy state.
  policy-data:
    driver: local
  # SQLite for orchestrator runtime config.
  config-data:
    driver: local

networks:
  gateway-net:
    name: ahu-gateway-net
    driver: bridge
  # External: existing docker network on Server 2 where vLLM/RAG/Milvus live.
  ahu-net:
    external: true
  • [ ] Step 2: Write env example
# infra/env/shared.env.example
# Copy to shared.env and fill in values.
REDIS_URL=redis://ahu-redis:6379
  • [ ] Step 3: Commit
git add infra/compose.shared.yaml infra/env/shared.env.example
git commit -m "feat(infra/compose): shared stack (Redis + policy/config volumes + gateway-net)"

Task 6: Write infra/compose.public.yaml

Files:
- Create: infra/compose.public.yaml
- Create: infra/env/public.env.example

  • [ ] Step 1: Write public compose
# infra/compose.public.yaml
# Public stack. Attaches to gateway-net for Redis and ahu-net for vLLM/RAG.
services:
  public-web:
    image: ahu-ai-chatbot-public:${IMAGE_TAG:-latest}
    container_name: ahu-ai-chatbot-public
    restart: unless-stopped
    env_file:
      - env/shared.env
      - env/public.env
    depends_on:
      ahu-redis:
        condition: service_healthy
    ports:
      - "127.0.0.1:${PUBLIC_WEB_PORT:-3500}:3000"
    volumes:
      - policy-data:/data:ro
      - config-data:/data-cfg
    networks:
      - gateway-net
      - ahu-net
    healthcheck:
      test: ["CMD", "wget", "-q", "-O-", "http://localhost:3000/api/health"]
      interval: 30s
      timeout: 5s
      retries: 3

  public-agent:
    image: ahu-ai-agent-public:${IMAGE_TAG:-latest}
    container_name: ahu-ai-agent-public
    restart: unless-stopped
    env_file:
      - env/shared.env
      - env/public.env
    environment:
      SURFACE: public
      KNOWLEDGE_DIR: /knowledge
    volumes:
      - ../infra/knowledge/public:/knowledge:ro
    networks:
      - gateway-net
      - ahu-net
    healthcheck:
      test: ["CMD-SHELL", "python3 -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/health\", timeout=3)'"]
      interval: 30s
      timeout: 5s
      retries: 3

networks:
  gateway-net:
    external: true
    name: ahu-gateway-net
  ahu-net:
    external: true

volumes:
  policy-data:
    external: true
    name: ahu-shared_policy-data
  config-data:
    external: true
    name: ahu-shared_config-data
  • [ ] Step 2: Write env example
# infra/env/public.env.example
# Model gateway (planning — local vLLM):
MODEL_GATEWAY_URL=http://ahu-vllm:8000
LLM_PLANNING_MODEL=Qwen/Qwen3.6-35B-A3B-FP8

# Synthesis (Alibaba Cloud during transitional phase; will flip to local when B200 lands):
SYNTHESIS_GATEWAY_URL=https://ws-c1jx7b6glz0serum.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1
LLM_SYNTHESIS_MODEL=qwen3.5-397b-a17b

# Tool endpoints on ahu-net:
RAG_BASE_URL=http://ai-ahu-rag:8110
DATA_PUBLIC_BASE_URL=http://public-agent:8000
DATA_PUBLIC_AGENT_ID=data-agent

# Orchestrator state:
ORCHESTRATOR_CONFIG_DB=/data-cfg/config.sqlite
ORCHESTRATOR_POLICY_DB=/data/policy.sqlite
POLICY_DEFAULTS_SHARED_PATH=/infra/policy/policy_defaults_shared.json
  • [ ] Step 3: Commit
git add infra/compose.public.yaml infra/env/public.env.example
git commit -m "feat(infra/compose): public stack (public-web + public-agent)

Public-web binds to 127.0.0.1:3500 — nginx on Server 2 fronts it for
x056.ahu-demo.chatbot-neo.val.id. Both containers attach to gateway-net (Redis) and
ahu-net (vLLM/RAG). Policy SQLite mounted read-only from shared volume."

Task 7: Write infra/compose.internal.yaml

Files:
- Create: infra/compose.internal.yaml
- Create: infra/env/internal.env.example

  • [ ] Step 1: Write internal compose
# infra/compose.internal.yaml
# Internal stack. LAN-only, no ingress binding on 0.0.0.0.
services:
  internal-web:
    image: ahu-ai-chatbot-internal:${IMAGE_TAG:-latest}
    container_name: ahu-ai-chatbot-internal
    restart: unless-stopped
    env_file:
      - env/shared.env
      - env/internal.env
    ports:
      - "192.168.83.20:${INTERNAL_WEB_PORT:-3510}:3000"
    volumes:
      - policy-data:/data
      - config-data:/data-cfg
    networks:
      - gateway-net
      - ahu-net
    healthcheck:
      test: ["CMD", "wget", "-q", "-O-", "http://localhost:3000/"]
      interval: 30s
      timeout: 5s
      retries: 3

  internal-agent:
    image: ahu-ai-agent-internal:${IMAGE_TAG:-latest}
    container_name: ahu-ai-agent-internal
    restart: unless-stopped
    env_file:
      - env/shared.env
      - env/internal.env
    environment:
      SURFACE: internal
      KNOWLEDGE_DIR: /knowledge
    volumes:
      - ../infra/knowledge/internal:/knowledge:ro
    networks:
      - gateway-net
      - ahu-net
    healthcheck:
      test: ["CMD-SHELL", "python3 -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/health\", timeout=3)'"]
      interval: 30s
      timeout: 5s
      retries: 3

networks:
  gateway-net:
    external: true
    name: ahu-gateway-net
  ahu-net:
    external: true

volumes:
  policy-data:
    external: true
    name: ahu-shared_policy-data
  config-data:
    external: true
    name: ahu-shared_config-data
  • [ ] Step 2: Write env example
# infra/env/internal.env.example
MODEL_GATEWAY_URL=http://ahu-vllm:8000
LLM_PLANNING_MODEL=Qwen/Qwen3.6-35B-A3B-FP8
SYNTHESIS_GATEWAY_URL=https://ws-c1jx7b6glz0serum.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1
LLM_SYNTHESIS_MODEL=qwen3.5-397b-a17b

# Internal DB (full creds — for now Phase 1 also used by public per spec caveat):
INTERNAL_DB_URL=mysql://staff:CHANGEME@192.168.62.155:3306/ahu_badan_hukum

# Agent + RAG endpoints:
INTERNAL_AGENT_URL=http://internal-agent:8000
RAG_BASE_URL=http://ai-ahu-rag:8110

# Orchestrator policy state (writes here; public-web reads):
ORCHESTRATOR_POLICY_DB=/data/policy.sqlite

# NextAuth (staff auth):
NEXTAUTH_SECRET=CHANGEME
NEXTAUTH_URL=http://192.168.83.20:3510
  • [ ] Step 3: Commit
git add infra/compose.internal.yaml infra/env/internal.env.example
git commit -m "feat(infra/compose): internal stack (internal-web + internal-agent, LAN-only bind)"

Phase 2C — Deploy scripts

Task 8: Write infra/deploy/build-and-ship.sh

Files:
- Create: infra/deploy/build-and-ship.sh

  • [ ] Step 1: Write the script
#!/usr/bin/env bash
# infra/deploy/build-and-ship.sh
# Build all 4 images on Server 1 and ship them to Server 2 via SSH.
set -euo pipefail

REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$REPO_ROOT"

TAG="${IMAGE_TAG:-$(git rev-parse --short HEAD)}"
SERVER2="${SERVER2:-obert@192.168.83.20}"
REMOTE_INBOX="${REMOTE_INBOX:-/tmp/ahu-images}"

echo "===> Building images with tag: $TAG"

docker build -t "ahu-ai-chatbot-public:$TAG" -f apps/public-web/Dockerfile .
docker build -t "ahu-ai-chatbot-internal:$TAG" -f apps/internal-web/Dockerfile .
docker build -t "ahu-ai-agent-public:$TAG" -f apps/public-agent/Dockerfile .
docker build -t "ahu-ai-agent-internal:$TAG" -f apps/internal-agent/Dockerfile .

for img in ahu-ai-chatbot-public ahu-ai-chatbot-internal ahu-ai-agent-public ahu-ai-agent-internal; do
  docker tag "$img:$TAG" "$img:latest"
done

echo "===> Saving images to tarballs..."
mkdir -p /tmp/ahu-out
for img in ahu-ai-chatbot-public ahu-ai-chatbot-internal ahu-ai-agent-public ahu-ai-agent-internal; do
  docker save "$img:$TAG" "$img:latest" | gzip -1 > "/tmp/ahu-out/$img.tar.gz"
  echo "  packed /tmp/ahu-out/$img.tar.gz ($(du -h /tmp/ahu-out/$img.tar.gz | cut -f1))"
done

echo "===> Shipping to $SERVER2:$REMOTE_INBOX..."
ssh "$SERVER2" "mkdir -p $REMOTE_INBOX"
for img in ahu-ai-chatbot-public ahu-ai-chatbot-internal ahu-ai-agent-public ahu-ai-agent-internal; do
  scp "/tmp/ahu-out/$img.tar.gz" "$SERVER2:$REMOTE_INBOX/"
done

echo "===> Loading images on Server 2..."
ssh "$SERVER2" bash -s <<REMOTE_EOF
set -e
for img in ahu-ai-chatbot-public ahu-ai-chatbot-internal ahu-ai-agent-public ahu-ai-agent-internal; do
  gunzip -c "$REMOTE_INBOX/\$img.tar.gz" | docker load
done
REMOTE_EOF

echo "===> Done. Images tagged $TAG (and :latest) on Server 2."
  • [ ] Step 2: Make executable + commit
chmod +x infra/deploy/build-and-ship.sh
git add infra/deploy/build-and-ship.sh
git commit -m "feat(deploy): build-and-ship.sh — 4-image local build + scp to Server 2"

Task 9: Write infra/deploy/deploy-staging.sh

Files:
- Create: infra/deploy/deploy-staging.sh

  • [ ] Step 1: Write the script
#!/usr/bin/env bash
# infra/deploy/deploy-staging.sh
# Bring up shared + public + internal stacks on Server 2 using :latest images.
# Assumes build-and-ship.sh has just run.
set -euo pipefail

SERVER2="${SERVER2:-obert@192.168.83.20}"
REMOTE_DIR="${REMOTE_DIR:-/home/obert/ahu-ai-staging}"
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"

echo "===> rsync infra/ to Server 2..."
rsync -avz --delete \
  "$REPO_ROOT/infra/" \
  "$SERVER2:$REMOTE_DIR/infra/"

echo "===> Copying env files (must exist locally with real values)..."
for f in shared public internal; do
  test -f "$REPO_ROOT/infra/env/$f.env" || {
    echo "MISSING: infra/env/$f.env — copy from $f.env.example and fill in"
    exit 1
  }
  scp "$REPO_ROOT/infra/env/$f.env" "$SERVER2:$REMOTE_DIR/infra/env/"
done

echo "===> Bringing up shared stack..."
ssh "$SERVER2" "cd $REMOTE_DIR/infra && docker compose -p ahu-shared -f compose.shared.yaml up -d"

echo "===> Bringing up public stack..."
ssh "$SERVER2" "cd $REMOTE_DIR/infra && docker compose -p ahu-public -f compose.public.yaml up -d"

echo "===> Bringing up internal stack..."
ssh "$SERVER2" "cd $REMOTE_DIR/infra && docker compose -p ahu-internal -f compose.internal.yaml up -d"

echo "===> Waiting for health checks..."
sleep 10

echo "===> Public health:"
ssh "$SERVER2" "curl -sf http://127.0.0.1:3500/api/health" || echo "PUBLIC UNHEALTHY"

echo "===> Internal reachable on LAN:"
ssh "$SERVER2" "curl -sfI http://127.0.0.1:3510/ | head -1" || echo "INTERNAL UNHEALTHY"

echo "===> Done. Staging URLs:"
echo "  Public:   http://192.168.83.20:3500  (or via nginx: chatbot-neo-next.val.id)"
echo "  Internal: http://192.168.83.20:3510"
  • [ ] Step 2: Make executable + commit
chmod +x infra/deploy/deploy-staging.sh
git add infra/deploy/deploy-staging.sh
git commit -m "feat(deploy): deploy-staging.sh — bring up all 3 stacks on Server 2"

Task 10: Write infra/deploy/rollback.sh

Files:
- Create: infra/deploy/rollback.sh

  • [ ] Step 1: Write the script
#!/usr/bin/env bash
# infra/deploy/rollback.sh
# Emergency rollback: stop the new stacks on Server 2 and revert nginx on
# Server 1 to point x056.ahu-demo.chatbot-neo.val.id at the legacy container.
set -euo pipefail

SERVER1="${SERVER1:-localhost}"  # nginx lives on Server 1
SERVER2="${SERVER2:-obert@192.168.83.20}"
REMOTE_DIR="${REMOTE_DIR:-/home/obert/ahu-ai-staging}"

echo "===> Stopping public stack on Server 2..."
ssh "$SERVER2" "cd $REMOTE_DIR/infra && docker compose -p ahu-public -f compose.public.yaml down"

echo "===> Reverting nginx to legacy ahu-chatbot-dev container..."
if [ "$SERVER1" = "localhost" ]; then
  sudo cp /etc/nginx/sites-available/chatbot-neo.conf.backup /etc/nginx/sites-available/chatbot-neo.conf
  sudo nginx -t && sudo systemctl reload nginx
else
  ssh "$SERVER1" "sudo cp /etc/nginx/sites-available/chatbot-neo.conf.backup /etc/nginx/sites-available/chatbot-neo.conf && sudo nginx -t && sudo systemctl reload nginx"
fi

echo "===> Rollback complete. x056.ahu-demo.chatbot-neo.val.id → legacy ahu-chatbot-dev:8120"
echo "===> Verify: curl -sfI https://x056.ahu-demo.chatbot-neo.val.id/ | head -1"
  • [ ] Step 2: Commit
chmod +x infra/deploy/rollback.sh
git add infra/deploy/rollback.sh
git commit -m "feat(deploy): rollback.sh — one-shot revert to legacy container + nginx"

Phase 2D — Build + smoke on Server 2

Task 11: Prepare env files with real values

Files:
- Create: infra/env/shared.env (NOT committed — contains secrets)
- Create: infra/env/public.env (NOT committed)
- Create: infra/env/internal.env (NOT committed)

  • [ ] Step 1: Verify env/ is gitignored
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
grep -q "^infra/env/\*.env" .gitignore || echo "infra/env/*.env" >> .gitignore
grep -q "!infra/env/\*.env.example" .gitignore || echo "!infra/env/*.env.example" >> .gitignore
git add .gitignore
git commit -m "chore(gitignore): exclude infra/env/*.env (keep *.env.example)"
  • [ ] Step 2: Copy templates and fill in
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/infra/env
cp shared.env.example shared.env
cp public.env.example public.env
cp internal.env.example internal.env

Then edit each file:
- shared.env: no changes (Redis URL is already correct default)
- public.env: fill in SYNTHESIS_GATEWAY_URL with the real Alibaba Cloud endpoint; leave RAG_BASE_URL and DATA_PUBLIC_BASE_URL as-is (they resolve via docker DNS)
- internal.env: fill in INTERNAL_DB_URL with real credentials + set NEXTAUTH_SECRET (openssl rand -base64 32)

  • [ ] Step 3: Verify env files are not tracked
git status infra/env/ | grep -E "\.env$" && echo "STILL TRACKED — abort" || echo "OK, not tracked"

Expected: prints OK, not tracked.


Task 12: Build + ship images

Files: none (script-driven)

  • [ ] Step 1: Run the build-and-ship script
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
./infra/deploy/build-and-ship.sh

Expected output ends with:

===> Done. Images tagged <sha> (and :latest) on Server 2.
  • [ ] Step 2: Verify images on Server 2
ssh obert@192.168.83.20 "docker image ls | grep ahu-ai-"

Expected: 4 lines, one per image with recent CREATED timestamp.


Task 13: Bring up staging stacks

Files: none

  • [ ] Step 1: Ensure ahu-net docker network exists on Server 2
ssh obert@192.168.83.20 "docker network ls | grep -q ahu-net && echo EXISTS || docker network create ahu-net"

Expected: prints EXISTS (it should already be there — vLLM lives on it).

  • [ ] Step 2: Run deploy-staging
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
./infra/deploy/deploy-staging.sh

Expected: script ends with public+internal health lines both printing OK.

  • [ ] Step 3: Manually check container health
ssh obert@192.168.83.20 "docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | grep ahu-ai-"

Expected: all 4 containers showing Up X seconds (healthy).

  • [ ] Step 4: Manual smoke queries
# Public /api/health
ssh obert@192.168.83.20 "curl -sf http://127.0.0.1:3500/api/health"
# Expected: {"ok":true,"surface":"public","timestamp":"..."}

# Public /api/orchestrate — real query (limit to a smoke question)
ssh obert@192.168.83.20 "curl -N -H 'Content-Type: application/json' -d '{\"surface\":\"public\",\"history\":[],\"message\":\"Apa itu perseroan terbatas?\",\"sessionId\":\"smoke-1\"}' http://127.0.0.1:3500/api/orchestrate | head -20"
# Expected: SSE stream — event: status, event: content, event: references, event: done

If the SSE stream shows real Indonesian answer text and hits event: done, staging is working.


Task 14: Run parity smoke against staging (fulfills Plan A Task 35 deferral)

Files: none (uses existing eval scripts under apps/*/dash/evals/)

  • [ ] Step 1: Point eval scripts at staging endpoints
ssh obert@192.168.83.20 "cd /home/obert/ahu-ai-staging/infra && docker compose -p ahu-public -f compose.public.yaml exec public-agent uv run python -m dash.evals.run_model_comparison --limit 5" | tee /tmp/parity-public-staging.log

Expected: 5 test cases run, each printing a Q + A pair. No HTTP 500 errors from vLLM or SQL.

  • [ ] Step 2: Same against internal
ssh obert@192.168.83.20 "cd /home/obert/ahu-ai-staging/infra && docker compose -p ahu-internal -f compose.internal.yaml exec internal-agent uv run python -m dash.evals.run_model_comparison --limit 5" | tee /tmp/parity-internal-staging.log

Expected: 5 test cases with staff-scope answers (possibly touching row-level data).

  • [ ] Step 3: Save parity snapshot
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
mkdir -p docs/superpowers/parity-snapshots/2026-07-01-staging
cp /tmp/parity-public-staging.log docs/superpowers/parity-snapshots/2026-07-01-staging/
cp /tmp/parity-internal-staging.log docs/superpowers/parity-snapshots/2026-07-01-staging/
git add docs/superpowers/parity-snapshots/2026-07-01-staging/
git commit -m "docs(parity): staging parity smoke snapshot 2026-07-01"

Phase 3A — Pre-cutover coordination

Task 15: Lower DNS TTL 24h before cutover

Files: none (out-of-band)

  • [ ] Step 1: Identify DNS zone owner

The x056.ahu-demo.chatbot-neo.val.id A record is managed by whoever owns val.id. Confirm current TTL.

dig +noall +answer x056.ahu-demo.chatbot-neo.val.id | head -1

Expected output includes the TTL as the second field. If TTL > 300, request lowering.

  • [ ] Step 2: Request TTL drop to 300 (5 min) via zone admin

Send a message to the DNS admin (whoever owns val.id) requesting TTL drop to 300 seconds. Wait 24h + old-TTL for the change to propagate.

Notes: this task is not automatable and blocks Task 19. Continue with Task 16-18 (they're independent) while waiting.

  • [ ] Step 3: Verify propagation
for r in 8.8.8.8 1.1.1.1 9.9.9.9; do
  dig +noall +answer @$r x056.ahu-demo.chatbot-neo.val.id | head -1
done

Expected: all three resolvers show TTL ≤ 300.


Phase 3B — Cutover

Task 16: Write new nginx site block

Files:
- Create: infra/nginx/chatbot-neo.conf.new

  • [ ] Step 1: Read current nginx config

The current /etc/nginx/sites-available/chatbot-neo.conf on Server 1 proxies to ahu-chatbot-dev:8120. Read it to preserve TLS + logging directives.

  • [ ] Step 2: Write the new block
# infra/nginx/chatbot-neo.conf.new
# Deploy to /etc/nginx/sites-available/chatbot-neo.conf on Server 1.
server {
    listen 443 ssl http2;
    server_name x056.ahu-demo.chatbot-neo.val.id;

    # <PRESERVE TLS cert paths from the existing config>
    # ssl_certificate ...
    # ssl_certificate_key ...

    access_log /var/log/nginx/chatbot-neo.access.log;
    error_log  /var/log/nginx/chatbot-neo.error.log;

    # SSE requires disabling buffering + long timeouts.
    proxy_http_version 1.1;
    proxy_buffering off;
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;

    # Server 2 public-web binds 127.0.0.1:3500; nginx reaches it via
    # host-to-host over the LAN.
    location / {
        proxy_pass http://192.168.83.20:3500;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header Connection "";
    }
}

server {
    listen 80;
    server_name x056.ahu-demo.chatbot-neo.val.id;
    return 301 https://$server_name$request_uri;
}

Replace <PRESERVE TLS cert paths from the existing config> with the actual cert paths from the existing nginx site config. Do NOT commit real cert paths if they're sensitive; if they are, use env-var substitution during deploy.

  • [ ] Step 3: Commit
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
git add infra/nginx/chatbot-neo.conf.new
git commit -m "feat(infra/nginx): new site block for x056.ahu-demo.chatbot-neo.val.id → 192.168.83.20:3500"

Task 17: Physical rename ai-ahu-chatbotahu-ai-chatbot

Files: none (filesystem operation)

  • [ ] Step 1: Announce maintenance window

Notify staff users that a brief maintenance window is starting. Send message via Slack/team channel.

  • [ ] Step 2: Stop the legacy container
cd /home/efran/remote-development/poc-ahu-ai/ai-ahu-chatbot
docker compose -f compose.dev.yaml stop ahu-chatbot
  • [ ] Step 3: Remove the symlink + physical rename
cd /home/efran/remote-development/poc-ahu-ai
rm ahu-ai-chatbot  # remove symlink
mv ai-ahu-chatbot ahu-ai-chatbot
  • [ ] Step 4: Verify git still works after rename
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
git status
git log --oneline -3

Expected: clean status, recent commits visible.

  • [ ] Step 5: Update .claude/settings.json project-path references

If any Claude Code session references the old path, update them. For future sessions the new path will be canonical.

  • [ ] Step 6: Empty rationale commit
git commit --allow-empty -m "chore: physical rename ai-ahu-chatbot → ahu-ai-chatbot complete (symlink removed)"

Task 18: Backup existing nginx config

Files: none (backup operation)

  • [ ] Step 1: Backup current nginx site block on Server 1
sudo cp /etc/nginx/sites-available/chatbot-neo.conf /etc/nginx/sites-available/chatbot-neo.conf.backup
sudo ls -la /etc/nginx/sites-available/chatbot-neo.conf.backup

Expected: file exists with recent timestamp.

  • [ ] Step 2: Verify staging is still healthy right before flip
ssh obert@192.168.83.20 "curl -sf http://127.0.0.1:3500/api/health"
ssh obert@192.168.83.20 "curl -sfI http://127.0.0.1:3510/"

Both should return healthy responses. If not, halt cutover.


Task 19: DNS/reverse-proxy flip

Files: none (deploy operation)

  • [ ] Step 1: Copy new nginx config into place
sudo cp /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/infra/nginx/chatbot-neo.conf.new /etc/nginx/sites-available/chatbot-neo.conf
sudo nginx -t

Expected: nginx: configuration file /etc/nginx/nginx.conf test is successful.

  • [ ] Step 2: Reload nginx
sudo systemctl reload nginx

Expected: no error output.

  • [ ] Step 3: Smoke prod URL
curl -sf https://x056.ahu-demo.chatbot-neo.val.id/api/health

Expected: {"ok":true,"surface":"public","timestamp":"..."}.

  • [ ] Step 4: Real query through prod
curl -N -H 'Content-Type: application/json' -d '{"surface":"public","history":[],"message":"Apa itu perseroan terbatas?","sessionId":"prod-smoke-1"}' https://x056.ahu-demo.chatbot-neo.val.id/api/orchestrate | head -20

Expected: SSE stream terminating with event: done.

  • [ ] Step 5: Announce cutover complete

Send Slack/team message: "x056.ahu-demo.chatbot-neo.val.id cutover complete. New split public/internal stack live on Server 2. Rollback script available at infra/deploy/rollback.sh if issues arise."


Task 20: Post-cutover monitoring window

Files: none

  • [ ] Step 1: Watch logs for the next 30 minutes
ssh obert@192.168.83.20 "docker logs -f ahu-ai-chatbot-public 2>&1 | tee /tmp/post-cutover-public.log"

Watch for 5xx errors, orchestrator failures, unusually long response times.

  • [ ] Step 2: Sample a handful of real user queries

Test the following via the actual https://x056.ahu-demo.chatbot-neo.val.id/tanya UI:
- "Apa itu PT?"
- "Berapa PT terdaftar tahun 2025?"
- "Bagaimana cara mendirikan yayasan?"
- "Siapa direktur Mandiri?" (should refuse — L1 person_lookup)
- "Cari NIK 1234567812345678" (should refuse — L1 nik pattern)

Expected: first 3 return real Indonesian answers with references; last 2 return the Indonesian refusal message.

  • [ ] Step 3: If any issue, execute rollback
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
./infra/deploy/rollback.sh

Then diagnose in staging before re-attempting.


Task 21: Retire legacy container

Files: none

  • [ ] Step 1: Confirm 24h of stable prod traffic on new stack

Review the last 24h of ahu-ai-chatbot-public logs for any error spikes. If none, proceed.

  • [ ] Step 2: Remove the legacy ahu-chatbot-dev container
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
docker compose -f compose.dev.yaml down ahu-chatbot
docker image rm ahu-chatbot:dev 2>/dev/null || true
  • [ ] Step 3: Retire the legacy compose file
git mv compose.dev.yaml infra/legacy-compose.dev.yaml
git commit -m "chore: retire legacy single-container compose.dev.yaml (moved to infra/legacy-)"

Task 22: Archive sibling repos + completion notes

Files:
- Create: docs/superpowers/plans/2026-07-01-monorepo-split-plan-b-completion-notes.md

  • [ ] Step 1: Archive sibling repos

Add a top-level README in each sibling repo noting archival status, then push a final commit + tag:

cd /home/efran/remote-development/poc-ahu-ai/ai-ahu-data-dash
git checkout main
cat > README.ARCHIVED.md <<'EOF'
# ai-ahu-data-dash — ARCHIVED 2026-07-01

This repo was absorbed into the `ahu-ai-chatbot` monorepo at
`apps/{public,internal}-agent/`. Do not add new commits here.

Latest history preserved via `git subtree` in the monorepo.
EOF
git add README.ARCHIVED.md
git commit -m "chore: archive — absorbed into ahu-ai-chatbot monorepo"
git tag "archive/2026-07-01"

cd /home/efran/remote-development/poc-ahu-ai/ahu-chatbot-orchestrator
cat > README.ARCHIVED.md <<'EOF'
# ahu-chatbot-orchestrator — ARCHIVED 2026-07-01

This repo was translated to TypeScript and absorbed into the
`ahu-ai-chatbot` monorepo at `apps/public-web/src/lib/orchestrator/`.
Do not add new commits here.
EOF
git add README.ARCHIVED.md
git commit -m "chore: archive — translated to TS in ahu-ai-chatbot monorepo"
git tag "archive/2026-07-01"
  • [ ] Step 2: Write completion notes
# Plan B completion notes (2026-07-01)

## State at end of Plan B

- Docker images `ahu-ai-chatbot-{public,internal}` + `ahu-ai-agent-{public,internal}` built with `:latest` tag on Server 2.
- Three compose stacks live on Server 2:
  - `infra/compose.shared.yaml` — Redis + shared volumes
  - `infra/compose.public.yaml` — public-web (127.0.0.1:3500) + public-agent
  - `infra/compose.internal.yaml` — internal-web (192.168.83.20:3510) + internal-agent
- `x056.ahu-demo.chatbot-neo.val.id` routes through nginx on Server 1 → `192.168.83.20:3500`.
- Sibling repos `ai-ahu-data-dash` + `ahu-chatbot-orchestrator` archived with tag `archive/2026-07-01`.
- Physical rename `ai-ahu-chatbot``ahu-ai-chatbot` complete.
- Legacy `ahu-chatbot-dev` container retired.
- `compose.dev.yaml` moved to `infra/legacy-compose.dev.yaml`.
- Rollback script `infra/deploy/rollback.sh` verified working (dry-run in staging).

## Deferrals from Plan B

- **Public DB isolation** — Phase 1 caveat from spec still holds; public-agent reads internal DB with full creds. `PUBLIC_DB_URL` env slot reserved; flip is config-only when a public DB lands.
- **Model gateway service**`MODEL_GATEWAY_URL` still points at direct vLLM. When the shared gateway service lands (separate repo), flip the env var.

## What Plan C picks up

- Admin scope-switcher UI (Public/Internal/Shared/Observasi nav) + audit log
- SSE resume primitive (Last-Event-ID + Redis checkpoint)
- Standard gateway request headers surfaced in admin
- BullMQ workers for nightly eval + knowledge re-ingestion
- SQL-guard TS port (Plan A deferred item #7)
- Dual-model streaming refactor (spec hot-spot #1)
  • [ ] Step 3: Commit
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
git add docs/superpowers/plans/2026-07-01-monorepo-split-plan-b-completion-notes.md
git commit -m "docs(plan-b): completion notes + handoff to Plan C"

End-of-Plan-B success criteria

Run these once Task 22 is committed:

# From anywhere with LAN access to Server 2:
curl -sf https://x056.ahu-demo.chatbot-neo.val.id/api/health
curl -sf http://192.168.83.20:3510/  # returns HTML for /login
ssh obert@192.168.83.20 "docker ps --filter name=ahu-ai- --format 'table {{.Names}}\t{{.Status}}'"

All of the following must be true:

  1. curl https://x056.ahu-demo.chatbot-neo.val.id/api/health returns {"ok":true,"surface":"public",...}
  2. All 4 ahu-ai-* containers on Server 2 show Up (healthy)
  3. infra/compose.dev.yaml no longer exists at repo root (moved to infra/legacy-)
  4. Legacy ahu-chatbot-dev container is not running (docker ps on Server 1 has no such line)
  5. Sibling repos ai-ahu-data-dash + ahu-chatbot-orchestrator carry the tag archive/2026-07-01
  6. ls /home/efran/remote-development/poc-ahu-ai/ shows ahu-ai-chatbot (not ai-ahu-chatbot)
  7. Rollback script tested at least once (either in staging or full dry-run)

Plan B is done when all 7 hold.