Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions src/__tests__/resource-monitor.test.ts
Original file line number Diff line number Diff line change
@@ -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>): ConwayClient {
return overrides as ConwayClient;
}

function makeDb(): AutomatonDatabase {
const kv = new Map<string, string>();
return {
getKV: vi.fn((key: string) => kv.get(key)),
setKV: vi.fn((key: string, value: string) => {
kv.set(key, value);
}),
} as unknown as AutomatonDatabase;
}
47 changes: 41 additions & 6 deletions src/survival/monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -32,25 +39,35 @@ export async function checkResources(
conway: ConwayClient,
db: AutomatonDatabase,
): Promise<ResourceStatus> {
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 = {
Expand All @@ -76,21 +93,39 @@ export async function checkResources(
previousTier,
tierChanged,
sandboxHealthy,
diagnostics,
};
}

/**
* 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";
}