import { storeJson } from './fileModels/storeJson' import { i18n } from './i18n' import { sdk } from './sdk' import { APP_USER, BITCOIND_RPC_HOSTNAME, BITCOIND_RPC_PORT, dataDir, EXPLORER_BUILD_DIR, EXPLORER_BUILD_ID, EXPLORER_DIR, EXPLORER_MARKER, EXPLORER_NETWORK, EXPLORER_PORT, EXPLORER_TARBALL_URL, INDEXER_ACTIVATION_HEIGHT, INDEXER_BIN_DIR, INDEXER_BUILD_ID, INDEXER_DIR, INDEXER_FAST_SYNC_HEIGHT, INDEXER_GOOSE_BIN, INDEXER_MARKER, INDEXER_MEMPOOL_CHUNK_SIZE, INDEXER_SCHEMA_DIR, INDEXER_SYNC_BIN, INDEXER_TARBALL_URL, INDEXER_UPDATE_INTERVAL, pgDataDir, POSTGRES_DB, POSTGRES_PORT, POSTGRES_USER, SPACED_CHAIN, spacedRpcPort, uiPort, } from './utils' export const main = sdk.setupMain(async ({ effects }) => { console.info(i18n('Starting Spaces!')) const store = await storeJson.read().const(effects) if ( !store?.password || !store?.btcAuth || !store?.dbAuth || !store?.spacedAuth ) { // taskSetPassword + taskBtcAuth + taskSeedDb + taskSeedSpacedAuth all seed // these in init; if they aren't populated yet, init hasn't finished — let // StartOS restart us. throw new Error('Spaces store.json is not yet populated.') } const { password: APP_PASSWORD, btcAuth, dbAuth, spacedAuth } = store const enableExplorer = store.enableExplorer === true const spacedEnv = { SPACED_CHAIN, SPACED_DATA_DIR: dataDir, SPACED_RPC_BIND: '127.0.0.1', SPACED_RPC_PORT: String(spacedRpcPort), SPACED_RPC_URL: `http://127.0.0.1:${spacedRpcPort}`, SPACED_BLOCK_INDEX: 'true', SPACED_BITCOIN_RPC_URL: `http://${BITCOIND_RPC_HOSTNAME}:${BITCOIND_RPC_PORT}`, SPACED_BITCOIN_RPC_USER: btcAuth.username, SPACED_BITCOIN_RPC_PASSWORD: btcAuth.password, SPACED_RPC_USER: spacedAuth.username, SPACED_RPC_PASSWORD: spacedAuth.password, // legacy aliases for `bitcoin-cli` / shell helpers that read these names BTC_RPC_HOST: BITCOIND_RPC_HOSTNAME, BTC_RPC_PORT: String(BITCOIND_RPC_PORT), BTC_RPC_USER: btcAuth.username, BTC_RPC_PASSWORD: btcAuth.password, APP_USER, APP_PASSWORD, DB_URL: `postgres://${dbAuth.username}:${dbAuth.password}@127.0.0.1:${POSTGRES_PORT}/${dbAuth.database}`, } const mounts = sdk.Mounts.of().mountVolume({ volumeId: 'main', subpath: null, mountpoint: dataDir, readonly: false, }) const spacedSub = await sdk.SubContainer.of( effects, { imageId: 'spaces' }, mounts, 'spaced-sub', ) const termSub = await sdk.SubContainer.of( effects, { imageId: 'spaces' }, mounts, 'terminal-sub', ) const postgresSub = await sdk.SubContainer.of( effects, { imageId: 'postgres' }, mounts, 'postgres-sub', ) const postgresEnv = { POSTGRES_USER: dbAuth.username, POSTGRES_PASSWORD: dbAuth.password, POSTGRES_DB: dbAuth.database, PGDATA: pgDataDir, } const indexerSub = await sdk.SubContainer.of( effects, { imageId: 'indexer-go' }, mounts, 'indexer-sub', ) const explorerSub = await sdk.SubContainer.of( effects, { imageId: 'explorer-ui' }, mounts, 'explorer-sub', ) const explorerEnv = { DB_URL: `postgres://${dbAuth.username}:${dbAuth.password}@127.0.0.1:${POSTGRES_PORT}/${dbAuth.database}?sslmode=disable`, PUBLIC_BTC_NETWORK: EXPLORER_NETWORK, PORT: String(EXPLORER_PORT), HOME: '/root', } const postgresUri = `postgres://${dbAuth.username}:${dbAuth.password}@127.0.0.1:${POSTGRES_PORT}/${dbAuth.database}?sslmode=disable` const indexerEnv = { POSTGRES_URI: postgresUri, BITCOIN_NODE_URI: `http://${BITCOIND_RPC_HOSTNAME}:${BITCOIND_RPC_PORT}`, BITCOIN_NODE_USER: btcAuth.username, BITCOIN_NODE_PASSWORD: btcAuth.password, SPACES_NODE_URI: `http://127.0.0.1:${spacedRpcPort}`, RPC_USER: spacedAuth.username, RPC_PASSWORD: spacedAuth.password, ACTIVATION_BLOCK_HEIGHT: INDEXER_ACTIVATION_HEIGHT, FAST_SYNC_BLOCK_HEIGHT: INDEXER_FAST_SYNC_HEIGHT, UPDATE_DB_INTERVAL: INDEXER_UPDATE_INTERVAL, MEMPOOL_CHUNK_SIZE: INDEXER_MEMPOOL_CHUNK_SIZE, PATH: `${INDEXER_BIN_DIR}:/usr/local/go/bin:/usr/local/bin:/usr/bin:/bin`, HOME: '/root', GOPATH: '/root/go', GOBIN: INDEXER_BIN_DIR, } const bashrc = [ 'export PATH=/root/.cargo/bin:/data/bin:/usr/local/bin:/usr/bin:/bin', "export PS1='spaces:\\w$ '", `alias spaces='space-cli --chain ${SPACED_CHAIN} --rpc-user "$SPACED_RPC_USER" --rpc-password "$SPACED_RPC_PASSWORD" '`, 'cat < /root/.bashrc <<'SPACES_BASHRC_EOF' ${bashrc} SPACES_BASHRC_EOF`], user: 'root', }, requires: [], }) .addDaemon('spaced', { subcontainer: spacedSub, exec: { command: ['/root/.cargo/bin/spaced'], env: spacedEnv, }, ready: { display: i18n('Spaced RPC'), fn: () => sdk.healthCheck.checkPortListening(effects, spacedRpcPort, { successMessage: i18n('spaced RPC is ready'), errorMessage: i18n('spaced RPC is not ready'), }), gracePeriod: 120_000, }, requires: [], }) .addDaemon('web-terminal', { subcontainer: termSub, exec: { command: [ 'gotty', '--port', String(uiPort), '-c', `${APP_USER}:${APP_PASSWORD}`, '--permit-write', '--reconnect', '/bin/bash', ], env: spacedEnv, }, ready: { display: i18n('Web Interface'), fn: () => sdk.healthCheck.checkPortListening(effects, uiPort, { successMessage: i18n('The web terminal is ready'), errorMessage: i18n('The web terminal is not ready'), }), }, requires: ['bashrc'], }) .addHealthCheck('sync', { ready: { display: i18n('Spaced Sync'), fn: async () => { try { const probe = await spacedSub.exec( [ '/root/.cargo/bin/space-cli', '--chain', SPACED_CHAIN, '--rpc-user', spacedAuth.username, '--rpc-password', spacedAuth.password, '--output-format', 'json', 'getserverinfo', ], {}, ) const stderr = (probe.stderr ?? '').toString().trim() const stdoutText = (probe.stdout ?? '').toString() const stdoutTrimmed = stdoutText.trim() if (probe.exitCode !== 0) { return { result: 'failure', message: i18n('space-cli exited ${code}: ${error}', { code: String(probe.exitCode), error: (stderr || stdoutTrimmed || '').slice(0, 200), }), } } let parsed: { ready?: boolean progress?: number chain?: { blocks?: number; headers?: number } } try { parsed = JSON.parse(stdoutText) } catch { return { result: 'failure', message: i18n( 'getserverinfo non-JSON. stdout=${stdout} stderr=${stderr}', { stdout: (stdoutTrimmed || '').slice(0, 160), stderr: (stderr || '').slice(0, 160), }, ), } } const progress = Math.min( 100, Math.max(0, Math.round((parsed.progress ?? 0) * 100)), ) const blocks = parsed.chain?.blocks ?? 0 const headers = parsed.chain?.headers ?? 0 if (parsed.ready === true && progress >= 100) { return { result: 'success', message: i18n( 'spaced is fully synced (blocks ${blocks} / headers ${headers}).', { blocks: String(blocks), headers: String(headers) }, ), } } return { result: 'loading', message: i18n( 'spaced is indexing: ${pct}% (blocks ${blocks} / headers ${headers}).', { pct: String(progress), blocks: String(blocks), headers: String(headers), }, ), } } catch (e) { return { result: 'failure', message: i18n('Spaced Sync health check crashed: ${error}', { error: (e as Error)?.message ?? String(e), }), } } }, gracePeriod: 30_000, trigger: sdk.trigger.cooldownTrigger(30_000), }, requires: ['spaced'], }) } // enableExplorer = true: full chain — spaced + terminal + embedded // PostgreSQL + Go indexer + indexer-sync health check. return sdk.Daemons.of(effects) .addOneshot('bashrc', { subcontainer: termSub, exec: { command: ['bash', '-c', `cat > /root/.bashrc <<'SPACES_BASHRC_EOF' ${bashrc} SPACES_BASHRC_EOF`], user: 'root', }, requires: [], }) .addOneshot('postgres-chown', { subcontainer: postgresSub, exec: { command: [ 'bash', '-c', `mkdir -p ${pgDataDir} && chown -R postgres:postgres ${pgDataDir} && chmod 700 ${pgDataDir}`, ], user: 'root', }, requires: [], }) .addDaemon('postgres', { subcontainer: postgresSub, exec: { command: [ 'docker-entrypoint.sh', 'postgres', '-c', 'listen_addresses=127.0.0.1', ], env: postgresEnv, }, ready: { display: i18n('Database'), fn: () => sdk.healthCheck.checkPortListening(effects, POSTGRES_PORT, { successMessage: i18n('postgres is ready'), errorMessage: i18n('postgres is not ready'), }), gracePeriod: 60_000, }, requires: ['postgres-chown'], }) .addDaemon('spaced', { subcontainer: spacedSub, exec: { command: ['/root/.cargo/bin/spaced'], env: spacedEnv, }, ready: { display: i18n('Spaced RPC'), fn: () => sdk.healthCheck.checkPortListening(effects, spacedRpcPort, { successMessage: i18n('spaced RPC is ready'), errorMessage: i18n('spaced RPC is not ready'), }), gracePeriod: 120_000, }, requires: [], }) .addOneshot('indexer-fetch', { subcontainer: indexerSub, exec: { command: [ 'sh', '-c', `set -eu; \ echo "indexer-fetch: image diagnostics..."; \ cat /etc/os-release 2>/dev/null | head -3 || echo "no /etc/os-release"; \ echo " go: $(go version 2>/dev/null || echo MISSING)"; \ echo " git: $(git --version 2>/dev/null || echo MISSING)"; \ echo " curl: $(curl --version 2>/dev/null | head -1 || echo MISSING)"; \ echo " wget: $(wget --version 2>/dev/null | head -1 || echo MISSING)"; \ echo " tar: $(tar --version 2>/dev/null | head -1 || echo MISSING)"; \ mkdir -p ${INDEXER_DIR} ${INDEXER_BIN_DIR}; \ if [ -f ${INDEXER_MARKER} ] && [ "$(cat ${INDEXER_MARKER})" = "${INDEXER_BUILD_ID}" ] && [ -x ${INDEXER_SYNC_BIN} ] && [ -x ${INDEXER_GOOSE_BIN} ]; then \ echo "indexer-fetch: ${INDEXER_BUILD_ID} already present, skipping."; \ exit 0; \ fi; \ echo "indexer-fetch: downloading ${INDEXER_BUILD_ID}..."; \ rm -rf ${INDEXER_DIR}; \ mkdir -p ${INDEXER_DIR} ${INDEXER_BIN_DIR}; \ if command -v curl >/dev/null 2>&1; then \ curl -fsSL '${INDEXER_TARBALL_URL}' -o /tmp/explorer-indexer.tar.gz; \ elif command -v wget >/dev/null 2>&1; then \ wget -q -O /tmp/explorer-indexer.tar.gz '${INDEXER_TARBALL_URL}'; \ else \ echo "indexer-fetch: ERROR no curl or wget available"; exit 1; \ fi; \ tar -xzf /tmp/explorer-indexer.tar.gz --strip-components=1 -C ${INDEXER_DIR}; \ rm -f /tmp/explorer-indexer.tar.gz; \ echo "indexer-fetch: patching types.go to make ptrs_root optional (older spaced compatibility)..."; \ awk 'BEGIN{patched=0} /if aux.PointersRoot == nil \\{/ {skip=2; patched++; next} skip>0 {skip--; next} /ra\\.PointersRoot = \\*aux\\.PointersRoot/ {print "\\tif aux.PointersRoot != nil { ra.PointersRoot = *aux.PointersRoot }"; next} {print} END{if(patched<2){print "PATCH-FAIL: expected 2 ptrs_root blocks, got " patched > "/dev/stderr"; exit 1}}' ${INDEXER_DIR}/pkg/node/types.go > ${INDEXER_DIR}/pkg/node/types.go.new; \ mv ${INDEXER_DIR}/pkg/node/types.go.new ${INDEXER_DIR}/pkg/node/types.go; \ if grep -q 'missing required field: ptrs_root' ${INDEXER_DIR}/pkg/node/types.go; then \ echo "indexer-fetch: ERROR ptrs_root check still present after patch"; exit 1; \ fi; \ echo "indexer-fetch: patching store.go to skip getptrblockmeta (older spaced has no pointer-block RPC)..."; \ awk 'BEGIN{patched=0} /spacesPtrBlock, err := sc.GetPtrBlockMeta/ {skip=8; patched++; next} skip>0 {skip--; next} {print} END{if(patched<1){print "PATCH-FAIL: expected GetPtrBlockMeta block, got " patched > "/dev/stderr"; exit 1}}' ${INDEXER_DIR}/pkg/store/store.go > ${INDEXER_DIR}/pkg/store/store.go.new; \ mv ${INDEXER_DIR}/pkg/store/store.go.new ${INDEXER_DIR}/pkg/store/store.go; \ if grep -q 'GetPtrBlockMeta' ${INDEXER_DIR}/pkg/store/store.go; then \ echo "indexer-fetch: ERROR GetPtrBlockMeta call still present after patch"; exit 1; \ fi; \ echo "${INDEXER_BUILD_ID}" > ${INDEXER_MARKER}; \ echo "indexer-fetch: done."`, ], user: 'root', }, requires: [], }) .addOneshot('indexer-build', { subcontainer: indexerSub, exec: { command: [ 'sh', '-c', `set -eu; \ if [ -x ${INDEXER_SYNC_BIN} ] && [ -x ${INDEXER_GOOSE_BIN} ]; then \ echo "indexer-build: sync + goose already built, skipping."; \ exit 0; \ fi; \ cd ${INDEXER_DIR}; \ mkdir -p ${INDEXER_BIN_DIR}; \ echo "indexer-build: building sync binary..."; \ CGO_ENABLED=0 go build -o ${INDEXER_SYNC_BIN} ./cmd/sync; \ echo "indexer-build: installing goose..."; \ CGO_ENABLED=0 GOBIN=${INDEXER_BIN_DIR} go install github.com/pressly/goose/v3/cmd/goose@v3.24.3; \ ls -l ${INDEXER_BIN_DIR}; \ echo "indexer-build: done."`, ], env: indexerEnv, user: 'root', }, requires: ['indexer-fetch'], }) .addOneshot('indexer-cleanup-legacy', { subcontainer: postgresSub, exec: { command: [ 'sh', '-c', `set -eu; \ PSQL="psql -U $POSTGRES_USER -h 127.0.0.1 -p 5432 -d $POSTGRES_DB -tA"; \ HAS_LEGACY=$($PSQL -c "SELECT (to_regclass('public.block_stats') IS NOT NULL) OR (to_regclass('public.spaces_history') IS NOT NULL)" 2>/dev/null || echo f); \ HAS_GOOSE=$($PSQL -c "SELECT to_regclass('public.goose_db_version') IS NOT NULL" 2>/dev/null || echo f); \ if [ "$HAS_GOOSE" != "t" ] && [ "$HAS_LEGACY" = "t" ]; then \ echo "indexer-cleanup-legacy: dropping leftover TS indexer schema..."; \ $PSQL -c "DROP TABLE IF EXISTS block_stats, syncs, spaces_history, spaces, transactions, blocks CASCADE; DROP EXTENSION IF EXISTS pg_trgm;"; \ echo "indexer-cleanup-legacy: done."; \ else \ echo "indexer-cleanup-legacy: nothing to clean (HAS_GOOSE=$HAS_GOOSE HAS_LEGACY=$HAS_LEGACY)."; \ fi`, ], env: { ...postgresEnv, PGPASSWORD: dbAuth.password, }, user: 'postgres', }, requires: ['postgres'], }) .addOneshot('indexer-migrate', { subcontainer: indexerSub, exec: { command: [ 'sh', '-c', `set -eu; \ echo "indexer-migrate: applying goose migrations..."; \ ${INDEXER_GOOSE_BIN} -dir ${INDEXER_SCHEMA_DIR} postgres "$POSTGRES_URI" up; \ echo "indexer-migrate: done."`, ], env: indexerEnv, user: 'root', }, requires: ['indexer-build', 'postgres', 'indexer-cleanup-legacy'], }) .addDaemon('indexer', { subcontainer: indexerSub, exec: { command: [INDEXER_SYNC_BIN], env: indexerEnv, }, ready: { display: i18n('Indexer Process'), fn: () => ({ result: 'success', message: i18n('indexer process is running'), }), gracePeriod: 60_000, }, requires: ['indexer-migrate', 'postgres', 'spaced'], }) .addOneshot('explorer-fetch', { subcontainer: explorerSub, exec: { command: [ 'sh', '-c', `set -eu; \ mkdir -p ${EXPLORER_DIR}; \ if [ -f ${EXPLORER_MARKER} ] && [ "$(cat ${EXPLORER_MARKER})" = "${EXPLORER_BUILD_ID}" ] && [ -d ${EXPLORER_BUILD_DIR} ]; then \ echo "explorer-fetch: ${EXPLORER_BUILD_ID} already present, skipping."; \ exit 0; \ fi; \ echo "explorer-fetch: downloading ${EXPLORER_BUILD_ID}..."; \ rm -rf ${EXPLORER_DIR}; \ mkdir -p ${EXPLORER_DIR}; \ if command -v curl >/dev/null 2>&1; then \ curl -fsSL '${EXPLORER_TARBALL_URL}' -o /tmp/explorer-ui.tar.gz; \ elif command -v wget >/dev/null 2>&1; then \ wget -q -O /tmp/explorer-ui.tar.gz '${EXPLORER_TARBALL_URL}'; \ else \ echo "explorer-fetch: ERROR no curl or wget"; exit 1; \ fi; \ tar -xzf /tmp/explorer-ui.tar.gz --strip-components=1 -C ${EXPLORER_DIR}; \ rm -f /tmp/explorer-ui.tar.gz; \ echo "${EXPLORER_BUILD_ID}" > ${EXPLORER_MARKER}; \ echo "explorer-fetch: done."`, ], user: 'root', }, requires: [], }) .addOneshot('explorer-install', { subcontainer: explorerSub, exec: { command: [ 'sh', '-c', `set -eu; \ cd ${EXPLORER_DIR}; \ if [ -d node_modules ] && [ -d build ] && [ -f build/index.js ]; then \ echo "explorer-install: already built, skipping."; \ exit 0; \ fi; \ echo "explorer-install: npm install (this may take a few minutes)..."; \ npm install --no-audit --no-fund; \ echo "explorer-install: building (PUBLIC_BTC_NETWORK=${EXPLORER_NETWORK})..."; \ PUBLIC_BTC_NETWORK=${EXPLORER_NETWORK} npm run build; \ if [ ! -f ${EXPLORER_BUILD_DIR}/index.js ]; then \ echo "explorer-install: ERROR build/index.js not produced"; \ ls -la ${EXPLORER_BUILD_DIR} || true; \ exit 1; \ fi; \ echo "explorer-install: done."`, ], env: explorerEnv, user: 'root', }, requires: ['explorer-fetch'], }) .addDaemon('explorer-ui', { subcontainer: explorerSub, exec: { command: ['node', EXPLORER_BUILD_DIR], env: explorerEnv, }, ready: { display: i18n('Explorer Web UI'), fn: () => sdk.healthCheck.checkPortListening(effects, EXPLORER_PORT, { successMessage: i18n('explorer UI is ready'), errorMessage: i18n('explorer UI is not ready'), }), gracePeriod: 60_000, }, requires: ['explorer-install', 'postgres'], }) .addDaemon('web-terminal', { subcontainer: termSub, exec: { command: [ 'gotty', '--port', String(uiPort), '-c', `${APP_USER}:${APP_PASSWORD}`, '--permit-write', '--reconnect', '/bin/bash', ], env: spacedEnv, }, ready: { display: i18n('Web Interface'), fn: () => sdk.healthCheck.checkPortListening(effects, uiPort, { successMessage: i18n('The web terminal is ready'), errorMessage: i18n('The web terminal is not ready'), }), }, requires: ['bashrc'], }) .addHealthCheck('sync', { ready: { display: i18n('Spaced Sync'), fn: async () => { try { const probe = await spacedSub.exec( [ '/root/.cargo/bin/space-cli', '--chain', SPACED_CHAIN, '--rpc-user', spacedAuth.username, '--rpc-password', spacedAuth.password, '--output-format', 'json', 'getserverinfo', ], {}, ) const stderr = (probe.stderr ?? '').toString().trim() const stdoutText = (probe.stdout ?? '').toString() const stdoutTrimmed = stdoutText.trim() if (probe.exitCode !== 0) { return { result: 'failure', message: i18n('space-cli exited ${code}: ${error}', { code: String(probe.exitCode), error: (stderr || stdoutTrimmed || '').slice(0, 200), }), } } let parsed: { ready?: boolean progress?: number chain?: { blocks?: number; headers?: number } } try { parsed = JSON.parse(stdoutText) } catch { return { result: 'failure', message: i18n( 'getserverinfo non-JSON. stdout=${stdout} stderr=${stderr}', { stdout: (stdoutTrimmed || '').slice(0, 160), stderr: (stderr || '').slice(0, 160), }, ), } } const progress = Math.min( 100, Math.max(0, Math.round((parsed.progress ?? 0) * 100)), ) const blocks = parsed.chain?.blocks ?? 0 const headers = parsed.chain?.headers ?? 0 if (parsed.ready === true && progress >= 100) { return { result: 'success', message: i18n( 'spaced is fully synced (blocks ${blocks} / headers ${headers}).', { blocks: String(blocks), headers: String(headers) }, ), } } return { result: 'loading', message: i18n( 'spaced is indexing: ${pct}% (blocks ${blocks} / headers ${headers}).', { pct: String(progress), blocks: String(blocks), headers: String(headers), }, ), } } catch (e) { return { result: 'failure', message: i18n( 'Spaced Sync health check crashed: ${error}', { error: (e as Error)?.message ?? String(e) }, ), } } }, gracePeriod: 30_000, trigger: sdk.trigger.cooldownTrigger(30_000), }, requires: ['spaced'], }) .addHealthCheck('indexer-sync', { ready: { display: i18n('Indexer Sync'), fn: async () => { try { // Go indexer has no syncs table; track progress by max(blocks.height) // of non-orphan rows. const psql = await postgresSub.exec( [ 'psql', '-U', POSTGRES_USER, '-d', POSTGRES_DB, '-h', '127.0.0.1', '-p', String(POSTGRES_PORT), '-t', '-A', '-F', '|', '-c', "SELECT COALESCE(MAX(height), 0) FROM blocks WHERE orphan = FALSE AND height >= 0;", ], { env: { PGPASSWORD: dbAuth.password } as Record }, ) if (psql.exitCode !== 0) { return { result: 'failure', message: i18n('indexer psql exited ${code}: ${error}', { code: String(psql.exitCode), error: ((psql.stderr ?? '') as string).toString().slice(0, 200) || '', }), } } const out = (psql.stdout ?? '').toString().trim() if (!out) { return { result: 'loading', message: i18n('indexer has not run a sync cycle yet.'), } } const parts = out.split('|') const endBlockHeight = parseInt(parts[0] ?? '0', 10) || 0 const ageSec = -1 // Go indexer has no per-sync timestamp surface const probe = await spacedSub.exec( [ '/root/.cargo/bin/space-cli', '--chain', SPACED_CHAIN, '--rpc-user', spacedAuth.username, '--rpc-password', spacedAuth.password, '--output-format', 'json', 'getserverinfo', ], {}, ) let spacedBlocks = 0 if (probe.exitCode === 0) { try { const parsed = JSON.parse((probe.stdout ?? '').toString()) spacedBlocks = parsed?.chain?.blocks ?? 0 } catch { /* ignore */ } } if (endBlockHeight === 0) { return { result: 'loading', message: i18n( 'indexer has not yet committed any blocks (spaced tip ${tip}).', { tip: String(spacedBlocks) }, ), } } const lag = Math.max(0, spacedBlocks - endBlockHeight) if (spacedBlocks > 0 && lag <= 5) { return { result: 'success', message: i18n( 'indexer caught up at block ${end} (spaced tip ${tip}).', { end: String(endBlockHeight), tip: String(spacedBlocks), }, ), } } return { result: 'loading', message: i18n( 'indexer at block ${end}, ${lag} behind spaced tip ${tip}.', { end: String(endBlockHeight), lag: String(lag), tip: String(spacedBlocks), }, ), } } catch (e) { return { result: 'failure', message: i18n('Indexer Sync health check crashed: ${error}', { error: (e as Error)?.message ?? String(e), }), } } }, gracePeriod: 60_000, trigger: sdk.trigger.cooldownTrigger(30_000), }, requires: ['indexer', 'postgres'], }) })