# PROMPT CODING AGENT — BE Golang: Chat Agent Pekerjaan + Centrifugo

> Tempel seluruh isi ke coding agent backend. Versi 3.0 — realtime via **Centrifugo standalone**, arsitektur FE (Nuxt 4) → BE (Golang) → Hermes.

---

Kamu adalah coding agent backend (Golang) yang membangun modul **Chat Agent Pekerjaan** di service backend tim yang sudah ada. Modul ini: proxy chat ke Hermes Agent (dieksekusi background, hasil di-stream ke **Centrifugo**), upload & serving file, manajemen sesi per user, dan **sistem kuota lamaran** dengan pencatatan ke database. Gunakan framework/router, database, dan pola auth yang SUDAH dipakai di codebase ini.

## 1. Konteks & arsitektur

- FE: Nuxt 4 — kirim pesan via HTTP ke BE, terima semua peristiwa via **Centrifugo** (WebSocket pub/sub).
- AI backend: **Hermes Agent**, API server **OpenAI-compatible** (`POST /v1/chat/completions`, streaming SSE). Satu mesin Hermes = SATU user.
- **Keputusan kunci:** eksekusi agent TIDAK terikat koneksi HTTP client. `POST /api/v1/chat` balas 202 cepat, agent jalan di background goroutine, event di-publish ke channel Centrifugo. Client boleh nutup tab — proses tetap jalan.

```
FE (Nuxt 4) ──HTTP──► BE Golang (modul ini) ──HTTP──► Hermes API (mesin per user)
      ▲                    │  ▲
      │                    │  └─ baca SSE Hermes → publish event typed
      │              ┌─────┴──────┐
      └──WS◄─ Centrifugo standalone ◄─HTTP/GRPC publish──┘
            (channel "user:<user_id>")
      BE juga: quota check & log → DATABASE · upload → simpan → URL publik
```

## 2. Centrifugo (standalone) — setup & integrasi

### 2.1 Deploy & konfigurasi Centrifugo

- Deploy **standalone Centrifugo** (binary Go resmi atau image `centrifugal/centrifugo`), versi terbaru stable. Satu instance cukup untuk v1 (engine memory); siapkan catatan bahwa engine Redis dibutuhkan jika nanti multi-instance.
- Config (`config.json`):
  ```json
  {
    "token_hmac_secret_key": "<CENTRIFUGO_TOKEN_SECRET>",
    "api_key": "<CENTRIFUGO_API_KEY>",
    "allowed_origins": ["<origin FE>"],
    "namespaces": [
      {
        "name": "user",
        "history_size": 500,
        "history_ttl": "900s",
        "presence": true,
        "join_leave": true
      }
    ]
  }
  ```
- Channel pribadi per user: `user:<user_id>` (prefix namespace `user`).
- Verifikasi: `curl http://localhost:8000/health` → `{"status":"ok"...}`.

### 2.2 Env BE baru

```bash
CENTRIFUGO_URL=http://localhost:8000
CENTRIFUGO_API_KEY=<CENTRIFUGO_API_KEY>          # untuk HTTP API publish
CENTRIFUGO_TOKEN_SECRET=<CENTRIFUGO_TOKEN_SECRET> # SAMA dengan token_hmac_secret_key
REALTIME_CHANNEL_PREFIX=user
VNC_JWT_SECRET=<isi file vnc-jwt-secret di mesin user>   # untuk tanda tangan JWT viewer VNC
# ...env lama tetap: HERMES_API_URL, HERMES_API_KEY, HERMES_INTERNAL_TOKEN,
# UPLOAD_DIR, PUBLIC_BASE_URL
# Kuota TIDAK dari env — baca per-user dari user_services (daily_quota, max_quota);
# fallback default jika kolom kosong: 5/hari, 150 total (5×30 hari).
```

### 2.3 Token koneksi WS untuk FE

- Endpoint `GET /api/v1/realtime/token` (auth FE→BE yang ada): issue JWT HS256 dengan `CENTRIFUGO_TOKEN_SECRET`:
  ```json
  {"sub": "<user_id>", "channels": ["user:<user_id>"], "exp": <now+1h>}
  ```
- `sub` WAJIB diambil dari sesi auth (jangan percaya input client). Balas `{"token":"..."}`.

### 2.4 Publish event

- HTTP API: `POST {CENTRIFUGO_URL}/api/publish`, header `Authorization: apikey <CENTRIFUGO_API_KEY>`, body `{"channel":"user:<id>","data":{...}}`.
- Semua publish di-bungkus helper: `publishEvent(userID, eventType, payload)` → data = `{"type": "<eventType>", ...payload}`.

## 3. Pemetaan user → mesin Hermes

- Per user, simpan di tabel **`user_service`** (sudah ada di codebase): `user_id → { base_url, api_key }` (+ tambahkan kolom **`vnc_url`** untuk URL WebSocket viewer VNC mesin user, mis. `wss://<hostname-mesin>-6080.jkt3.sumopod.my.id/websockify`, dan **`next_run_at`** untuk jadwal daily run, lihat §10.1). Default dari env `HERMES_API_URL`/`HERMES_API_KEY` jika entri kosong.
- Saat chat masuk, resolve `base_url`+`api_key` milik user tersebut; jika user belum punya mesin → 409 `{"error":{"code":"agent_not_provisioned","message":"Mesin agent untuk user ini belum disiapkan."}}`.
- **Kerahasiaan antar-user (1 domain multi-user):** `base_url`, `api_key`, dan `vnc_url` TIDAK boleh bocor keluar BE — tidak pernah di respons, log, atau header yang sampai ke FE/user lain.
- **`GET /api/v1/realtime/vnc`** (auth FE→BE): kembalikan URL WebSocket lengkap **dengan JWT pendek**, hanya untuk user yang terautentikasi:
  `{"url":"wss://<hostname-mesin>-6080.jkt3.sumopod.my.id/websockify?token=<jwt>"}`
  - JWT: HS256, secret `VNC_JWT_SECRET` (= isi file `vnc-jwt-secret` di mesin user), claims `{"host":"localhost","port":5901,"exp":<now+1h>}`.
  - ⚠️ **GOTCHA (wajib):** websockify (jwcrypto) menginterpretasi secret sebagai **base64url**, BUKAN string mentah. Hex secret 64-char → kunci HMAC = `base64url_decode(hex)` (48 byte). Contoh Go yang BENAR:
    ```go
    import "encoding/base64"

    secretBytes, _ := base64.RawURLEncoding.DecodeString(vncJWTSecret) // 48 byte — JANGAN pakai []byte(vncJWTSecret) mentah
    claims := jwt.MapClaims{"host": "localhost", "port": 5901, "exp": time.Now().Add(time.Hour).Unix()}
    s, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secretBytes)
    ```
    (Tanda tangan dengan `[]byte(vncJWTSecret)` mentah akan ditolak websockify: `Verification failed`.)
  - Auth ter-enforce di MESIN (websockify JWTTokenApi): token tanpa/rusak/kedaluwarsa → koneksi WS ditolak. BE hanya penerbit token.
  - User tanpa `vnc_url` → 404 `{"error":{"code":"vnc_not_configured",...}}`. JANGAN pernah mengembalikan URL mesin user lain.

## 4. Eksekusi turn agent (background) — KONTRAK EVENT

### 4.1 `POST /api/v1/chat`

- Body `{"message":"..."}`. Ambil session id Hermes user (bagian 5).
- **Scope restriction (WAJIB):** setiap request ke Hermes, `messages` SELALU diawali pesan `role:"system"` berisi batasan scope (lihat teks di bawah). Tanpa ini, agent bisa melayani permintaan di luar topik (terbukti: `system_prompt` di body TIDAK efektif; `role:"system"` di messages yang bekerja).
  ```json
  {"role":"system","content":"Kamu adalah \"Jobseeker Assistant\": asisten yang HANYA membantu proses lamaran kerja otomatis di job portal (daftar akun, isi profil, cari & lamar lowongan, cek kuota, lapor). SETIAP permintaan di luar scope — menulis kode, tugas umum, topik non-karir, dll — WAJIB ditolak dengan sopan lalu tawarkan bantuan jobseeker. Jangan pernah melayani di luar scope. Jawab dalam Bahasa Indonesia."}
  ```
  Teks boleh dari env `SCOPE_SYSTEM_PROMPT` (default = di atas) agar bisa disesuaikan per produk.
- **Lock per user:** jika ada turn berjalan untuk user itu → **429** `{"error":{"code":"agent_busy","message":"Agent masih bekerja."}}`.
- Balas **202** `{"accepted":true,"run_id":"<uuid>"}` → jalankan goroutine:

### 4.2 Alur goroutine (satu turn)

1. Publish `agent.status` `{state:"working"}` (+ sertakan `run_id` di semua event turn ini).
2. Panggil upstream:
   ```
   POST {hermes_url}/v1/chat/completions
   Headers: Authorization: Bearer {key} · Content-Type: application/json · X-Hermes-Session-Id: {session_id}
   Body: {"model":"hermes-agent","messages":[{"role":"user","content":"<message>"}],"stream":true}
   ```
3. Baca SSE upstream baris demi baris, buffer per blok `\n\n`:
   - Blok berisi `event: hermes.tool.progress` + `data: {json}` → publish `agent.activity` `{tool, emoji, label, toolCallId}`.
   - Blok `data: {json}` biasa dengan `choices[0].delta.content` → akumulasi ke buffer teks + publish `agent.text_delta` `{delta}`.
   - `data: [DONE]` → selesai.
   - Tahan blok terpotong (simpan sisa buffer). Abaikan blok yang tidak bisa di-parse.
4. Setelah stream selesai, dari teks LENGKAP:
   - Deteksi marker dengan regex `<<<ASK_USER:(\{[\s\S]*?\})>>>` (tangani marker pertama; sisanya log saja).
   - Jika ada → publish `agent.text_done` `{text: <teks tanpa marker>}`, lalu publish `agent.ask_user` `{question, choices?, askId:"<uuid>"}` dan `agent.status` `{state:"waiting_user"}`. (Jawaban user nanti datang sebagai turn baru dengan session id sama — kontinuitas otomatis dari Hermes.)
   - Jika tidak → publish `agent.text_done` `{text}` dan `agent.status` `{state:"idle"}`.
5. Publish `agent.finished` `{ok:true}` (di akhir, baik ada ask_user maupun tidak). Lepas lock user.
6. Error (upstream HTTP error, timeout, parse gagal fatal) → publish `agent.error` `{code,message}` (pesan aman, tanpa API key/detail internal), `agent.status` `{state:"idle"}`, `agent.finished` `{ok:false}`, lepas lock.
7. Timeout longgar (mis. 10 menit) via context; yang penting: **client disconnect TIDAK membatalkan goroutine** — hanya cancel manual (bagian 4.3) atau timeout.

### 4.3 `POST /api/v1/chat/stop` (opsional tapi disarankan)

- Batalkan turn berjalan user: cancel context goroutine (request upstream ikut ter-cancel). Publish `agent.status` `{state:"idle"}` + `agent.finished` `{ok:false, reason:"stopped"}`. Lepas lock.

### 4.4 `POST /api/v1/chat/session`

- Generate session id Hermes baru untuk user, timpa yang lama (bagian 5). Publish `agent.status` `{state:"idle"}`. Balas `{"ok":true}`.

## 5. Manajemen sesi Hermes per user

- UUID per user, dibuat saat chat pertama; simpan di DB (prefer) atau in-memory map (v1, dengan TODO: restart BE memutus konteks percakapan). `POST /api/v1/chat/session` → UUID baru.

## 6. Konvensi `ask_user` (diproses BE → event typed)

- **WAJIB**: SEMUA pertanyaan/konfirmasi agent ke user di chat HARUS memakai marker `<<<ASK_USER:{"question":"...","choices":["a","b"]}>>>` di akhir teks. Tanpa marker, teks tampil sebagai pesan biasa tanpa tombol → user bingung (contoh buruk: "Mau saya lanjutkan? ..." tanpa marker). FE mengandalkan event `agent.ask_user` untuk menampilkan kartu + tombol.
- Agent menyisipkan marker di akhir pesan: `<<<ASK_USER:{"question":"...","choices":["a","b"]}>>>` (maks 4 pilihan).
- **BE yang mem-parsing marker** (bagian 4.2 langkah 4) dan mengubahnya jadi event `agent.ask_user`. FE tidak pernah melihat marker mentah.
- Kalau agent bertanya tanpa marker → BE tetap publish `agent.ask_user` hanya jika marker ada; tanpa marker → teks biasa (FE tampilkan sebagai pesan, tanpa kartu).
- Jawaban user = pesan chat biasa `Jawaban user: ...` di turn berikutnya (session id sama).

## 7. Upload & serving file

### 7.1 `POST /api/v1/upload`

- Multipart field `file`. Validasi ekstensi & MIME: PDF/DOC/DOCX (CV), JPG/JPEG/PNG (foto). Maks 10 MB.
- Simpan: `<timestamp>-<sanitized>` (sanitasi path chars, max 100 char). Balas `{"url":"<PUBLIC_BASE_URL atau origin>/api/v1/files/<nama>","name":"<nama-orisinal>"}`.

### 7.2 `GET /api/v1/files/{name}`

- `filepath.Join(UPLOAD_DIR, name)` → pastikan hasil masih di dalam UPLOAD_DIR (`filepath.Rel` + cek prefix); `/` atau `..` di nama → 404. `Content-Type` dari ekstensi, `Content-Disposition: inline`. Tidak ada → 404.

## 8. Applications: ANTRIAN (queued) → submitted (sumber kebenaran: BE + database)

- Tabel `applications` (struktur produksi): `id UUID gen_random_uuid()`, `user_id UUID FK users(id)`, `portal`, `job_title`, `job_url`, `status` (text bebas), `company`, `applied_at INT4 epoch`, `created_at INT4 epoch`. Index `(user_id, applied_at DESC)`.
- **Status flow**: `queued` (antrian, BELUM dilamar) → `submitted` (sukses lamar — **HANYA ini memakai kuota**) | `failed` (**SUDAH dicoba** melamar tapi gagal: teknis/pretest) | `skipped` (**TIDAK dilamar** — dilewati: tidak cocok profile, portal expired, external redirect) | `duplicate`. Mesin TIDAK menghapus baris — hanya ubah status. `failed` ≠ `skipped`: failed = ada usaha melamar; skipped = tidak ada usaha.
- **`POST /v1/agent/applications`** (internal, `X-Internal-Token`, constant-time compare) — **INSERT** (antrian DIISI OLEH BE/flow internal — agent mesin TIDAK memanggil POST ini; agent hanya GET antrian & PATCH status):
  - Body `{"user_id":"<uuid>","portal":"jobstreet","job_title":"...","job_url":"https://...","company":"...","status":"queued"|"submitted"}`.
  - `status=queued` → masukkan ke antrian, **TANPA cek kuota** → 201 `{"accepted":true,"id":"<uuid>","status":"queued","quota":{...}}`.
  - `status=submitted` → cek kuota dulu (transaksi + constraint anti-race): `used_today >= daily_quota` atau `total_used >= max_quota` → **403 quota_exceeded** TANPA insert; lolos → insert 201 + publish `quota.updated`.
  - **Duplikat**: `(user_id, job_url)` sudah ada (status apa pun) → **409** `{"error":{"code":"duplicate","message":"Lowongan sudah ada di antrian/riwayat"}}` TANPA insert (anti-duplikat).
- **`GET /v1/agent/applications?user_id=&status=&random=1&limit=N`** (internal) — list aplikasi user.
  - `status=queued` → daftar antrian; **`random=1`** → `ORDER BY random()` (ambil acak).
  - **`limit` di-clamp**: `limit = min(limit_diminta, sisa_kuota_hari_ini)` — agent mengirim limit = sisa kuota dari `GET /quota`, BE jaga jangan sampai over-pick.
  - `status=submitted` (atau tanpa filter) → riwayat untuk **anti-duplikat** agent sebelum melamar.
  - Balas `{"data":{"applications":[{id,portal,job_title,job_url,company,status,applied_at}],...}}`.
- **`PATCH /v1/agent/applications/status`** (internal) — **UPDATE status** baris yang sudah ada.
  - **Payload (WAJIB): `{"id":"<applications.id — uuid>","status":"submitted"|"duplicate"|"failed"}`**
  - (Alternatif opsional bila `id` tidak tersedia: `{"user_id":"<uuid>","job_url":"https://...","status":"..."}` — jangan campur dengan `id`.)
  - **Validasi status**: HANYA `submitted`, `failed`, `skipped`, `duplicate` yang diterima (nilai lain — termasuk `queued` — → **400/422** `invalid_status`; antrian diisi lewat POST, tidak bisa di-set via PATCH).
  - Cocok → update `status` (+ `applied_at` = now saat `submitted`) → **200** `{"updated":true,"id":"<uuid>"}`; tidak ada → **404**.
  - Saat status berubah jadi `submitted` via PATCH: jalankan cek kuota yang SAMA seperti POST submitted (transaksi; 403 kalau penuh, TANPA update) — jangan sampai PATCH bisa bypass kuota.
- Kuota — **dua jalur akses**:
  - **FE** → `GET /api/v1/quota` (auth FE→BE), balas `{"used_today","limit_per_day","remaining_today","window_days","total_used","total_limit"}`.
  - **AGEN Hermes** → `GET /v1/agent/quota?user_id=` (`X-Internal-Token`; dipanggil agent SEBELUM tiap lamaran untuk cek sisa kuota).
- `GET /api/v1/applications` (FE, auth FE→BE) → list lamaran user (portal, job_title, job_url, status, applied_at). **AGEN** memakai `GET /v1/agent/applications?user_id=` (`X-Internal-Token`) untuk anti-duplikat sebelum melamar/meng-queue.
- Chat TIDAK diblokir kuota — kuota hanya untuk status `submitted`.

## 9. Status akun portal (Connect Account)
User login ke job portal SENDIRI di browser mesin Hermes (via noVNC) — kredensial tidak pernah diberikan ke agent. Agent memverifikasi sesi lalu melaporkan status ke BE agar FE bisa menampilkan status koneksi ("JobStreet: terhubung ✓").

- Tabel `portal_accounts`: `user_id, portal, status, display_name, connected_at, verified_at, updated_at`. Primary key `(user_id, portal)`, **upsert**.
- Tabel **`portals`** (registry portal — sumber kebenaran daftar portal, dikelola admin):
  `id, slug UNIQUE, name, domain, url, marker_type ('name'|'name_contains_value'), marker_name, marker_value, enabled, sort_order, created_at, updated_at`.
  Seed awal: jobstreet, linkedin, indeed, glints, kalibrr, techinasia — marker yang belum diketahui diisi `marker_name=''` (mesin otomatis skip portal itu).
- **`GET /v1/agent/portals`** (internal, `X-Internal-Token`): kembalikan portal `enabled=true` (urut `sort_order`):
  `{"portals":[{"slug":"jobstreet","name":"JobStreet","url":"https://id.jobstreet.com/","marker_type":"name_contains_value","marker_name":"is.authenticated","marker_value":"true"}]}`
  — dipakai mesin (`portal-check.py`) untuk deteksi sesi otomatis. **Menambah portal baru = INSERT di tabel ini (tanpa deploy kode mesin).**
- (Disarankan) Admin CRUD portal: `GET /api/v1/admin/portals`, `POST /api/v1/admin/portals`, `PUT /api/v1/admin/portals/{slug}`, `DELETE /api/v1/admin/portals/{slug}` (auth admin) — agar staf non-teknis bisa kelola via panel.
- **`POST /v1/agent/portal-status`** (internal, dipanggil AGEN Hermes): auth `X-Internal-Token` (constant-time compare).
  - Body: `{"user_id":"<user_id mesin>","portal":"jobstreet","status":"connected","display_name":"ena***@yahoo.com","connected_at":"<ISO>","verified_at":"<ISO>"}` (field opsional: `display_name`, `connected_at`, `verified_at`, `note`).
  - `status` valid: `connected | disconnected | connecting | expired` → selain itu **422**.
  - Upsert `(user_id, portal)`. Balas `201 {"accepted":true}` (update → `200`).
- **`GET /api/v1/portal-status`** (FE, auth FE→BE): balas list status semua portal user (**JOIN `portals`** untuk `name`):
  ```json
  [{"portal":"jobstreet","status":"connected","display_name":"ena***@yahoo.com","verified_at":"..."}]
  ```
- Catatan: `display_name` sudah di-mask oleh agent (`ena***@yahoo.com`); BE tidak perlu memproses.

## 10. Profil user & daily report (internal, dipanggil agen mesin)

> **Konvensi respons produksi:** semua respons sukses dibungkus `{"data": {...}}`; error `{"error": {...}}` dengan `code`. Tooling mesin sudah menangani keduanya (unwrap `data`). Profil produksi juga menyertakan `levels[]`, `province`, dan `photo_url` (di luar kontrak minimum) — agent boleh memakainya.

- **`GET /v1/agent/user-profile`** (internal, `X-Internal-Token` + `?user_id=`): kembalikan preferensi + **`selected_portal`** (whitelist portal yang diizinkan untuk proses lamaran user ini):
  ```json
  {"profile": {"level":"staff","city":"Jakarta","keywords":["software engineer"],
               "salary_min":null,"work_type":null,"exclude_companies":[]},
   "selected_portal": ["jobstreet","linkedin"],
   "photo_url": "...", "cv_url": "..."}
  ```
  - **`selected_portal`** = daftar slug portal yang BOLEH dibantu proses lamarannya (agent menolak bantuan di luar daftar ini). Kosong → tidak ada portal yang diizinkan. **`priority_jobs` DIHAPUS** dari kontrak — antrian loker murni dari tabel `applications` (status `queued`).
- **`POST /v1/agent/daily-report`** (internal, `X-Internal-Token`): laporan akhir run harian agen:
  ```json
  {"user_id":"...","date":"2026-08-13","applied":4,"failed":1,"quota_left":1,
   "portals_ok":["jobstreet"],"portals_expired":["linkedin"],
   "no_match":false}
  ```
  - BE simpan (tabel `daily_reports`) untuk ditampilkan ke FE. (Field `priority_processed` sudah dihapus — tidak dikirim lagi.)
  - Setelah tersimpan, **publish event `daily_report`** ke channel user (payload = isi laporan) agar FE tampil real-time.
- **`GET /api/v1/daily-reports`** (FE, auth FE→BE): list daily report user, terbaru duluan (untuk kartu ringkasan & histori).

### 10.1 Scheduler daily run (BE) — pemicu harian (MENGANTI daemon mesin)

Daily run di-trigger dari BE (bukan cron/daemon di mesin — `jobseeker-scheduler.py` & `run-daily.sh` di mesin TIDAK dipakai lagi). Alur:

1. **Jadwal**: kolom `user_service.next_run_at TIMESTAMPTZ` (nullable). Saat entri dibuat/diaktifkan → isi = waktu acak **besok 08:00–16:00 WIB** (`Asia/Jakarta`).
2. **Ticker**: goroutine BE tiap 1 menit → cari user dengan `next_run_at <= now` → trigger → set `next_run_at` = waktu acak besok (08–16 WIB) → lanjut.
3. **Trigger via chat proxy**: jalankan alur yang SAMA dengan `POST /api/v1/chat` (internal), `session_id` = **sesi chat user** (FE ikut melihat), pesan:
   `"Jalankan DAILY RUN jobseeker (ikuti skill jobseeker-agent): 1) baca /opt/data/jobseeker/user-profile.json; 2) cek kuota via check-quota.py (baca sisa); 3) verifikasi akun portal via accounts.py (lewati yang expired, tandai expired); 4) PICK dari antrian queued sebanyak SISA kuota via report-application.py pick --limit <sisa> (antrian diisi BE — jangan insert); 5) filter: portal expired → skip, url sudah submitted → update duplicate; 6) lamar tiap loker (satu tab), pretest dijawab dari data CV user; 7) tiap selesai → report-application.py update --status submitted/failed/duplicate (by id atau url+user_id); 8) akhiri dengan POST /v1/agent/daily-report (curl, baca agent-config.env). Jika kuota habis / antrian kosong / portal expired, TETAP kirim daily-report yang jujur."`
   → karena lewat chat proxy, SEMUA aktivitas agent ter-stream ke FE (`agent.status`/`agent.activity`/`agent.text_delta`) — user melihat proses live di chat.
4. **Retry**: call Hermes gagal (mesin mati/tunnel down) → retry tiap 5 menit maks 3× dalam window; masih gagal → skip hari ini (log + jadwal ulang besok).
5. **Konflik turn**: jika user chat saat daily run berjalan, pesan user diproses setelah turn selesai (Hermes: satu turn aktif per sesi).
- Catatan: semua payload memakai `user_id` dari body/query (bukan token).

## 11. Keamanan

- API key Hermes & token Centrifugo hanya di sisi server; jangan pernah di respons/log/header yang sampai FE.
- Token realtime: `sub` dari sesi auth; verifikasi `X-Internal-Token` constant-time; channel selalu `user:<user_id>` milik user sendiri.
- Sanitasi nama file; proteksi path traversal; jangan log isi pesan/body aplikasi (data pribadi).
- Rate limit wajar `POST /api/v1/chat` per user.

## 12. Kriteria penerimaan

1. Centrifugo standalone jalan; namespace `user` dengan history + presence; `curl /health` ok.
2. `GET /api/v1/realtime/token` → JWT valid; FE bisa connect & subscribe `user:<id>` (verifikasi manual pakai centrifuge-js atau contoh client).
3. `POST /api/v1/chat` → 202 cepat (tanpa menunggu agent); urutan event di channel: `agent.status(working)` → `agent.activity`/`agent.text_delta` → `agent.text_done` → (`agent.ask_user`+`agent.status(waiting_user)`) atau (`agent.status(idle)`) → `agent.finished`.
4. Marker `ask_user` ter-parse: `text_done` bersih tanpa marker; `agent.ask_user` berisi question/choices yang benar.
5. Chat kedua saat turn masih jalan → 429 `agent_busy`; setelah `finished` → bisa chat lagi.
6. Client disconnect saat agent bekerja → turn tetap selesai, event tetap ter-publish (history Centrifugo menyimpannya; replay dari history utuh).
7. `POST /api/v1/chat/stop` membatalkan turn + publish idle/finished.
8. Upload PDF/JPG sukses & URL bisa diakses; `.exe` & >10MB ditolak; `../` di `GET /files` → 404.
9. Kuota: lamaran ke-11 dalam sehari → 403 tanpa insert; `quota.updated` ter-publish saat lamaran sukses; `GET /quota` & `GET /applications` benar.
10. `POST /applications` tanpa `X-Internal-Token` benar → 401.
11. Tidak ada goroutine bocor (turn selesai/timed-out/stop → lock lepas; tes berulang tanpa error).
12. `POST /v1/agent/portal-status` upsert (201/200); `status` invalid → 422; tanpa `X-Internal-Token` → 401; `GET /api/v1/portal-status` mengembalikan list per user.
13. `GET /api/v1/realtime/vnc` → URL berisi `?token=<jwt>` valid (koneksi WS diterima, 101); token kedaluwarsa/rusak/tanpa token → koneksi ditolak mesin; request user A tidak pernah mengembalikan URL mesin user B.
14. Kuota memakai `user_services.daily_quota`/`max_quota` per user (fallback 5/150 saat kolom kosong); dua user dengan nilai berbeda di-respect.
15. `GET /v1/agent/user-profile` → preferensi + `selected_portal` sesuai user (di luar daftar → agent tolak); `POST /v1/agent/daily-report` tersimpan.
16. `POST /v1/agent/daily-report` → event `daily_report` ter-publish ke channel user; `GET /api/v1/daily-reports` mengembalikan histori (terbaru duluan).
17. Scope: `POST /api/v1/chat` SELALU menyertakan `role:"system"` pembatas scope; pertanyaan luar topik (mis. "buat script python") → agent MENOLAK; pertanyaan jobseeker → dilayani.
18. `GET /v1/agent/portals` → hanya `enabled=true`, urut `sort_order`; portal baru yang di-INSERT langsung muncul (mesin fetch tiap run, tanpa deploy); `GET /api/v1/portal-status` menyertakan `name` dari tabel `portals`.
19. **Scheduler BE**: `next_run_at` terisi acak 08:00–16:00 WIB; ticker trigger tepat waktu; daily run tampil LIVE di chat FE (event `agent.*` ter-publish); gagal 3× → skip + jadwal ulang besok; `next_run_at` ter-update setelah trigger.

## 13. Batasan

- JANGAN memodifikasi apa pun di sisi Hermes (mesin agent).
- JANGAN hardcode API key/token di kode — env/config per user.
- JANGAN kirim SSE mentah ke FE — semua lewat event Centrifugo (FE tidak boleh perlu parser SSE).
- JANGAN blokir chat biasa karena kuota — kuota hanya untuk `POST /v1/agent/applications`.
- JANGAN tambah dependency berat; gunakan stdlib + library yang sudah ada di codebase (klien HTTP untuk publish Centrifugo cukup stdlib).
