think
16px
820px

Monorepo Public/Internal Split — Plan C: Polish + Full Backlog

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: Clear the entire post-cutover backlog from Plans A+B — get the app from "infrastructure ships, RAG empty" to "user-visible legal answers with polished admin, resilient streaming, secure SQL guard, and clean dev ergonomics."

Architecture: Eight independent-ish phases (A-I skipping D-as-standalone). Phase A (RAG load) is the only phase whose result end users notice; run it first so the app becomes useful before the polish work lands. Phases B-H iterate on infra polish + admin UX + defense-in-depth. Phase I is the safe-window physical rename.

Tech Stack: Next.js 15 + React 19 + Tailwind 4 (admin UI), TypeScript (orchestrator), Python (dash agents + RAG), BullMQ + ioredis (workers), Milvus + Qwen3-Embedding-4B (RAG), node-sql-parser (SQL AST).

Prior plans:
- 2026-06-30-monorepo-split-plan-a-skeleton-and-refactor.md (Phase 0+1 — code split + TS orchestrator)
- 2026-07-01-monorepo-split-plan-b-deploy-and-cutover.md (Phase 2+3 — deploy + cutover)

Spec: docs/superpowers/specs/2026-06-30-monorepo-public-internal-split-design.md


Where this plan runs

  • Phase A (RAG load) runs on Server 2 (192.168.83.20) where Milvus + embedding model + vLLM live. Ingestion job runs inside ai-ahu-rag container.
  • Phases B, C, D, F, G, H run on Server 1 = 103.30.246.154 (the edge host you're on). Source-of-truth for the monorepo.
  • Phase E (BullMQ workers) runs on Server 2 alongside the app containers.
  • Phase I (rename) is a filesystem operation on Server 1 with a coordination window.

Phase A — RAG corpus load (unblocks real user-visible answers)

Task A1: Inventory available AHU legal docs

Files: none (audit task)

  • [x] Step 1: Enumerate candidate source directories

Check the three most likely sources on Server 1:

find /home/efran -maxdepth 3 -type d -iname "*legal*" -o -iname "*hukum*" -o -iname "*akta*" 2>/dev/null | head -20
find /home/efran -maxdepth 3 -name "*.pdf" -o -name "*.txt" 2>/dev/null | wc -l
ls /home/efran/remote-development/poc-ahu-ai/ 2>/dev/null | grep -i "doc\|legal\|hukum\|ocr"

Expected: identifies at least one directory with AHU legal PDFs or text.

  • [x] Step 2: Record inventory in docs

Create docs/superpowers/rag-corpus-inventory.md listing:
- Path
- Approximate document count
- Format (PDF / text / DOCX)
- Coverage areas (PT, perseroan, fidusia, notaris, etc.)
- Any known preprocessing already done (OCR? plain text extract?)

  • [x] Step 3: Commit
git add docs/superpowers/rag-corpus-inventory.md
git commit -m "docs(rag): inventory candidate AHU legal doc sources for corpus load"

Task A2: Verify Milvus + embedding + RAG service state

Files: none (health-check task)

  • [x] Step 1: Check Milvus reachability from within ahu-net
ssh obert@192.168.83.20 "docker exec ai-ahu-rag curl -s http://milvus:19530/v1/vector/collections 2>&1 | head -5"

Expected: JSON list of collections (may be empty), no connection error.

  • [x] Step 2: Check embedding model status
ssh obert@192.168.83.20 "curl -sf http://192.168.83.20:8110/health 2>&1 | head -3"

Expected: some form of ok response from ai-ahu-rag.

  • [x] Step 3: Test embedding endpoint with a single sentence
ssh obert@192.168.83.20 "docker exec ai-ahu-rag python -c 'from rag.embed import embed_texts; v = embed_texts([\"apa itu perseroan\"]); print(len(v[0]))'"

Expected: prints an embedding dimension (typically 3584 for Qwen3-Embedding-4B).

  • [x] Step 4: If any of Step 1-3 fail, document + escalate

Create docs/superpowers/rag-infra-blockers.md describing what's broken and skip to Task A3 only after resolution.


Task A3: Ingest inventoried docs into Milvus

Files:
- Reference: ai-ahu-rag's existing ingestion script (per its own repo)

  • [x] Step 1: SSH into ai-ahu-rag container + list existing ingest tooling
ssh obert@192.168.83.20 "docker exec ai-ahu-rag ls /app/rag/scripts/ 2>&1 | head"

Expected: shows one or more .py files (likely ingest.py, load_docs.py, or similar).

  • [x] Step 2: Copy source PDFs into ai-ahu-rag's mount

Identify where ai-ahu-rag reads docs from (check its compose volumes). Copy the inventoried source dir into that mount from Server 1 via SSH.

# Example — adjust paths based on Task A1 inventory + ai-ahu-rag's actual mount:
rsync -avz /home/efran/remote-development/poc-ahu-ai/<INVENTORY_DIR>/ obert@192.168.83.20:/home/obert/ahu-rag-corpus/source-2026-07/
  • [x] Step 3: Run the ingestion job
ssh obert@192.168.83.20 "docker exec ai-ahu-rag python -m rag.scripts.ingest --source /data/source-2026-07 --collection ahu_legal_v1 2>&1 | tail -30"

Expected: log lines showing docs chunked + embedded + inserted. Final line reports counts.

  • [x] Step 4: Verify collection is populated
ssh obert@192.168.83.20 "docker exec ai-ahu-rag python -c 'from rag.milvus import stats; print(stats(\"ahu_legal_v1\"))'"

Expected: shows row count > 0.

  • [x] Step 5: Commit ingest run notes
mkdir -p docs/superpowers/parity-snapshots/2026-07-01-rag-load
echo "ran at $(date -Iseconds)" > docs/superpowers/parity-snapshots/2026-07-01-rag-load/ingest-run.txt
ssh obert@192.168.83.20 "docker exec ai-ahu-rag python -c 'from rag.milvus import stats; print(stats(\"ahu_legal_v1\"))'" >> docs/superpowers/parity-snapshots/2026-07-01-rag-load/ingest-run.txt
git add docs/superpowers/parity-snapshots/2026-07-01-rag-load/
git commit -m "docs(rag): ingest run 2026-07-01 — <N> docs loaded into ahu_legal_v1"

Task A4: Re-run prod parity smoke — verify RAG returns non-empty

Files:
- Update: docs/superpowers/parity-snapshots/2026-07-01-rag-load/live-prod-smoke-with-rag.txt

  • [x] Step 1: Re-run the 5-question smoke against live prod
for i in 1 2 3 4 5; do
  case $i in
    1) Q="Apa itu perseroan terbatas?" ;;
    2) Q="Berapa jumlah PT terdaftar tahun 2025?" ;;
    3) Q="Bagaimana prosedur mendirikan yayasan?" ;;
    4) Q="Siapa direktur Bank Mandiri?" ;;
    5) Q="Cari NIK 1234567812345678" ;;
  esac
  echo "=== Q$i: $Q ==="
  curl -s -H 'Content-Type: application/json' \
    -d "{\"surface\":\"public\",\"history\":[],\"message\":\"$Q\",\"sessionId\":\"rag-smoke-$i\"}" \
    https://x056.ahu-demo.chatbot-neo.val.id/api/orchestrate --max-time 120 \
    | grep -E '"delta"|"message"' | head -12
  echo
done | tee docs/superpowers/parity-snapshots/2026-07-01-rag-load/live-prod-smoke-with-rag.txt

Expected: Q1-3 now stream real Indonesian legal answers (definisi, prosedur, jumlah), NOT "berdasarkan data yang tersedia, tidak ada informasi". Q4-5 still refuse via L1 guard.

  • [x] Step 2: Commit
git add docs/superpowers/parity-snapshots/2026-07-01-rag-load/live-prod-smoke-with-rag.txt
git commit -m "docs(rag): live-prod smoke shows real answers after corpus load"

Phase B — Admin scope-switcher UI + audit log

Task B1: Scope-switcher primitives + tokens

Files:
- Create: apps/internal-web/src/components/admin/scope/ScopePill.tsx
- Create: apps/internal-web/src/components/admin/scope/ScopeBadge.tsx
- Modify: apps/internal-web/src/app/globals.css (add .scope-public, .scope-internal, .scope-shared CSS custom-property blocks)

  • [x] Step 1: Add scope tokens to globals.css

Append the following to apps/internal-web/src/app/globals.css:

/* Admin scope tokens (Plan C Phase B) — cool blue = public, warm amber
   = internal, neutral = shared. Text/icon carry the semantic meaning too;
   color is decoration only. */
.scope-public {
  --scope-color: oklch(0.63 0.14 240);
  --scope-bg: oklch(0.96 0.02 240);
  --scope-border: oklch(0.85 0.08 240);
}
.scope-internal {
  --scope-color: oklch(0.55 0.15 60);
  --scope-bg: oklch(0.97 0.03 60);
  --scope-border: oklch(0.85 0.10 60);
}
.scope-shared {
  --scope-color: oklch(0.45 0.02 260);
  --scope-bg: oklch(0.96 0.005 260);
  --scope-border: oklch(0.85 0.02 260);
}
  • [x] Step 2: Write ScopeBadge.tsx
"use client";
type Scope = "public" | "internal" | "shared";

const SCOPE_META: Record<Scope, { label: string; icon: string }> = {
  public: { label: "Publik", icon: "🌐" },
  internal: { label: "Internal", icon: "🔒" },
  shared: { label: "Bersama", icon: "⚙️" },
};

export function ScopeBadge({ scope }: { scope: Scope }): React.ReactElement {
  const meta = SCOPE_META[scope];
  return (
    <span
      className={`scope-${scope} inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium`}
      style={{
        color: "var(--scope-color)",
        background: "var(--scope-bg)",
        border: "1px solid var(--scope-border)",
      }}
      aria-label={`Lingkup ${meta.label}`}
    >
      <span aria-hidden>{meta.icon}</span>
      <span>{meta.label}</span>
    </span>
  );
}
  • [x] Step 3: Write ScopePill.tsx (used in page headers)
"use client";
import Link from "next/link";
import { ScopeBadge } from "./ScopeBadge";

type Scope = "public" | "internal" | "shared";

const SCOPE_ROOT: Record<Scope, string> = {
  public: "/admin/public",
  internal: "/admin/internal",
  shared: "/admin/shared",
};

export function ScopePill({ scope }: { scope: Scope }): React.ReactElement {
  return (
    <div className="ml-auto flex items-center gap-2">
      <ScopeBadge scope={scope} />
      <div className="flex text-xs opacity-70">
        {(["public", "internal", "shared"] as Scope[]).map((s) => (
          <Link
            key={s}
            href={SCOPE_ROOT[s]}
            className={`px-2 py-1 rounded ${s === scope ? "font-semibold underline" : "hover:opacity-100"}`}
            aria-current={s === scope ? "page" : undefined}
          >
            {s === "public" ? "Publik" : s === "internal" ? "Internal" : "Bersama"}
          </Link>
        ))}
      </div>
    </div>
  );
}
  • [x] Step 4: Commit
git add apps/internal-web/src/components/admin/scope/ apps/internal-web/src/app/globals.css
git commit -m "feat(admin/scope): ScopePill + ScopeBadge primitives + tokens"

Task B2: Route split — /admin/{public,internal,shared,observe}/

Files:
- Create: apps/internal-web/src/app/(staff)/admin/public/layout.tsx
- Create: apps/internal-web/src/app/(staff)/admin/internal/layout.tsx
- Create: apps/internal-web/src/app/(staff)/admin/shared/layout.tsx
- Create: apps/internal-web/src/app/(staff)/admin/observe/layout.tsx
- Move: existing admin routes into their appropriate scope

  • [x] Step 1: Write scope layout files (identical shape, differing scope prop)

For each scope in public, internal, shared, observe:

// apps/internal-web/src/app/(staff)/admin/<SCOPE>/layout.tsx
import { ScopePill } from "@/components/admin/scope/ScopePill";
import { AdminNav } from "@/components/admin/AdminNav";

export default function AdminScopeLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="admin-shell scope-<SCOPE>">
      <header className="flex items-center gap-3 border-b px-4 py-2">
        <AdminNav scope="<SCOPE>" />
        <ScopePill scope="<SCOPE>" />
      </header>
      <main className="px-4 py-3">{children}</main>
    </div>
  );
}

Replace <SCOPE> with each of public, internal, shared, observe. Note: observe uses scope="shared" for its ScopePill (observation spans scopes).

  • [x] Step 2: Move existing admin pages into scoped routes

Per spec §Admin UI/UX:
- admin/knowledge/*admin/internal/knowledge/* (existing staff knowledge management)
- admin/public-policy/*admin/public/policy/*
- admin/settings/{ModelProvidersSection,SynthesisModelSection,ActivitySection}admin/shared/model-gateway/*
- admin/settings/{DataDashProviderSection}admin/shared/provider/*
- admin/settings/{AnonRateLimitSection}admin/public/policy/rate-limit/*
- admin/dashboard/*admin/observe/dashboard/*
- admin/learnings/*admin/observe/learnings/*

cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/apps/internal-web/src/app/(staff)/admin
git mv knowledge internal/knowledge
git mv public-policy public/policy
mkdir -p shared/{model-gateway,provider} observe
git mv learnings observe/learnings
git mv dashboard observe/dashboard
  • [x] Step 3: Add redirect layer for old URLs

apps/internal-web/src/app/(staff)/admin/page.tsx (root of /admin) — redirect to /admin/internal (the default landing for existing staff who bookmarked /admin).

import { redirect } from "next/navigation";
export default function AdminRoot(): never {
  redirect("/admin/internal");
}

Add legacy compat: apps/internal-web/src/middleware.ts — redirect /admin/{knowledge,public-policy,dashboard,learnings} prefixes to their new scoped paths.

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

const LEGACY_MAP: Record<string, string> = {
  "/admin/knowledge": "/admin/internal/knowledge",
  "/admin/public-policy": "/admin/public/policy",
  "/admin/dashboard": "/admin/observe/dashboard",
  "/admin/learnings": "/admin/observe/learnings",
};

export function middleware(req: NextRequest) {
  const p = req.nextUrl.pathname;
  for (const [old, next] of Object.entries(LEGACY_MAP)) {
    if (p === old || p.startsWith(old + "/")) {
      const target = p.replace(old, next);
      return NextResponse.redirect(new URL(target, req.url));
    }
  }
  return NextResponse.next();
}

export const config = { matcher: ["/admin/:path*"] };
  • [x] Step 4: Verify build
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
pnpm --filter @ahu/internal-web build 2>&1 | tail -3

Expected: build succeeds. Any residual TS errors from moved imports need import-path fixups first.

  • [x] Step 5: Commit
git add apps/internal-web/src/
git commit -m "refactor(admin): move admin routes under /admin/{public,internal,shared,observe}/

Adds legacy redirect middleware so bookmarked /admin/knowledge etc. still
land on the right new URL. Root /admin redirects to /admin/internal
(default staff landing)."

Task B3: AdminNav (side nav per scope)

Files:
- Create: apps/internal-web/src/components/admin/AdminNav.tsx

  • [x] Step 1: Write nav component
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";

type Scope = "public" | "internal" | "shared" | "observe";

const NAV: Record<Scope, { href: string; label: string }[]> = {
  public: [
    { href: "/admin/public/policy", label: "Kebijakan Publik" },
    { href: "/admin/public/rag", label: "Korpus RAG" },
    { href: "/admin/public/persona", label: "Prompt & Persona" },
  ],
  internal: [
    { href: "/admin/internal/knowledge", label: "Basis Pengetahuan" },
    { href: "/admin/internal/persona", label: "Prompt & Persona" },
    { href: "/admin/internal/schema", label: "Skema DB" },
  ],
  shared: [
    { href: "/admin/shared/model-gateway", label: "Model Gateway" },
    { href: "/admin/shared/provider", label: "Provider Config" },
    { href: "/admin/shared/jobs", label: "Background Jobs" },
  ],
  observe: [
    { href: "/admin/observe/dashboard", label: "Dashboard" },
    { href: "/admin/observe/threads", label: "Threads & Sessions" },
    { href: "/admin/observe/learnings", label: "Learnings" },
    { href: "/admin/observe/audit", label: "Audit Log" },
  ],
};

export function AdminNav({ scope }: { scope: Scope }): React.ReactElement {
  const p = usePathname();
  return (
    <nav aria-label={`Konsol Admin — ${scope}`} className="flex gap-1">
      {NAV[scope].map((item) => (
        <Link
          key={item.href}
          href={item.href}
          className={`px-2 py-1 text-sm rounded hover:bg-black/5 ${
            p?.startsWith(item.href) ? "font-semibold" : ""
          }`}
        >
          {item.label}
        </Link>
      ))}
    </nav>
  );
}
  • [x] Step 2: Commit
git add apps/internal-web/src/components/admin/AdminNav.tsx
git commit -m "feat(admin/scope): AdminNav side navigation per scope"

Task B4: Audit-log storage + write hook

Files:
- Create: apps/internal-web/src/lib/audit/store.ts (SQLite audit table)
- Create: apps/internal-web/src/lib/audit/log.ts (write helper)
- Create: apps/internal-web/src/app/(staff)/admin/observe/audit/page.tsx

  • [x] Step 1: Write audit SQLite store
// apps/internal-web/src/lib/audit/store.ts
import Database from "better-sqlite3";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";

export interface AuditRow {
  id: number;
  ts: string;
  who: string;
  scope: "public" | "internal" | "shared" | null;
  action: string;
  target: string;
  before: string | null;
  after: string | null;
}

export class AuditStore {
  private db: Database.Database;
  constructor(dbPath: string) {
    mkdirSync(dirname(dbPath), { recursive: true });
    this.db = new Database(dbPath);
    this.db.exec(`
      CREATE TABLE IF NOT EXISTS audit (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        ts TEXT NOT NULL,
        who TEXT NOT NULL,
        scope TEXT,
        action TEXT NOT NULL,
        target TEXT NOT NULL,
        before TEXT,
        after TEXT
      );
      CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit(ts DESC);
    `);
  }
  insert(row: Omit<AuditRow, "id" | "ts"> & { ts?: string }): AuditRow {
    const ts = row.ts ?? new Date().toISOString();
    const info = this.db
      .prepare(
        "INSERT INTO audit (ts,who,scope,action,target,before,after) VALUES (?,?,?,?,?,?,?)",
      )
      .run(ts, row.who, row.scope, row.action, row.target, row.before, row.after);
    return { id: Number(info.lastInsertRowid), ts, ...row } as AuditRow;
  }
  recent(limit: number = 100): AuditRow[] {
    return this.db
      .prepare("SELECT * FROM audit ORDER BY ts DESC LIMIT ?")
      .all(limit) as AuditRow[];
  }
}
  • [x] Step 2: Write log.ts helper for route handlers
// apps/internal-web/src/lib/audit/log.ts
import { AuditStore } from "./store";

const store = new AuditStore(process.env.AUDIT_DB ?? "/data/audit.sqlite");

export function auditLog(args: {
  who: string;
  scope: "public" | "internal" | "shared" | null;
  action: string;
  target: string;
  before?: unknown;
  after?: unknown;
}): void {
  store.insert({
    who: args.who,
    scope: args.scope,
    action: args.action,
    target: args.target,
    before: args.before !== undefined ? JSON.stringify(args.before) : null,
    after: args.after !== undefined ? JSON.stringify(args.after) : null,
  });
}
  • [x] Step 3: Wire into /api/admin/policy/route.ts (POST upsert path)

Read the file, add before the store().upsert(body) line:

import { auditLog } from "@/lib/audit/log";
// ...inside POST:
const beforeDoc = store().getActive();
store().upsert(body);
auditLog({
  who: /* session.user.email if using next-auth, else "system" */ "system",
  scope: null, // policy is cross-scope
  action: "policy.upsert",
  target: `policy.v${body.version}`,
  before: beforeDoc,
  after: body,
});
  • [x] Step 4: Write audit-log viewer page
// apps/internal-web/src/app/(staff)/admin/observe/audit/page.tsx
import { AuditStore } from "@/lib/audit/store";

export const runtime = "nodejs";

export default async function AuditPage() {
  const rows = new AuditStore(process.env.AUDIT_DB ?? "/data/audit.sqlite").recent(200);
  return (
    <section>
      <h1 className="text-lg font-semibold mb-3">Audit Log</h1>
      <table className="w-full text-sm">
        <thead>
          <tr className="text-left border-b">
            <th className="py-1">Waktu</th>
            <th>Siapa</th>
            <th>Lingkup</th>
            <th>Tindakan</th>
            <th>Target</th>
          </tr>
        </thead>
        <tbody>
          {rows.map((r) => (
            <tr key={r.id} className="border-b">
              <td className="py-1 font-mono text-xs">{r.ts}</td>
              <td>{r.who}</td>
              <td>{r.scope ?? "—"}</td>
              <td>{r.action}</td>
              <td className="font-mono text-xs">{r.target}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </section>
  );
}
  • [x] Step 5: Commit
git add apps/internal-web/src/lib/audit/ apps/internal-web/src/app/(staff)/admin/observe/audit/ apps/internal-web/src/app/api/admin/policy/route.ts
git commit -m "feat(admin/audit): SQLite audit store + log helper + observe page

Every mutation via admin CRUD endpoints writes an audit row {who, scope,
action, target, before, after}. Recent 200 rows visible at
/admin/observe/audit."

Task B5: Cmd+K palette scope switcher

Files:
- Create: apps/internal-web/src/components/admin/scope/ScopePalette.tsx
- Modify: layout to mount the palette globally

  • [x] Step 1: Add the palette component
// apps/internal-web/src/components/admin/scope/ScopePalette.tsx
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";

const ACTIONS = [
  { label: "Switch to Public admin", href: "/admin/public" },
  { label: "Switch to Internal admin", href: "/admin/internal" },
  { label: "Switch to Shared config", href: "/admin/shared" },
  { label: "Open Audit Log", href: "/admin/observe/audit" },
  { label: "Open Dashboard", href: "/admin/observe/dashboard" },
];

export function ScopePalette(): React.ReactElement | null {
  const [open, setOpen] = useState(false);
  const [q, setQ] = useState("");
  const router = useRouter();
  useEffect(() => {
    const on = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
        e.preventDefault();
        setOpen((v) => !v);
      } else if (e.key === "Escape") setOpen(false);
    };
    window.addEventListener("keydown", on);
    return () => window.removeEventListener("keydown", on);
  }, []);
  if (!open) return null;
  const filtered = ACTIONS.filter((a) => a.label.toLowerCase().includes(q.toLowerCase()));
  return (
    <div
      className="fixed inset-0 bg-black/30 grid place-items-start pt-32 z-50"
      onClick={() => setOpen(false)}
    >
      <div
        className="bg-white dark:bg-neutral-900 rounded-lg shadow-lg w-[520px] p-3"
        onClick={(e) => e.stopPropagation()}
      >
        <input
          autoFocus
          value={q}
          onChange={(e) => setQ(e.target.value)}
          placeholder="Cari perintah admin..."
          className="w-full border-b py-2 text-sm outline-none bg-transparent"
        />
        <ul className="mt-2 max-h-72 overflow-y-auto">
          {filtered.map((a) => (
            <li key={a.href}>
              <button
                className="w-full text-left px-2 py-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-sm"
                onClick={() => {
                  setOpen(false);
                  router.push(a.href);
                }}
              >
                {a.label}
              </button>
            </li>
          ))}
          {filtered.length === 0 && (
            <li className="px-2 py-1.5 text-xs opacity-60">Tidak ada hasil.</li>
          )}
        </ul>
      </div>
    </div>
  );
}
  • [x] Step 2: Mount in staff layout

Edit apps/internal-web/src/app/(staff)/layout.tsx — import + render <ScopePalette /> as a sibling of children.

  • [x] Step 3: Commit
git add apps/internal-web/src/components/admin/scope/ScopePalette.tsx apps/internal-web/src/app/(staff)/layout.tsx
git commit -m "feat(admin/scope): Cmd+K palette to jump between scopes + audit + dashboard"

Task B6: Knowledge-base drift indicator

Files:
- Create: apps/internal-web/src/lib/admin/knowledge-drift.ts
- Modify: apps/internal-web/src/components/admin/knowledge/TabelDataTab.tsx (surface the indicator)

  • [x] Step 1: Write drift-detection helper
// apps/internal-web/src/lib/admin/knowledge-drift.ts
import { readFileSync, existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path";

function sha(buf: Buffer): string {
  return createHash("sha1").update(buf).digest("hex");
}

export function driftForTable(name: string): { drifted: boolean; publicHash: string | null; internalHash: string | null } {
  const root = process.env.KNOWLEDGE_ROOT ?? "/infra/knowledge";
  const pub = join(root, "public", "tables", `${name}.json`);
  const int = join(root, "internal", "tables", `${name}.json`);
  const p = existsSync(pub) ? sha(readFileSync(pub)) : null;
  const i = existsSync(int) ? sha(readFileSync(int)) : null;
  return { drifted: p !== null && i !== null && p !== i, publicHash: p, internalHash: i };
}
  • [x] Step 2: Surface in TabelDataTab

Read the current file. Where table rows render, add:

{drifted && (
  <span className="ml-2 rounded bg-amber-100 text-amber-800 text-xs px-1.5 py-0.5">drift</span>
)}

with const { drifted } = driftForTable(row.name); in a server-component wrapper.

  • [x] Step 3: Commit
git add apps/internal-web/src/lib/admin/knowledge-drift.ts apps/internal-web/src/components/admin/knowledge/TabelDataTab.tsx
git commit -m "feat(admin/knowledge): drift indicator on tables that exist in both scopes with different content"

Phase C — SSE resume primitive

Task C1: Redis-backed event-id checkpoints

Files:
- Create: apps/public-web/src/lib/orchestrator/sse-resume.ts

  • [x] Step 1: Write the module
// apps/public-web/src/lib/orchestrator/sse-resume.ts
import IORedis from "ioredis";

const redis = new IORedis(process.env.REDIS_URL ?? "redis://localhost:6379", { maxRetriesPerRequest: null });
const TTL_SECONDS = 300; // 5 minutes — enough for reconnect

export interface CheckpointEntry {
  id: string;
  event: string;
  data: string;
}

export async function appendCheckpoint(sessionId: string, entry: CheckpointEntry): Promise<void> {
  const key = `sse:resume:${sessionId}`;
  await redis
    .multi()
    .rpush(key, JSON.stringify(entry))
    .expire(key, TTL_SECONDS)
    .exec();
}

export async function since(sessionId: string, lastEventId: string | null): Promise<CheckpointEntry[]> {
  const key = `sse:resume:${sessionId}`;
  const raw = await redis.lrange(key, 0, -1);
  const all = raw.map((r) => JSON.parse(r) as CheckpointEntry);
  if (!lastEventId) return [];
  const idx = all.findIndex((e) => e.id === lastEventId);
  return idx >= 0 ? all.slice(idx + 1) : [];
}
  • [x] Step 2: Commit
git add apps/public-web/src/lib/orchestrator/sse-resume.ts
git commit -m "feat(orchestrator/sse-resume): Redis-backed event-id checkpoint list per session"

Task C2: Wire checkpoint into runOrchestrator

Files:
- Modify: apps/public-web/src/lib/orchestrator/orchestrate.ts
- Modify: apps/public-web/src/lib/orchestrator/sse.ts

  • [x] Step 1: Add ID to sse encoders

Modify sse.ts to accept an optional id and emit id: <val>\n lines:

function event(name: string, data: Record<string, unknown> = {}, id?: string): string {
  const payload = JSON.stringify(data);
  const idLine = id ? `id: ${id}\n` : "";
  return `${idLine}event: ${name}\ndata: ${payload}\n\n`;
}
// Update each exported function to accept + forward id, e.g.:
export const status = (text: string, id?: string) => event("status", { text }, id);

Apply the same pattern to content, references, done, error.

  • [x] Step 2: Wire checkpointing into runOrchestrator

In orchestrate.ts, generate incrementing IDs and call appendCheckpoint:

import { appendCheckpoint } from "./sse-resume";

// Inside runOrchestrator, wrap each `yield sse.X(...)` with:
let seq = 0;
const emit = async (chunk: string, name: string, payload: Record<string, unknown>): Promise<string> => {
  const id = `${req.sessionId}-${++seq}`;
  await appendCheckpoint(req.sessionId, { id, event: name, data: JSON.stringify(payload) });
  return chunk;
};
// Replace `yield sse.status(txt)` with:
yield await emit(sse.status(txt, `${req.sessionId}-${seq + 1}`), "status", { text: txt });
  • [x] Step 3: Add unit test
// apps/public-web/tests/orchestrator/sse-resume.test.ts
import { describe, it, expect } from "vitest";
import { appendCheckpoint, since } from "../../src/lib/orchestrator/sse-resume";

describe("sse-resume", () => {
  it("appends + retrieves entries since a checkpoint id", async () => {
    const sid = `t-${Date.now()}`;
    await appendCheckpoint(sid, { id: "e1", event: "content", data: '{"delta":"a"}' });
    await appendCheckpoint(sid, { id: "e2", event: "content", data: '{"delta":"b"}' });
    const s = await since(sid, "e1");
    expect(s.length).toBe(1);
    expect(s[0].id).toBe("e2");
  });
});
  • [x] Step 4: Run + commit
cd apps/public-web && pnpm vitest run tests/orchestrator/sse-resume.test.ts
git add apps/public-web/src/lib/orchestrator/{orchestrate,sse,sse-resume}.ts apps/public-web/tests/orchestrator/sse-resume.test.ts
git commit -m "feat(orchestrator): checkpoint every SSE event to Redis for resume"

Task C3: Handle Last-Event-ID in the route

Files:
- Modify: apps/public-web/src/app/api/orchestrate/route.ts

  • [x] Step 1: Read Last-Event-ID header + replay before resuming

Modify the route:

import { since } from "@/lib/orchestrator/sse-resume";

export async function POST(req: Request): Promise<Response> {
  // ...existing parse + validate...
  const lastEventId = req.headers.get("Last-Event-ID");

  const stream = new ReadableStream<Uint8Array>({
    async start(controller) {
      const enc = new TextEncoder();
      if (lastEventId) {
        const missed = await since(body.sessionId, lastEventId);
        for (const e of missed) {
          controller.enqueue(enc.encode(`id: ${e.id}\nevent: ${e.event}\ndata: ${e.data}\n\n`));
        }
      }
      for await (const chunk of runOrchestrator(body)) {
        controller.enqueue(enc.encode(chunk));
      }
      controller.close();
    },
  });
  // ...existing headers...
}
  • [x] Step 2: Commit
git add apps/public-web/src/app/api/orchestrate/route.ts
git commit -m "feat(api/orchestrate): honor Last-Event-ID header for SSE resume"

Task C4: Frontend reconnect logic in native-provider

Files:
- Modify: packages/streams/src/native-provider.ts

  • [x] Step 1: Track lastId + reconnect on close

Add near the top of NativeProvider.sendMessage:

let lastId: string | null = null;

In the SSE reader loop, when parsing lines, capture id: lines into lastId. When the reader signals disconnect (early close of response.body), retry the fetch with Last-Event-ID: <lastId> header for up to 3 attempts with exponential backoff (0.5s, 1s, 2s).

  • [x] Step 2: Commit
git add packages/streams/src/native-provider.ts
git commit -m "feat(streams): reconnect with Last-Event-ID on premature SSE close (3 retries, exponential backoff)"

Phase D — Standard gateway request headers

Task D1: Emit X-Tenant-Id / X-Surface / X-User-Id / X-Priority from LlmClient

Files:
- Modify: apps/public-web/src/lib/orchestrator/orchestrate.ts

  • [x] Step 1: Pass headers through each LlmClient call

In buildLlms() and each step that calls the planning or synthesis client, thread a headers arg:

const commonHeaders = {
  "X-Tenant-Id": "ahu-chatbot",
  "X-Surface": req.surface,
  "X-User-Id": req.sessionId, // proxy for user until public auth lands
};

// In each plan/evaluate/compose call:
await planning.completeJson(msgs, { headers: { ...commonHeaders, "X-Priority": "planning" } });
// ...
for await (const delta of synthesis.stream(msgs, { headers: { ...commonHeaders, "X-Priority": "synthesis" } })) { ... }
  • [x] Step 2: Test presence

Add to orchestrate.test.ts or a new test: mock fetch, assert the request had X-Tenant-Id header.

  • [x] Step 3: Commit
git add apps/public-web/src/lib/orchestrator/orchestrate.ts apps/public-web/tests/orchestrator/orchestrate.test.ts
git commit -m "feat(orchestrator): emit X-Tenant-Id + X-Surface + X-User-Id + X-Priority headers on every LLM call"

Phase E — BullMQ workers

Task E1: Nightly eval worker

Files:
- Create: apps/internal-web/src/workers/eval-nightly.ts
- Create: infra/compose.workers.yaml

  • [x] Step 1: Write worker
// apps/internal-web/src/workers/eval-nightly.ts
import { makeWorker } from "@ahu/queue";

interface EvalJob { agent: "public" | "internal"; limit?: number }

makeWorker<EvalJob>("nightly-eval", async ({ data }) => {
  const { agent, limit = 5 } = data;
  const url = agent === "public" ? "http://public-agent:8000" : "http://internal-agent:8000";
  // Fire evaluation queries; capture outputs to /data/eval-runs/<date>.json
  console.log(`[eval-nightly] running ${limit} cases against ${url}`);
  // ... actual eval logic (fetch loop) ...
});
  • [x] Step 2: Write worker compose stack
# infra/compose.workers.yaml
services:
  ahu-workers:
    image: ahu-ai-chatbot-internal:${IMAGE_TAG:-latest}
    container_name: ahu-ai-workers
    restart: unless-stopped
    command: ["node", "apps/internal-web/dist/workers/eval-nightly.js"]
    env_file:
      - env/shared.env
      - env/internal.env
    networks:
      - gateway-net
      - ahu-net

networks:
  gateway-net:
    external: true
    name: ahu-gateway-net
  ahu-net:
    external: true
  • [x] Step 3: Commit
git add apps/internal-web/src/workers/eval-nightly.ts infra/compose.workers.yaml
git commit -m "feat(workers): nightly eval worker + workers compose stack"

Task E2: Knowledge re-ingestion worker

Files:
- Create: apps/internal-web/src/workers/knowledge-reingest.ts

  • [x] Step 1: Same shape as Task E1 but calls the ai-ahu-rag ingest endpoint
// apps/internal-web/src/workers/knowledge-reingest.ts
import { makeWorker } from "@ahu/queue";

interface ReingestJob { collection: string; sourceDir: string }

makeWorker<ReingestJob>("knowledge-reingest", async ({ data }) => {
  const res = await fetch(`http://ai-ahu-rag:8110/admin/ingest?collection=${data.collection}&source=${data.sourceDir}`, {
    method: "POST",
  });
  if (!res.ok) throw new Error(`ingest failed: HTTP ${res.status}`);
});
  • [x] Step 2: Add to workers compose command list

Update infra/compose.workers.yaml to run BOTH workers (spawn one container per worker, OR a single container that starts both).

  • [x] Step 3: Commit
git add apps/internal-web/src/workers/knowledge-reingest.ts infra/compose.workers.yaml
git commit -m "feat(workers): knowledge re-ingestion worker (calls ai-ahu-rag /admin/ingest)"

Task E3: Admin UI to trigger + view jobs

Files:
- Create: apps/internal-web/src/app/(staff)/admin/shared/jobs/page.tsx
- Create: apps/internal-web/src/app/api/admin/jobs/route.ts

  • [x] Step 1: API route enqueues + reads job status
// apps/internal-web/src/app/api/admin/jobs/route.ts
import { NextResponse } from "next/server";
import { makeQueue } from "@ahu/queue";

const q = makeQueue("nightly-eval");

export const runtime = "nodejs";

export async function GET() {
  const active = await q.getActive();
  const completed = await q.getCompleted(0, 20);
  return NextResponse.json({ active, completed });
}

export async function POST(req: Request) {
  const body = (await req.json()) as { name: string; data: unknown };
  const job = await q.add(body.name, body.data);
  return NextResponse.json({ jobId: job.id });
}
  • [x] Step 2: Write page

Simple table listing active + completed jobs; button to enqueue "Run eval now".

  • [x] Step 3: Commit
git add apps/internal-web/src/app/api/admin/jobs/ apps/internal-web/src/app/\(staff\)/admin/shared/jobs/
git commit -m "feat(admin/jobs): admin UI to trigger + view BullMQ jobs"

Phase F — SQL-guard TS port

Task F1: Add JS SQL parser dep + port validator

Files:
- Create: apps/public-web/src/lib/orchestrator/sql-guard/validator.ts
- Create: apps/public-web/tests/orchestrator/sql-guard.test.ts
- Modify: apps/public-web/package.json — add node-sql-parser

  • [x] Step 1: Add dep
cd apps/public-web
pnpm add node-sql-parser
  • [x] Step 2: Port validator (mirrors sql_guard/validator.py from Plan A deferral)
// apps/public-web/src/lib/orchestrator/sql-guard/validator.ts
import { Parser } from "node-sql-parser";

const AGGREGATE_FNS = new Set(["COUNT", "SUM", "AVG", "MIN", "MAX", "MEDIAN", "STDDEV", "VARIANCE"]);

export interface ValidatorConfig {
  allowed_tables: Set<string>;
  pre_aggregated_tables: Set<string>;
  disallowed_columns: Set<string>;
  dialect: "mysql" | "postgres";
}

export class ValidationError extends Error {}

const norm = (s: string | null | undefined): string => (s ?? "").trim().toLowerCase();

export function validate(sql: string, cfg: ValidatorConfig): void {
  const parser = new Parser();
  let ast: any;
  try {
    ast = parser.astify(sql, { database: cfg.dialect });
  } catch (e) {
    throw new ValidationError(`parse error: ${(e as Error).message}`);
  }
  const stmts = Array.isArray(ast) ? ast : [ast];
  if (stmts.length !== 1) throw new ValidationError("must be exactly one statement");
  const s = stmts[0];
  if (s.type !== "select") throw new ValidationError(`non-SELECT statement: ${s.type}`);

  // No SELECT *
  for (const col of s.columns ?? []) {
    if (col.expr?.type === "star") throw new ValidationError("SELECT * is not permitted");
  }

  // Collect referenced tables + columns
  const tables = new Set<string>();
  const cols = new Set<string>();
  const walk = (node: any) => {
    if (!node || typeof node !== "object") return;
    if (node.table) tables.add(norm(node.table));
    if (node.column) cols.add(norm(node.column));
    for (const k of Object.keys(node)) if (typeof node[k] === "object") walk(node[k]);
  };
  walk(s);

  // Table allowlist
  const badTables = [...tables].filter((t) => !cfg.allowed_tables.has(t));
  if (badTables.length) throw new ValidationError(`table not in allowlist: ${badTables.sort()}`);

  // Disallowed columns
  const badCols = [...cols].filter((c) => cfg.disallowed_columns.has(c));
  if (badCols.length) throw new ValidationError(`disallowed column referenced: ${badCols.sort()}`);

  // No LIMIT 1
  if (s.limit?.value?.[0]?.value === 1) throw new ValidationError("LIMIT 1 / single-row pattern blocked");

  // Non-pre-aggregated table requires aggregate function
  const nonPreAgg = [...tables].filter((t) => !cfg.pre_aggregated_tables.has(t));
  if (nonPreAgg.length > 0) {
    const hasAgg = (s.columns ?? []).some((c: any) => {
      const walk2 = (n: any): boolean => {
        if (!n || typeof n !== "object") return false;
        if (n.type === "aggr_func" && AGGREGATE_FNS.has(String(n.name).toUpperCase())) return true;
        return Object.values(n).some((v) => walk2(v));
      };
      return walk2(c.expr);
    });
    if (!hasAgg) throw new ValidationError("query against non-aggregated table requires an aggregate function");
  }
}
  • [x] Step 3: Write tests (mirrors Python test_sql_validator.py)
import { describe, it, expect } from "vitest";
import { validate, ValidationError, type ValidatorConfig } from "../../src/lib/orchestrator/sql-guard/validator";

const cfg: ValidatorConfig = {
  allowed_tables: new Set(["tbl_perseroan", "summary_jumlah_transaksi"]),
  pre_aggregated_tables: new Set(["summary_jumlah_transaksi"]),
  disallowed_columns: new Set(["nik", "nama", "alamat"]),
  dialect: "mysql",
};

describe("sql-guard validator", () => {
  it("accepts aggregate SELECT on allowed table", () => {
    expect(() => validate("SELECT COUNT(*) FROM tbl_perseroan", cfg)).not.toThrow();
  });
  it("accepts SELECT on pre-aggregated table without aggregate", () => {
    expect(() => validate("SELECT total FROM summary_jumlah_transaksi", cfg)).not.toThrow();
  });
  it("rejects SELECT *", () => {
    expect(() => validate("SELECT * FROM tbl_perseroan", cfg)).toThrow(ValidationError);
  });
  it("rejects unknown table", () => {
    expect(() => validate("SELECT COUNT(*) FROM tbl_secret", cfg)).toThrow(/allowlist/);
  });
  it("rejects disallowed column", () => {
    expect(() => validate("SELECT nik FROM tbl_perseroan", cfg)).toThrow(/disallowed/);
  });
  it("rejects LIMIT 1", () => {
    expect(() => validate("SELECT COUNT(*) FROM tbl_perseroan LIMIT 1", cfg)).toThrow(/LIMIT 1/);
  });
  it("rejects non-aggregate on non-pre-agg table", () => {
    expect(() => validate("SELECT tahun FROM tbl_perseroan", cfg)).toThrow(/aggregate function/);
  });
});
  • [x] Step 4: Run + commit
pnpm vitest run tests/orchestrator/sql-guard.test.ts
git add apps/public-web/src/lib/orchestrator/sql-guard/ apps/public-web/tests/orchestrator/sql-guard.test.ts apps/public-web/package.json pnpm-lock.yaml
git commit -m "feat(orchestrator/sql-guard): TS port of validator (Plan A deferred item #7)"

Task F2: Port result_filter (row-level k-anonymity)

Files:
- Create: apps/public-web/src/lib/orchestrator/sql-guard/result-filter.ts
- Create: apps/public-web/tests/orchestrator/result-filter.test.ts

  • [x] Step 1: Port result_filter.py to TS
// apps/public-web/src/lib/orchestrator/sql-guard/result-filter.ts
export interface FilterConfig {
  disallowed_columns: Set<string>;
  k_anonymity_min: number;
  max_rows: number;
  aggregate_count_columns: Set<string>;
}

export class FilteredOut extends Error {}

const norm = (s: string): string => s.trim().toLowerCase();

export interface FilteredResult {
  columns: string[];
  rows: unknown[][];
  suppressed_count: number;
  truncated: boolean;
}

export function filterResult(
  columns: string[],
  rows: unknown[][],
  cfg: FilterConfig,
): FilteredResult {
  const normCols = columns.map(norm);
  const bad = normCols.filter((c) => cfg.disallowed_columns.has(c));
  if (bad.length > 0) throw new FilteredOut(`disallowed column in result schema: ${bad.sort()}`);
  const countIdx = normCols.findIndex((c) => cfg.aggregate_count_columns.has(c));
  let suppressed = 0;
  let filtered = rows;
  if (countIdx >= 0) {
    filtered = rows.filter((r) => {
      const v = Number(r[countIdx]);
      if (Number.isFinite(v) && v < cfg.k_anonymity_min) {
        suppressed++;
        return false;
      }
      return true;
    });
  }
  const truncated = filtered.length > cfg.max_rows;
  return {
    columns: [...columns],
    rows: truncated ? filtered.slice(0, cfg.max_rows) : filtered,
    suppressed_count: suppressed,
    truncated,
  };
}
  • [x] Step 2: Write tests + commit
import { describe, it, expect } from "vitest";
import { filterResult, FilteredOut } from "../../src/lib/orchestrator/sql-guard/result-filter";

const base = {
  disallowed_columns: new Set(["nik"]),
  k_anonymity_min: 10,
  max_rows: 5,
  aggregate_count_columns: new Set(["total", "count", "jumlah"]),
};

describe("filterResult", () => {
  it("rejects schema with disallowed column", () => {
    expect(() => filterResult(["nik", "tahun"], [[1, 2020]], base)).toThrow(FilteredOut);
  });
  it("suppresses rows with count < k_anonymity_min", () => {
    const out = filterResult(["provinsi", "total"], [["JATIM", 5], ["JABAR", 100]], base);
    expect(out.suppressed_count).toBe(1);
    expect(out.rows.length).toBe(1);
  });
  it("truncates when rows > max_rows", () => {
    const rows = Array.from({ length: 8 }, (_, i) => ["p", i * 100]);
    const out = filterResult(["provinsi", "total"], rows, base);
    expect(out.truncated).toBe(true);
    expect(out.rows.length).toBe(5);
  });
});
pnpm vitest run tests/orchestrator/result-filter.test.ts
git add apps/public-web/src/lib/orchestrator/sql-guard/result-filter.ts apps/public-web/tests/orchestrator/result-filter.test.ts
git commit -m "feat(orchestrator/sql-guard): TS port of result_filter (k-anon + row cap)"

Phase G — Dual-model streaming refactor

Task G1: Refactor native-provider around an event pipeline

Files:
- Modify: packages/streams/src/native-provider.ts

  • [x] Step 1: Replace the ad-hoc dual-mode detection with a state machine

Look at current dualMode detection: it's a boolean flag set on first RunIntermediateContent or OutputModelResponseStarted. Refactor into an explicit type StreamMode = "single" | "dual" set at start-of-stream + a pipeline that routes events:

  • RunIntermediateContent → always emits reasoning_step
  • RunContent → if mode === "single", buffered raw + split at </think> at run_end; if "dual", emit text_chunk directly

Rewrite the loop with a clean state machine so the DUAL_MODE_SIGNAL vs FALLBACK_SIGNAL distinction is explicit and testable.

  • [x] Step 2: Add unit tests for both single and dual modes

Create packages/streams/tests/native-provider-modes.test.ts covering:
- single mode: 3 RunContent events → 3 text_chunk events after finalize
- dual mode: 1 RunIntermediateContent + 2 RunContent → 1 reasoning_step + 2 text_chunk streamed live

  • [x] Step 3: Commit
git add packages/streams/src/native-provider.ts packages/streams/tests/native-provider-modes.test.ts
git commit -m "refactor(streams): native-provider around explicit single/dual state machine

Replaces implicit-boolean dualMode detection with a typed StreamMode +
pipeline routing. Adds test coverage for both modes independently."

Phase H — Unify Dash eval env schema

Task H1: Read same env vars as orchestrator

Files:
- Modify: apps/{public,internal}-agent/dash/evals/run_model_comparison.py

  • [x] Step 1: Read the current env schema
grep -n "os.environ\|getenv" apps/internal-agent/dash/evals/run_model_comparison.py | head -20
  • [x] Step 2: Substitute the legacy names for the orchestrator ones

Search for DASHSCOPE_API_KEY, LLM_API_KEY_LOCAL etc. — replace with SYNTHESIS_API_KEY, MODEL_GATEWAY_URL, LLM_PLANNING_MODEL, LLM_SYNTHESIS_MODEL respectively, with fallback:

import os
API_KEY = os.environ.get("SYNTHESIS_API_KEY") or os.environ.get("DASHSCOPE_API_KEY")
GATEWAY = os.environ.get("MODEL_GATEWAY_URL", "http://ahu-vllm:8000")
  • [x] Step 3: Run inside staging container to verify
ssh obert@192.168.83.20 "docker exec ahu-ai-agent-public sh -c 'cd /repo/apps/public-agent && python -m dash.evals.run_model_comparison --limit 2'"

Expected: no more "missing env" skips; actual eval runs against the live gateway.

  • [x] Step 4: Commit
git add apps/public-agent/dash/evals/run_model_comparison.py apps/internal-agent/dash/evals/run_model_comparison.py
git commit -m "fix(evals): unify env schema with orchestrator (SYNTHESIS_API_KEY, MODEL_GATEWAY_URL)"

Phase I — Physical dir rename

Task I1: Coordinate safe window

Files: none (procedural)

  • [x] Step 1: Verify no active session has the old path pinned

Check active shells, containers, systemd services:

lsof /home/efran/remote-development/poc-ahu-ai/ai-ahu-chatbot 2>&1 | head
docker ps --format '{{.Names}} {{.Mounts}}' | grep ai-ahu-chatbot

Both should return empty.

  • [x] Step 2: Announce maintenance window

Slack/team channel: "renaming ai-ahu-chatbotahu-ai-chatbot on Server 1. Any live SSH sessions in the old path will need to re-cd. ETA: 2 minutes."


Task I2: Perform the rename

Files: filesystem operation

  • [ ] Step 1: Remove symlink + rename
cd /home/efran/remote-development/poc-ahu-ai
rm ahu-ai-chatbot  # symlink
mv ai-ahu-chatbot ahu-ai-chatbot
  • [ ] Step 2: Verify git still works
git -C /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot status

Expected: clean, on main.

  • [ ] Step 3: Commit rationale
git -C /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot commit --allow-empty -m "chore: physical rename ai-ahu-chatbot → ahu-ai-chatbot (symlink removed)"
  • [ ] Step 4: Update memory + old references

Grep for absolute paths referencing the old name in any deploy scripts, then update:

grep -rln "ai-ahu-chatbot" /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/infra/ /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/apps/*/scripts/ | head

Replace occurrences with ahu-ai-chatbot. Commit.


End-of-Plan-C success criteria

Run these once Task I2 is committed:

# From anywhere with LAN access:
curl -sf https://x056.ahu-demo.chatbot-neo.val.id/api/health
curl -N -H 'Content-Type: application/json' \
  -d '{"surface":"public","history":[],"message":"Apa itu perseroan terbatas?","sessionId":"final-1"}' \
  https://x056.ahu-demo.chatbot-neo.val.id/api/orchestrate --max-time 60 | grep -c '"delta"'
# Expected: real Indonesian answer with references; delta count > 10

cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
pnpm typecheck
pnpm test
uv run pytest tests/isolation -v
ls /home/efran/remote-development/poc-ahu-ai/ | grep -E "^(ai-ahu|ahu-ai)-chatbot$"
# Expected: only ahu-ai-chatbot listed

Plan C is done when:

  1. ✓ Live prod returns real Indonesian legal answers (RAG loaded)
  2. ✓ /admin/{public,internal,shared,observe}/ routing works; ScopePill visible on each page
  3. ✓ Audit log records at least one mutation from Task B4
  4. ✓ SSE resume: interrupt + reconnect with Last-Event-ID resumes stream
  5. ✓ LLM calls carry X-Tenant-Id / X-Surface / X-User-Id / X-Priority headers
  6. ✓ At least one BullMQ job runs successfully via /admin/shared/jobs
  7. pnpm vitest run tests/orchestrator/sql-guard.test.ts — 7/7 pass
  8. ✓ Native-provider handles both single-model and dual-model with test coverage
  9. docker exec ahu-ai-agent-public python -m dash.evals.run_model_comparison --limit 2 runs without env skips
  10. ls poc-ahu-ai/ shows only ahu-ai-chatbot (physical rename complete)