diff --git a/src/core/service-snapshot-summary.mjs b/src/core/service-snapshot-summary.mjs new file mode 100644 index 0000000..c297fee --- /dev/null +++ b/src/core/service-snapshot-summary.mjs @@ -0,0 +1,150 @@ +const COMMON_STATE_FIELDS = [ + 'paused', + 'armed', + 'last_error', + 'last_event_at', + 'last_runtime_eval_at', + 'last_published_at', + 'last_publish_error', + 'publish_count', + 'last_sync_at', + 'last_write_at', + 'last_metrics_at', + 'last_quote_outcomes_at', + 'database_connectivity', + 'source_error_count', + 'last_source_error_at', + 'last_bootstrap_at', +]; + +export function summarizeServiceSnapshotForSentinel(snapshot = {}) { + return { + service: snapshot.service, + label: snapshot.label, + base_url: snapshot.base_url, + reachable: snapshot.reachable, + state: summarizeServiceState(snapshot.service, snapshot.state), + health: snapshot.health || null, + error: snapshot.error || null, + }; +} + +export function summarizeServiceState(service, state) { + if (!state || typeof state !== 'object') return state || null; + + switch (service) { + case 'near-intents-ingest': + return pickFields(state, ['service', 'namespace', 'pair_filter', 'ingest']); + case 'market-reference-ingest': + return pickFields(state, [ + 'service', + 'namespace', + 'paused', + 'kraken', + 'coingecko', + 'last_published_at', + 'last_publish_error', + 'publish_count', + 'error_count', + 'active_pair', + ]); + case 'inventory-sync': + return { + ...pickFields(state, ['service', 'namespace', 'account_id', 'paused', 'last_sync_at', 'last_error', 'publish_count']), + last_snapshot: summarizeInventorySnapshot(state.last_snapshot), + }; + case 'liquidity-manager': + return pickFields(state, [ + 'service', + 'namespace', + 'account_id', + 'paused', + 'funding_observer_paused', + 'withdrawals_frozen', + 'deposit_addresses', + 'withdrawal_defaults', + 'latest_funding_observation_at', + 'last_refresh_at', + 'last_error', + ]); + case 'history-writer': + return pickFields(state, [ + 'service', + 'namespace', + 'database_connectivity', + 'last_write_at', + 'last_alert_write_at', + 'last_funding_observation_write_at', + 'last_environment_status_write_at', + 'last_environment_status_seen_at', + 'last_environment_status_duplicate_at', + 'last_metrics_at', + 'last_quote_outcomes_at', + 'latest_portfolio_metrics', + 'latest_quote_outcomes', + 'offsets', + 'metrics_error', + 'quote_outcomes_error', + ]); + case 'strategy-engine': + return pickFields(state, [ + 'service', + 'namespace', + 'paused', + 'armed', + 'last_error', + 'latest_decision', + 'latest_inventory_event', + 'decision_count', + 'command_count', + 'reject_count', + 'block_count', + ]); + case 'trade-executor': + return pickFields(state, [ + 'service', + 'namespace', + 'paused', + 'armed', + 'last_error', + 'relay', + 'last_result', + 'last_quote_status', + 'submitted_count', + 'failed_count', + 'blocked_count', + ]); + case 'operator-dashboard': + return pickFields(state, [ + 'service', + 'namespace', + 'source_error_count', + 'last_source_error_at', + 'last_bootstrap_at', + 'source_errors', + ]); + default: + return pickFields(state, COMMON_STATE_FIELDS); + } +} + +function summarizeInventorySnapshot(snapshot) { + if (!snapshot || typeof snapshot !== 'object') return snapshot || null; + return pickFields(snapshot, [ + 'inventory_id', + 'account_id', + 'synced_at', + 'spendable', + 'pending_inbound', + 'pending_outbound', + 'reconciliation_status', + ]); +} + +function pickFields(source, fields) { + const picked = {}; + for (const field of fields) { + if (source[field] !== undefined) picked[field] = source[field]; + } + return picked; +} diff --git a/test/ops-sentinel-static.test.mjs b/test/ops-sentinel-static.test.mjs index 328fe2f..1121b7f 100644 --- a/test/ops-sentinel-static.test.mjs +++ b/test/ops-sentinel-static.test.mjs @@ -20,3 +20,12 @@ test('ops sentinel polls official NEAR status and publishes environment status w assert.match(source, /normalized\.status_fingerprint === state\.last_environment_status_fingerprint/); assert.match(source, /assertEnvironmentStatusEvent/); }); + +test('ops sentinel exposes trimmed service snapshots and computed runtime alerts', () => { + assert.match(source, /summarizeServiceSnapshotForSentinel/); + assert.match(source, /serviceSnapshots\.map\(summarizeServiceSnapshotForSentinel\)/); + assert.match(source, /latest_runtime_alerts: state\.latest_runtime_alerts/); + assert.match(source, /active_alerts: state\.latest_runtime_alerts/); + assert.match(source, /activeAlerts: desiredRuntimeAlerts/); + assert.match(source, /state\.latest_runtime_alerts = desiredRuntimeAlerts/); +}); diff --git a/test/service-snapshot-summary.test.mjs b/test/service-snapshot-summary.test.mjs new file mode 100644 index 0000000..32d43db --- /dev/null +++ b/test/service-snapshot-summary.test.mjs @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + summarizeServiceSnapshotForSentinel, + summarizeServiceState, +} from '../src/core/service-snapshot-summary.mjs'; + +test('sentinel snapshot summary keeps trade-executor health fields and drops retained command maps', () => { + const summary = summarizeServiceSnapshotForSentinel({ + service: 'trade-executor', + label: 'Trade Executor', + base_url: 'http://trade-executor', + reachable: true, + health: { ok: true }, + state: { + service: 'trade-executor', + namespace: 'unrip', + paused: false, + armed: true, + relay: { connected: true, last_message_at: '2026-05-06T15:00:00.000Z' }, + last_result: { status: 'submitted' }, + submitted_count: 100, + processed_idempotency_keys: Object.fromEntries(Array.from({ length: 100 }, (_, index) => [`quote:${index}`, true])), + submitted_commands: { + 'cmd-1': { quote_id: 'quote-1', raw_response: { large: true } }, + }, + }, + }); + + assert.equal(summary.state.armed, true); + assert.deepEqual(summary.state.relay, { connected: true, last_message_at: '2026-05-06T15:00:00.000Z' }); + assert.equal(summary.state.processed_idempotency_keys, undefined); + assert.equal(summary.state.submitted_commands, undefined); +}); + +test('sentinel snapshot summary keeps near-intents ingest truth fields', () => { + const state = summarizeServiceState('near-intents-ingest', { + service: 'near-intents-ingest', + namespace: 'unrip', + pair_filter: { pair: 'btc->eure' }, + ingest: { + connected: true, + last_message_at: '2026-05-06T15:00:00.000Z', + last_matching_quote_at: '2026-05-06T09:56:42.742Z', + last_published_at: '2026-05-06T09:56:42.744Z', + filtered_count: 10, + }, + bulky_unused_field: Object.fromEntries(Array.from({ length: 100 }, (_, index) => [index, index])), + }); + + assert.equal(state.ingest.connected, true); + assert.equal(state.ingest.last_matching_quote_at, '2026-05-06T09:56:42.742Z'); + assert.equal(state.bulky_unused_field, undefined); +});