diff --git a/backend/src/db/migrate.js b/backend/src/db/migrate.js index d2b5608..1cb047f 100644 --- a/backend/src/db/migrate.js +++ b/backend/src/db/migrate.js @@ -295,6 +295,18 @@ const migrate = async () => { ON CONFLICT (key) DO NOTHING ` + // Mitra reachability — replaces the implicit `ping_interval * 3` grace + // window with an operator-facing "max heartbeat age" knob. The companion + // heartbeat cadence lives in env (MITRA_HEARTBEAT_CADENCE_SECONDS, default + // 30s). Default 45s keeps the same effective grace as the old 15s ping × 3. + // `mitra_ping_interval_seconds` is left in place (vestigial) — no live code + // path reads it anymore; safe to drop after one release. + await sql` + INSERT INTO app_config (key, value) + VALUES ('mitra_stale_after_seconds', '{"value": 45}') + ON CONFLICT (key) DO NOTHING + ` + // --- Phase 3.2: Mitra Request Activity Log --- await sql` diff --git a/backend/src/routes/internal/config.routes.js b/backend/src/routes/internal/config.routes.js index 14d3fc3..5ccdcb5 100644 --- a/backend/src/routes/internal/config.routes.js +++ b/backend/src/routes/internal/config.routes.js @@ -9,7 +9,7 @@ import { getFreeTrialConfig, setFreeTrialConfig, getExtensionTimeoutConfig, setExtensionTimeoutConfig, getEarlyEndConfig, setEarlyEndConfig, - getMitraPingConfig, setMitraPingConfig, + getMitraPingConfig, setMitraPingConfig, getMitraHeartbeatCadenceSeconds, getSensitivityConfig, setSensitivityConfig, getPaymentSessionTimeoutMinutes, setPaymentSessionTimeoutMinutes, getReturningChatConfirmationTimeoutSeconds, setReturningChatConfirmationTimeoutSeconds, @@ -173,14 +173,23 @@ export const internalConfigRoutes = async (app) => { app.patch('/mitra-ping', { preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')], }, async (request, reply) => { - const { require_ping, ping_interval_seconds } = request.body ?? {} + const { require_ping, stale_after_seconds } = request.body ?? {} if (require_ping !== undefined && typeof require_ping !== 'boolean') { return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'require_ping must be a boolean' } }) } - if (ping_interval_seconds !== undefined && (typeof ping_interval_seconds !== 'number' || ping_interval_seconds < 5)) { - return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'ping_interval_seconds must be a number >= 5' } }) + if (stale_after_seconds !== undefined) { + const cadence = getMitraHeartbeatCadenceSeconds() + if (typeof stale_after_seconds !== 'number' || stale_after_seconds < cadence) { + return reply.code(422).send({ + success: false, + error: { + code: 'VALIDATION_ERROR', + message: `stale_after_seconds must be a number >= heartbeat cadence (${cadence}s)`, + }, + }) + } } - const config = await setMitraPingConfig({ require_ping, ping_interval_seconds }) + const config = await setMitraPingConfig({ require_ping, stale_after_seconds }) return reply.send({ success: true, data: config }) }) diff --git a/backend/src/services/config.service.js b/backend/src/services/config.service.js index 2b8e0d6..429152b 100644 --- a/backend/src/services/config.service.js +++ b/backend/src/services/config.service.js @@ -128,18 +128,38 @@ export const getEarlyEndConfig = async () => { } } -// --- Phase 3.1: Mitra Ping Config --- +// --- Mitra reachability config --- +// +// Two separate concerns, deliberately decoupled: +// - heartbeat_cadence_seconds: how often the mitra app sends a heartbeat. +// Fixed per backend deployment via the MITRA_HEARTBEAT_CADENCE_SECONDS +// env (default 30). The mitra app reads this from /api/mitra/status and +// uses it directly as its Timer.periodic interval. +// - stale_after_seconds: how long the backend tolerates silence before +// marking a mitra offline. DB-stored, CC-tunable. Must be >= the +// heartbeat cadence (CC PATCH validates this). +// +// `require_ping` stays as the master switch — when false, the auto-offline +// sweep is skipped entirely and mitras stay online forever once they toggle. + +export const getMitraHeartbeatCadenceSeconds = () => { + const raw = process.env.MITRA_HEARTBEAT_CADENCE_SECONDS + if (!raw || raw.trim() === '') return 30 + const parsed = Number.parseInt(raw, 10) + return Number.isFinite(parsed) && parsed >= 5 ? parsed : 30 +} export const getMitraPingConfig = async () => { const [requireRow] = await sql`SELECT value FROM app_config WHERE key = 'require_mitra_ping'` - const [intervalRow] = await sql`SELECT value FROM app_config WHERE key = 'mitra_ping_interval_seconds'` + const [staleRow] = await sql`SELECT value FROM app_config WHERE key = 'mitra_stale_after_seconds'` return { require_ping: requireRow?.value?.value ?? true, - ping_interval_seconds: intervalRow?.value?.value ?? 15, + stale_after_seconds: staleRow?.value?.value ?? 45, + heartbeat_cadence_seconds: getMitraHeartbeatCadenceSeconds(), } } -export const setMitraPingConfig = async ({ require_ping, ping_interval_seconds }) => { +export const setMitraPingConfig = async ({ require_ping, stale_after_seconds }) => { if (require_ping !== undefined) { await sql` INSERT INTO app_config (key, value, updated_at) @@ -147,10 +167,10 @@ export const setMitraPingConfig = async ({ require_ping, ping_interval_seconds } ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ` } - if (ping_interval_seconds !== undefined) { + if (stale_after_seconds !== undefined) { await sql` INSERT INTO app_config (key, value, updated_at) - VALUES ('mitra_ping_interval_seconds', ${sql.json({ value: ping_interval_seconds })}, NOW()) + VALUES ('mitra_stale_after_seconds', ${sql.json({ value: stale_after_seconds })}, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ` } diff --git a/backend/src/services/mitra-status.service.js b/backend/src/services/mitra-status.service.js index 6cd0376..558deda 100644 --- a/backend/src/services/mitra-status.service.js +++ b/backend/src/services/mitra-status.service.js @@ -96,7 +96,9 @@ export const getStatus = async (mitraId) => { return { ...status, require_ping: pingConfig.require_ping, - ping_interval_seconds: pingConfig.ping_interval_seconds, + // The app reads this to set its Timer.periodic interval. Backend-fixed + // (via env), not operator-tunable. + heartbeat_cadence_seconds: pingConfig.heartbeat_cadence_seconds, } } @@ -134,7 +136,12 @@ export const autoOfflineStaleMitras = async () => { // If ping is not required, skip the auto-offline sweep entirely if (!pingConfig.require_ping) return 0 - const staleSeconds = pingConfig.ping_interval_seconds * 3 + // stale_after_seconds is the operator-facing knob — what they set is what + // they get. No multiplier, no implicit "tolerate N missed heartbeats" + // contract baked in. The CC PATCH validates that the value is >= the env- + // driven heartbeat cadence so single missed pings can't flip a mitra + // offline. + const staleSeconds = pingConfig.stale_after_seconds const stale = await sql` UPDATE mitra_online_status SET is_online = false, last_offline_at = NOW(), updated_at = NOW() diff --git a/control_center/src/pages/settings/SettingsPage.jsx b/control_center/src/pages/settings/SettingsPage.jsx index 672b728..0797270 100644 --- a/control_center/src/pages/settings/SettingsPage.jsx +++ b/control_center/src/pages/settings/SettingsPage.jsx @@ -452,7 +452,7 @@ export default function SettingsPage() {

Mitra Online Status (Ping)

-

Konfigurasi apakah mitra harus mengirim ping (heartbeat) untuk tetap online.

+

Mitra dianggap online selama heartbeat terakhir berusia ≤ ambang batas. Cadence (frekuensi ping aplikasi) di-fix oleh server lewat env var.

diff --git a/mitra_app/lib/core/status/status_notifier.dart b/mitra_app/lib/core/status/status_notifier.dart index 4d5efc2..b5f65e5 100644 --- a/mitra_app/lib/core/status/status_notifier.dart +++ b/mitra_app/lib/core/status/status_notifier.dart @@ -31,7 +31,11 @@ class StatusErrorData extends OnlineStatusData { class OnlineStatus extends _$OnlineStatus { Timer? _heartbeatTimer; bool _requirePing = true; - int _pingIntervalSeconds = 15; + // Heartbeat cadence is backend-fixed (MITRA_HEARTBEAT_CADENCE_SECONDS env, + // default 30s). Surfaced via /api/mitra/status. The CC-tunable + // mitra_stale_after_seconds is the offline threshold and lives entirely + // server-side — the app doesn't care, it just pings on a steady cadence. + int _heartbeatCadenceSeconds = 30; @override OnlineStatusData build() => const StatusInitialData(); @@ -41,7 +45,7 @@ class OnlineStatus extends _$OnlineStatus { final response = await ref.read(apiClientProvider).get('/api/mitra/status'); final data = response['data'] as Map; _requirePing = data['require_ping'] as bool? ?? true; - _pingIntervalSeconds = data['ping_interval_seconds'] as int? ?? 15; + _heartbeatCadenceSeconds = data['heartbeat_cadence_seconds'] as int? ?? 30; state = StatusLoadedData(isOnline: data['is_online'] as bool); } catch (e) { state = const StatusLoadedData(isOnline: false); @@ -83,7 +87,7 @@ class OnlineStatus extends _$OnlineStatus { void _startHeartbeat() { _stopHeartbeat(); - _heartbeatTimer = Timer.periodic(Duration(seconds: _pingIntervalSeconds), (_) { + _heartbeatTimer = Timer.periodic(Duration(seconds: _heartbeatCadenceSeconds), (_) { _heartbeatTick(); }); }