Tampilan
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 tool | suspend() pada workflow | |
|---|---|---|
| Yang berhenti | Satu pemanggilan tool | Satu langkah workflow |
| Data yang bisa dikirim balik | Setuju / tolak saja | Objek apa pun sesuai resumeSchema |
| Melanjutkan | approveToolCall() | resume() dengan resumeData |
| Umur tunggu wajar | Menit | Hari |
| Cocok untuk | Mencegah tindakan berisiko | Alur 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 memutuskan | Workflow memutuskan | |
|---|---|---|
| Konsistensi | Bisa bervariasi | Selalu sama |
| Bisa diaudit | Sulit | Ya, di kode |
| Bisa diuji | Butuh scorer | Assertion biasa |
| Kalau salah | Uang 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 menyimpan | Kelebihan | Kekurangan |
|---|---|---|
| Log saja | Nol kerja | ❌ Praktis hilang |
| Baris database pengajuan | Bisa dicari, bisa ditampilkan di daftar | Perlu tabel |
| Di dalam tautan email | Manajer langsung mendarat di run yang benar | Tautan 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.jsonserver/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-1d4150939a8fPerhatikan [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 resumeEntri 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
| Situasi | Kenapa gagal | Ke mana |
|---|---|---|
| Butuh dua tingkat persetujuan berurutan | Satu suspend() hanya satu titik | Dua langkah suspend berurutan |
| Persetujuan kedaluwarsa setelah 7 hari | Tidak ada mekanisme kedaluwarsa bawaan | sleepUntil atau job pembersih |
| Manajer per departemen berbeda | Satu alur tidak cukup | 10.10 Multi-tenant |
| Perlu tahu berapa lama rata-rata persetujuan | Tidak terukur | 10.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)
- [ ]
runIddisimpan, dan kamu bisa melanjutkan setelah mematikan proses Node sepenuhnya di antara dua langkah - [ ]
jejakdi 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.