diff --git a/src/__tests__/resource-monitor.test.ts b/src/__tests__/resource-monitor.test.ts new file mode 100644 index 00000000..af39e45e --- /dev/null +++ b/src/__tests__/resource-monitor.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AutomatonDatabase, AutomatonIdentity, ConwayClient } from "../types.js"; + +vi.mock("../conway/x402.js", () => ({ + getUsdcBalance: vi.fn(), +})); + +const { getUsdcBalance } = await import("../conway/x402.js"); +const { checkResources, formatResourceReport } = await import("../survival/monitor.js"); + +describe("resource monitor diagnostics", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("records diagnostics when balance checks fail instead of presenting confirmed zero balances", async () => { + vi.mocked(getUsdcBalance).mockRejectedValue(new Error("RPC unavailable")); + + const status = await checkResources( + makeIdentity(), + makeConwayClient({ + getCreditsBalance: vi.fn().mockRejectedValue(new Error("Conway API unavailable")), + exec: vi.fn().mockResolvedValue({ stdout: "ok\n", stderr: "", exitCode: 0 }), + }), + makeDb(), + ); + + expect(status.financial.creditsCents).toBe(0); + expect(status.financial.usdcBalance).toBe(0); + expect(status.diagnostics).toMatchObject({ + creditsError: "Conway API unavailable", + usdcError: "RPC unavailable", + }); + + const report = formatResourceReport(status); + expect(report).toContain("Credits: unknown (Conway API unavailable)"); + expect(report).toContain("USDC: unknown (RPC unavailable)"); + expect(report).not.toContain("Credits: $0.00"); + expect(report).not.toContain("USDC: 0.000000"); + }); + + it("records sandbox diagnostics for failed health checks", async () => { + vi.mocked(getUsdcBalance).mockResolvedValue(1.25); + + const status = await checkResources( + makeIdentity(), + makeConwayClient({ + getCreditsBalance: vi.fn().mockResolvedValue(250), + exec: vi.fn().mockRejectedValue(new Error("sandbox exec timeout")), + }), + makeDb(), + ); + + expect(status.sandboxHealthy).toBe(false); + expect(status.diagnostics.sandboxError).toBe("sandbox exec timeout"); + expect(formatResourceReport(status)).toContain("Sandbox: UNHEALTHY (sandbox exec timeout)"); + }); + + it("records non-zero sandbox exit codes as diagnostics", async () => { + vi.mocked(getUsdcBalance).mockResolvedValue(1.25); + + const status = await checkResources( + makeIdentity(), + makeConwayClient({ + getCreditsBalance: vi.fn().mockResolvedValue(250), + exec: vi.fn().mockResolvedValue({ stdout: "", stderr: "boom", exitCode: 2 }), + }), + makeDb(), + ); + + expect(status.sandboxHealthy).toBe(false); + expect(status.diagnostics.sandboxError).toBe("health command exited 2"); + expect(formatResourceReport(status)).toContain("Sandbox: UNHEALTHY (health command exited 2)"); + }); +}); + +function makeIdentity(): AutomatonIdentity { + return { + name: "Test Automaton", + address: "0x1234567890123456789012345678901234567890", + account: {} as AutomatonIdentity["account"], + creatorAddress: "0x0000000000000000000000000000000000000000", + sandboxId: "sandbox-1", + apiKey: "test-api-key", + createdAt: new Date(0).toISOString(), + }; +} + +function makeConwayClient(overrides: Partial): ConwayClient { + return overrides as ConwayClient; +} + +function makeDb(): AutomatonDatabase { + const kv = new Map(); + return { + getKV: vi.fn((key: string) => kv.get(key)), + setKV: vi.fn((key: string, value: string) => { + kv.set(key, value); + }), + } as unknown as AutomatonDatabase; +} diff --git a/src/survival/monitor.ts b/src/survival/monitor.ts index e1461f3f..5cc63644 100644 --- a/src/survival/monitor.ts +++ b/src/survival/monitor.ts @@ -22,6 +22,13 @@ export interface ResourceStatus { previousTier: SurvivalTier | null; tierChanged: boolean; sandboxHealthy: boolean; + diagnostics: ResourceDiagnostics; +} + +export interface ResourceDiagnostics { + creditsError?: string; + usdcError?: string; + sandboxError?: string; } /** @@ -32,25 +39,35 @@ export async function checkResources( conway: ConwayClient, db: AutomatonDatabase, ): Promise { + const diagnostics: ResourceDiagnostics = {}; + // Check credits let creditsCents = 0; try { creditsCents = await conway.getCreditsBalance(); - } catch {} + } catch (error) { + diagnostics.creditsError = toDiagnosticMessage(error); + } // Check USDC let usdcBalance = 0; try { usdcBalance = await getUsdcBalance(identity.address); - } catch {} + } catch (error) { + diagnostics.usdcError = toDiagnosticMessage(error); + } // Check sandbox health let sandboxHealthy = true; try { const result = await conway.exec("echo ok", 5000); sandboxHealthy = result.exitCode === 0; - } catch { + if (!sandboxHealthy) { + diagnostics.sandboxError = `health command exited ${result.exitCode}`; + } + } catch (error) { sandboxHealthy = false; + diagnostics.sandboxError = toDiagnosticMessage(error); } const financial: FinancialState = { @@ -76,6 +93,7 @@ export async function checkResources( previousTier, tierChanged, sandboxHealthy, + diagnostics, }; } @@ -83,14 +101,31 @@ export async function checkResources( * Generate a human-readable resource report. */ export function formatResourceReport(status: ResourceStatus): string { + const diagnostics = status.diagnostics || {}; + const creditsLine = diagnostics.creditsError + ? `Credits: unknown (${diagnostics.creditsError})` + : `Credits: ${formatCredits(status.financial.creditsCents)}`; + const usdcLine = diagnostics.usdcError + ? `USDC: unknown (${diagnostics.usdcError})` + : `USDC: ${status.financial.usdcBalance.toFixed(6)}`; + const sandboxLine = diagnostics.sandboxError + ? `Sandbox: UNHEALTHY (${diagnostics.sandboxError})` + : `Sandbox: ${status.sandboxHealthy ? "healthy" : "UNHEALTHY"}`; + const lines = [ `=== RESOURCE STATUS ===`, - `Credits: ${formatCredits(status.financial.creditsCents)}`, - `USDC: ${status.financial.usdcBalance.toFixed(6)}`, + creditsLine, + usdcLine, `Tier: ${status.tier}${status.tierChanged ? ` (changed from ${status.previousTier})` : ""}`, - `Sandbox: ${status.sandboxHealthy ? "healthy" : "UNHEALTHY"}`, + sandboxLine, `Checked: ${status.financial.lastChecked}`, `========================`, ]; return lines.join("\n"); } + +function toDiagnosticMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + return "unknown error"; +}