Skip to content

10.9 Approval Pengeluaran

Topologi: B — Agent + Workflow Materi yang dipakai: 4.4, 4.5, 2.3Prasyarat: 10.8

Masalahnya

Karyawan mengajukan pengeluaran lewat chat. Aturannya:

  • Di bawah Rp1.000.000 → cair otomatis
  • Di atas itu → harus disetujui manajer

Persetujuan manajer tidak datang dalam hitungan detik. Ia mengklik tautan di email, mungkin tiga hari kemudian, dari perangkat lain. Di antaranya server sudah di-deploy dua kali.

Ini kebutuhan yang tidak bisa diselesaikan dengan segala hal di delapan kasus sebelumnya. Fungsi biasa tidak bisa tidur tiga hari. Menyimpan status di memori proses hilang saat restart.

Keputusan arsitektur

Keputusan 1 — Tool approval atau workflow suspend?

Kamu punya dua mekanisme untuk "berhenti menunggu manusia". Keduanya sudah dibahas — di 2.3 dan 4.5 — dan memilihnya salah adalah kesalahan desain yang mahal.

requireApproval pada toolsuspend() pada workflow
Yang berhentiSatu pemanggilan toolSatu langkah workflow
Data yang bisa dikirim balikSetuju / tolak sajaObjek apa pun sesuai resumeSchema
MelanjutkanapproveToolCall()resume() dengan resumeData
Umur tunggu wajarMenitHari
Cocok untukMencegah tindakan berisikoAlur persetujuan bertahap

Pilihan: workflow suspend().

Alasan yang menentukan ada di baris kedua. Manajer tidak hanya menekan "setuju" — ia juga menulis catatan ("OK untuk tim engineering") dan identitasnya harus tercatat untuk audit.

requireApproval hanya bisa mengembalikan keputusan biner. resumeSchema bisa membawa { disetujui, catatan, olehSiapa }.

Keputusan 2 — Ambang batas: di agent atau di workflow?

Agent memutuskanWorkflow memutuskan
KonsistensiBisa bervariasiSelalu sama
Bisa diauditSulitYa, di kode
Bisa diujiButuh scorerAssertion biasa
Kalau salahUang cair tanpa persetujuan

Pilihan: workflow.

Ini bukan keputusan yang bisa diperdebatkan. Ambang Rp1.000.000 adalah kebijakan perusahaan, dan kebijakan tidak boleh bervariasi antar pemanggilan. Satu kesalahan berarti uang keluar tanpa persetujuan.

Aturan dari pengantar bagian ini berlaku penuh di sini: kalau salah langkah berarti kerugian uang, itu workflow.

Keputusan 3 — Di mana runId disimpan?

Ini keputusan yang paling sering dilupakan, dan tanpanya seluruh mekanisme tidak berguna.

Ingat dari 4.5: melanjutkan run membutuhkan runId. Kalau ia hanya tercetak di log, workflow yang tertunda tidak bisa ditemukan lagi.

Tempat menyimpanKelebihanKekurangan
Log sajaNol kerja❌ Praktis hilang
Baris database pengajuanBisa dicari, bisa ditampilkan di daftarPerlu tabel
Di dalam tautan emailManajer langsung mendarat di run yang benarTautan bisa diteruskan orang lain

Pilihan: keduanya — disimpan di baris pengajuan, dan disisipkan ke tautan email sebagai token yang ditandatangani.

Struktur direktori

text
approval-pengeluaran/
├── src/
│   ├── domain/
│   │   ├── kebijakan.ts         ← AMBANG dan aturannya
│   │   └── pengajuan.ts         ← repo: simpan runId
│   ├── infra/
│   │   └── email.ts             ← kirim tautan persetujuan
│   ├── mastra/
│   │   ├── agents/
│   │   │   └── keuangan-agent.ts
│   │   ├── workflows/
│   │   │   └── approval-workflow.ts
│   │   ├── tools/
│   │   │   └── ajukan-pengeluaran.ts
│   │   └── index.ts
│   └── server/
│       ├── webhook.ts           ← chat masuk
│       └── rute-approval.ts     ← BARU: manajer klik tautan
└── package.json

server/rute-approval.ts adalah berkas yang membuat kasus ini berbeda: ada dua pintu masuk ke sistem yang sama — chat dari karyawan, dan klik dari manajer.

Alur

Garis Note over itu adalah inti kasus ini. Di antara suspend dan resume, tidak ada apa pun yang perlu tetap hidup kecuali storage.

Implementasi

src/domain/kebijakan.ts

ts
export const AMBANG_APPROVAL = 1_000_000

export function perluPersetujuan(jumlah: number): boolean {
  return jumlah > AMBANG_APPROVAL
}

Dua baris, berkas sendiri. Terlihat berlebihan — tapi ini satu-satunya tempat angka kebijakan itu tertulis, dan ia bisa diuji tanpa menyentuh apa pun.

src/mastra/workflows/approval-workflow.ts

ts
import { createWorkflow, createStep } from '@mastra/core/workflows'
import { z } from 'zod'
import { perluPersetujuan } from '../../domain/kebijakan.ts'

// --- 1. Klasifikasi: perlu approval atau tidak ---
const klasifikasi = createStep({
  id: 'klasifikasi',
  inputSchema: z.object({
    pemohon: z.string(),
    jumlah: z.number(),
    keperluan: z.string(),
  }),
  outputSchema: z.object({
    pemohon: z.string(),
    jumlah: z.number(),
    perluApproval: z.boolean(),
  }),
  stateSchema: z.object({ jejak: z.array(z.string()) }),
  execute: async ({ inputData, state, setState }) => {
    const perlu = perluPersetujuan(inputData.jumlah)
    await setState({ jejak: [...state.jejak, `klasifikasi:${perlu}`] })
    return { pemohon: inputData.pemohon, jumlah: inputData.jumlah, perluApproval: perlu }
  },
})

// --- 2. Persetujuan: BISA berhenti di sini ---
const persetujuan = createStep({
  id: 'persetujuan',
  inputSchema: z.object({
    pemohon: z.string(),
    jumlah: z.number(),
    perluApproval: z.boolean(),
  }),
  outputSchema: z.object({
    pemohon: z.string(),
    jumlah: z.number(),
    disetujui: z.boolean(),
    catatan: z.string(),
  }),
  resumeSchema: z.object({
    disetujui: z.boolean(),
    catatan: z.string(),
    olehSiapa: z.string(),
  }),
  stateSchema: z.object({ jejak: z.array(z.string()) }),
  execute: async ({ inputData, resumeData, suspend, state, setState }) => {
    // Jalur cepat: di bawah ambang, tidak perlu berhenti.
    if (!inputData.perluApproval) {
      await setState({ jejak: [...state.jejak, 'persetujuan:otomatis'] })
      return {
        pemohon: inputData.pemohon,
        jumlah: inputData.jumlah,
        disetujui: true,
        catatan: 'otomatis',
      }
    }

    // Pola "cek dulu, baru suspend" dari 4.5.
    if (!resumeData) {
      return await suspend({})
    }

    await setState({ jejak: [...state.jejak, `persetujuan:${resumeData.olehSiapa}`] })
    return {
      pemohon: inputData.pemohon,
      jumlah: inputData.jumlah,
      disetujui: resumeData.disetujui,
      catatan: resumeData.catatan,
    }
  },
})

// --- 3. Eksekusi ---
const eksekusi = createStep({
  id: 'eksekusi',
  inputSchema: z.object({
    pemohon: z.string(),
    jumlah: z.number(),
    disetujui: z.boolean(),
    catatan: z.string(),
  }),
  outputSchema: z.object({ hasil: z.string(), jejak: z.array(z.string()) }),
  stateSchema: z.object({ jejak: z.array(z.string()) }),
  execute: async ({ inputData, state, setState }) => {
    const hasil = inputData.disetujui ? 'dicairkan' : 'ditolak'
    const jejak = [...state.jejak, `eksekusi:${hasil}`]
    await setState({ jejak })
    return { hasil, jejak }
  },
})

export const approvalWorkflow = createWorkflow({
  id: 'approval',
  inputSchema: z.object({
    pemohon: z.string(),
    jumlah: z.number(),
    keperluan: z.string(),
  }),
  outputSchema: z.object({ hasil: z.string(), jejak: z.array(z.string()) }),
  stateSchema: z.object({ jejak: z.array(z.string()) }),
})
  .then(klasifikasi)
  .then(persetujuan)
  .then(eksekusi)
  .commit()

Langkah persetujuan punya tiga jalur dalam satu execute, dan itu pola yang perlu dikenali:

text
inputData.perluApproval = false  →  langsung selesai (tidak pernah berhenti)
resumeData = undefined           →  suspend()        (eksekusi PERTAMA)
resumeData = { ... }             →  lanjut & selesai (eksekusi KEDUA)

Ingat dari 4.5: execute dijalankan dua kali untuk kasus yang berhenti. Itu bukan bug.

src/mastra/tools/ajukan-pengeluaran.ts

ts
import { createTool } from '@mastra/core/tools'
import { z } from 'zod'
import { simpanPengajuan } from '../../domain/pengajuan.ts'
import { kirimTautanPersetujuan } from '../../infra/email.ts'

export const ajukanPengeluaran = createTool({
  id: 'ajukan-pengeluaran',
  description:
    'Ajukan permintaan pengeluaran dana. Panggil setelah jumlah dan keperluan ' +
    'jelas. Pengajuan di atas ambang akan menunggu persetujuan manajer.',
  inputSchema: z.object({
    jumlah: z.number().describe('Jumlah dalam rupiah'),
    keperluan: z.string().describe('Keperluan pengeluaran, singkat'),
  }),
  outputSchema: z.object({
    status: z.enum(['dicairkan', 'menunggu-persetujuan', 'gagal']),
    idPengajuan: z.string().nullable(),
  }),
  execute: async ({ jumlah, keperluan }, { requestContext, mastra }) => {
    // Identitas dari sesi, BUKAN dari yang diketik pengguna.
    const pemohon = requestContext.get('userId') as string

    const wf = mastra.getWorkflow('approvalWorkflow')
    const run = await wf.createRun()

    const r = await run.start({
      inputData: { pemohon, jumlah, keperluan },
      initialState: { jejak: [] },
    })

    if (r.status === 'suspended') {
      // KRUSIAL: runId harus disimpan, kalau tidak run ini hilang selamanya.
      const id = await simpanPengajuan({ pemohon, jumlah, keperluan, runId: run.runId })
      await kirimTautanPersetujuan({ idPengajuan: id, runId: run.runId, pemohon, jumlah, keperluan })
      return { status: 'menunggu-persetujuan' as const, idPengajuan: id }
    }

    if (r.status === 'success') {
      return { status: 'dicairkan' as const, idPengajuan: null }
    }

    return { status: 'gagal' as const, idPengajuan: null }
  },
})

Dua hal yang menentukan keamanan dan kebenaran di sini.

pemohon diambil dari requestContext. Kalau ia masuk lewat inputSchema, model bisa mengisinya dengan nama siapa pun — dan karyawan mengajukan pengeluaran atas nama orang lain.

run.runId disimpan sebelum tool kembali. Ini satu baris yang, kalau terlupa, membuat seluruh mekanisme suspend/resume tidak berguna.

src/server/rute-approval.ts

ts
import { mastra } from '../mastra/index.ts'
import { ambilPengajuan } from '../domain/pengajuan.ts'

export async function handleApproval(params: {
  idPengajuan: string
  disetujui: boolean
  catatan: string
  olehSiapa: string
}): Promise<{ ok: boolean; pesan: string }> {
  const pengajuan = await ambilPengajuan(params.idPengajuan)
  if (!pengajuan) return { ok: false, pesan: 'Pengajuan tidak ditemukan' }
  if (pengajuan.selesai) return { ok: false, pesan: 'Pengajuan ini sudah diputuskan' }

  const wf = mastra.getWorkflow('approvalWorkflow')

  // Mengambil KEMBALI run yang sudah ada, bukan membuat baru.
  const run = await wf.createRun({ runId: pengajuan.runId })

  const r = await run.resume({
    resumeData: {
      disetujui: params.disetujui,
      catatan: params.catatan,
      olehSiapa: params.olehSiapa,
    },
  })

  if (r.status !== 'success') {
    return { ok: false, pesan: 'Gagal melanjutkan proses' }
  }

  return { ok: true, pesan: `Persetujuan tercatat: ${r.result.hasil}` }
}

Pemeriksaan pengajuan.selesai mencegah persetujuan ganda — manajer yang mengklik tautan dua kali, atau tautan yang diteruskan ke orang lain.

Hasil nyata

Ketiga skenario dijalankan sungguhan dengan storage libSQL.

A. Di bawah ambang — tidak pernah berhenti:

text
   [klasifikasi] Rp500.000 -> perluApproval=false
   [persetujuan] di bawah ambang, lewat
   [eksekusi] dicairkan
   STATUS: success
   {"hasil":"dicairkan","jejak":["klasifikasi:false","persetujuan:otomatis","eksekusi:dicairkan"]}

B. Di atas ambang — berhenti:

text
   [klasifikasi] Rp5.000.000 -> perluApproval=true
   [persetujuan] MENUNGGU MANUSIA -> suspend()
   STATUS: suspended
   runId : 75e4366a-581f-439d-99fb-1d4150939a8f

Perhatikan [eksekusi] tidak pernah tercetak. Langkah ketiga tidak berjalan — uang tidak keluar.

C. Dilanjutkan dari instance run yang benar-benar baru:

text
   [persetujuan] dilanjutkan oleh manajer-it: SETUJU
   [eksekusi] dicairkan
   STATUS: success
   {"hasil":"dicairkan","jejak":["klasifikasi:true","persetujuan:manajer-it","eksekusi:dicairkan"]}

Ini bagian yang paling penting diperhatikan. Lihat isi jejak:

text
["klasifikasi:true", "persetujuan:manajer-it", "eksekusi:dicairkan"]
  ↑ ditulis SEBELUM suspend    ↑ ditulis SESUDAH resume

Entri pertama ditulis pada eksekusi yang berbeda, sebelum workflow berhenti. Ia masih utuh setelah dilanjutkan lewat createRun({ runId }) — yang membuat objek run baru, bukan meneruskan objek lama.

Workflow state bertahan melewati siklus suspend dan resume, persis seperti yang dijanjikan 4.3.

Perhatikan juga [persetujuan] tercetak dua kali di seluruh transkrip — sekali dengan "MENUNGGU MANUSIA", sekali dengan "dilanjutkan oleh". Itu execute yang sama dijalankan dua kali.

Best practice yang dipakai di sini

1. Ambang kebijakan di domain/, satu tempat. Angka Rp1.000.000 tertulis sekali dan bisa diuji tanpa menjalankan apa pun.

2. runId disimpan di baris database, bukan hanya di log. Tanpa ini, mekanismenya tidak berguna.

3. Identitas pemohon dari requestContext. Bukan dari inputSchema yang bisa diisi model.

4. Penjaga persetujuan ganda. Periksa selesai sebelum resume().

5. resumeSchema membawa lebih dari boolean. catatan dan olehSiapa adalah kebutuhan audit, bukan kemewahan.

6. Jalur cepat dan jalur lambat dalam satu langkah. Pengajuan kecil tidak membayar biaya mekanisme suspend sama sekali.

Kapan pola ini salah

SituasiKenapa gagalKe mana
Butuh dua tingkat persetujuan berurutanSatu suspend() hanya satu titikDua langkah suspend berurutan
Persetujuan kedaluwarsa setelah 7 hariTidak ada mekanisme kedaluwarsa bawaansleepUntil atau job pembersih
Manajer per departemen berbedaSatu alur tidak cukup10.10 Multi-tenant
Perlu tahu berapa lama rata-rata persetujuanTidak terukur10.15 Platform produksi

Coba sendiri

Bangun alur persetujuan cuti dua tingkat: atasan langsung, lalu HR — tapi HR hanya diperlukan kalau cutinya lebih dari 5 hari.

Selesai kalau

  • [ ] Cuti 2 hari berhenti satu kali (hanya atasan)
  • [ ] Cuti 10 hari berhenti dua kali (atasan lalu HR)
  • [ ] runId disimpan, dan kamu bisa melanjutkan setelah mematikan proses Node sepenuhnya di antara dua langkah
  • [ ] jejak di akhir memuat keputusan kedua penyetuju beserta nama mereka
  • [ ] Penolakan di tingkat pertama tidak melanjutkan ke tingkat kedua
  • [ ] Melanjutkan run yang sudah selesai ditolak dengan pesan jelas

Kalau macet

Workflow langsung selesai tanpa berhenti? Kondisi jaganya sudah terpenuhi sejak eksekusi pertama. Pastikan resumeData masih undefined di awal — pola if (!resumeData) return await suspend({}).

Tidak bisa melanjutkan setelah proses dimatikan? Dua penyebab: storage tidak terkonfigurasi, atau kamu memakai createRun() tanpa runId sehingga membuat run baru alih-alih mengambil yang lama.

State hilang setelah resume? Periksa stateSchema dideklarasikan di workflow dan di langkah yang memakainya.

Lanjut ke mana

Sembilan kasus pertama semuanya melayani satu organisasi. Kasus berikutnya mengubah asumsi paling mendasar itu: agent yang sama melayani banyak perusahaan sekaligus, dengan data yang tidak boleh bercampur — 10.10 SaaS Multi-tenant.

Materi belajar mandiri. Bukan dokumentasi resmi Mastra.