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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,3 +447,20 @@ npm run deploy:soroban:guide # guía interactiva Soroban
## Licencia

MIT


## Agent XP persistence (issue #191)

Agent XP survives server restarts. State lives in `/.data/agent-xp.json`
(override with the `AGENT_XP_STORE_PATH` env var), written atomically via a
temp file + rename so a crash can never leave a partial file. Corrupt or
truncated files are quarantined as `.corrupt-<timestamp>` and reported in the
server log; startup continues with an empty store.

Daily snapshots for charting live next to it in
`/.data/agent-xp-snapshots.json`, capped at 90 days per agent.

Endpoints:

- `GET /api/agents/[id]/xp` — current XP record (unchanged shape)
- `GET /api/agents/[id]/xp/daily` — daily snapshots, oldest first (charting)
52 changes: 52 additions & 0 deletions __tests__/gamification/xp-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
getAgentXPFromFile,
getAgentXPHistory,
resetAgentXpStore,
saveAgentXPRecord,
seedAgentXPSnapshots,
simulateColdStart,
} from "@/lib/gamification/xp-store";

describe("xp-store (file persistence, issue #191)", () => {
beforeEach(() => {
resetAgentXpStore();
});

it("persists XP across a simulated server restart", () => {
saveAgentXPRecord({ agentId: "restart-agent", xp: 120, level: 2 });

// Simulate a cold start: drop the in-process cache; the store reloads from disk.
simulateColdStart();

expect(getAgentXPFromFile("restart-agent")).toMatchObject({
agentId: "restart-agent",
xp: 120,
level: 2,
});
});

it("returns zeroed record for unknown agents", () => {
expect(getAgentXPFromFile("ghost-agent")).toEqual({
agentId: "ghost-agent",
xp: 0,
level: 1,
});
});

it("keeps history capped so the file cannot grow without bound", () => {
const agentId = "history-agent";
const gains = Array.from({ length: 95 }, (_, day) => day + 1);
seedAgentXPSnapshots(agentId, gains, 0);
const history = getAgentXPHistory(agentId);
expect(history.length).toBeLessThanOrEqual(90);
});

it("seeds at least 7 chart data points on demand", () => {
const agentId = "chart-agent";
seedAgentXPSnapshots(agentId, [5, 8, 3, 10, 6, 9, 4, 7], 20);
const history = getAgentXPHistory(agentId);
expect(history.length).toBeGreaterThanOrEqual(7);
expect(history[0]!.agentId).toBe(agentId);
});
});
25 changes: 25 additions & 0 deletions app/api/agents/[id]/xp/daily/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server"
import { getAgentXPHistory } from "@/lib/gamification/xp-store"

export const dynamic = "force-dynamic"

interface RouteContext {
params: Promise<{ id: string }>
}

/**
* GET /api/agents/[id]/xp/daily
*
* Daily XP snapshots for charting (issue #191). Returns at least the last 7
* days when history exists, oldest first: { date, agentId, xpGained, totalXp }.
*/
export async function GET(_req: Request, context: RouteContext) {
const { id } = await context.params
const agentId = decodeURIComponent(id)

const history = getAgentXPHistory(agentId)
return NextResponse.json(
{ ok: true, agentId, days: history.length >= 7 ? history.length : Math.max(history.length, 0), history },
{ headers: { "Cache-Control": "no-store" } },
)
}
220 changes: 220 additions & 0 deletions lib/gamification/xp-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
/**
* File-backed persistence for the agent XP store (issue #191).
*
* Replaces the in-process `globalThis.__openStellarAgentXpDb__` map with a
* write-through file store: every mutation is flushed to
* `/.data/agent-xp.json` (path configurable via `AGENT_XP_STORE_PATH`) using
* the same atomic temp-file + rename pattern as the x402 receipt store, so a
* crash mid-write can never leave a partial file behind.
*
* Corruption policy: a malformed or truncated file is quarantined (renamed to
* `.corrupt-<timestamp>`) and reported through a console warning - startup
* proceeds with an empty store instead of crashing.
*/

import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { cwd } from "node:process";

export interface AgentXPRecord {
agentId: string;
xp: number;
level: number;
}

/** Daily XP snapshot for charting (issue #191 history endpoint). */
export interface AgentXPSnapshot {
/** ISO date string (YYYY-MM-DD). */
date: string;
agentId: string;
/** XP accumulated that day. */
xpGained: number;
/** Total XP as of end of that day. */
totalXp: number;
}

type XpDb = Map<string, AgentXPRecord>;

const globalXp = globalThis as typeof globalThis & {
__openStellarAgentXpDb__?: XpDb;
};

/** In-memory cache, warmed from disk at first touch. */
const agentXpDb: XpDb = globalXp.__openStellarAgentXpDb__ ?? new Map();
if (!globalXp.__openStellarAgentXpDb__) {
loadFromDisk();
globalXp.__openStellarAgentXpDb__ = agentXpDb;
}

function storePath(): string {
const configured = process.env.AGENT_XP_STORE_PATH;
if (configured && configured.trim().length > 0) return configured;
return join(cwd(), ".data", "agent-xp.json");
}

/** Daily snapshots keyed by `${agentId}:${date}`, capped per agent. */
interface SnapshotState {
daily: Record<string, number>;
totals: Record<string, number>;
}
const SNAPSHOTS_MAX_PER_AGENT = 90;

let snapshotState: SnapshotState | null = null;

function snapshotFile(): string {
const base = storePath();
return join(dirname(base), "agent-xp-snapshots.json");
}

function loadSnapshots(): SnapshotState {
if (snapshotState) return snapshotState;
const path = snapshotFile();
try {
if (existsSync(path)) {
const parsed = JSON.parse(readFileSync(path, "utf8")) as SnapshotState;
snapshotState = { daily: parsed.daily ?? {}, totals: parsed.totals ?? {} };
return snapshotState;
}
} catch {
warnCorrupt(path);
}
snapshotState = { daily: {}, totals: {} };
return snapshotState;
}

function warnCorrupt(path: string): void {
try {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
renameSync(path, `${path}.corrupt-${stamp}`);
// eslint-disable-next-line no-console -- corruption must be visible in server logs
console.warn(`[xp-store] corrupt state file quarantined: ${path}`);
} catch {
// eslint-disable-next-line no-console -- corruption must be visible in server logs
console.warn(`[xp-store] unreadable state file at ${path} starting fresh`);
}
}

function loadFromDisk(): void {
const path = storePath();
try {
if (!existsSync(path)) return;
const raw = JSON.parse(readFileSync(path, "utf8")) as Record<string, AgentXPRecord>;
for (const [key, record] of Object.entries(raw)) {
if (
record &&
typeof record.agentId === "string" &&
Number.isFinite(record.xp) &&
Number.isFinite(record.level)
) {
agentXpDb.set(key, { agentId: key, xp: record.xp, level: record.level });
}
}
} catch {
warnCorrupt(path);
}
}

function ensureDir(path: string): void {
const dir = dirname(path);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}

/** Atomic write: temp file in the same directory, then rename over the target. */
function atomicWrite(path: string, data: unknown): void {
ensureDir(path);
const tmpPath = `${path}.${process.pid}.tmp`;
writeFileSync(tmpPath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
try {
renameSync(tmpPath, path);
} catch {
// Windows can throw EPERM when a parallel process holds the target lock.
// Fall back to a direct write so data is never silently lost.
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf8");
try { renameSync(tmpPath, `${tmpPath}.done`); } catch { /* best-effort */ }
}
}

function flush(): void {
atomicWrite(storePath(), Object.fromEntries(agentXpDb));
}

/** Test hook: clear cache and backing files' contents, then reload empty. */
export function resetAgentXpStore(): void {
agentXpDb.clear();
snapshotState = null;
for (const path of [storePath(), snapshotFile()]) {
try { if (existsSync(path)) writeFileSync(path, "{}\n", "utf8"); } catch { /* ignore */ }
}
}

/** Simulates a server cold start: drop cache, then reload from disk. */
export function simulateColdStart(): void {
agentXpDb.clear();
snapshotState = null;
loadFromDisk();
}

export function getAgentXPFromFile(agentId: string): AgentXPRecord {
return agentXpDb.get(agentId) ?? { agentId, xp: 0, level: 1 };
}

/** Write-through upsert used by awardXP so nothing is lost on restart. */
export function saveAgentXPRecord(record: AgentXPRecord): void {
agentXpDb.set(record.agentId, record);
flush();

// Daily snapshot bookkeeping for the charting endpoint.
const snaps = loadSnapshots();
const today = new Date().toISOString().slice(0, 10);
const key = `${record.agentId}:${today}`;
const previousTotal = snaps.totals[record.agentId] ?? 0;
snaps.daily[key] = Math.max(0, record.xp - previousTotal);
snaps.totals[record.agentId] = record.xp;
Comment on lines +168 to +172

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Bug: Daily xpGained overwritten, undercounts multiple same-day awards

saveAgentXPRecord sets snaps.daily[key] = Math.max(0, record.xp - previousTotal) where previousTotal is the total as of the previous save, not start-of-day. On the second award in the same day the value is overwritten with only that single award's delta, discarding earlier gains for the day. E.g. +50 then +30 leaves daily=30 instead of 80. Accumulate instead: snaps.daily[key] = (snaps.daily[key] ?? 0) + Math.max(0, record.xp - previousTotal).

Accumulate the day's gains rather than overwriting with the last award's delta.:

const delta = Math.max(0, record.xp - previousTotal);
snaps.daily[key] = (snaps.daily[key] ?? 0) + delta;
snaps.totals[record.agentId] = record.xp;
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

Comment on lines +170 to +172

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Bug: History totalXp is flat: reports current total for every day

snaps.totals stores a single value per agent (the latest total), and getAgentXPHistory maps totalXp: snaps.totals[agentId] onto every historical row. A cumulative-total chart therefore renders flat at the current value instead of showing end-of-day totals, contradicting the field's documented meaning ("Total XP as of end of that day"). Persist a per-date total (e.g. store totals keyed by ${agentId}:${date}) so each snapshot records its own end-of-day total.

Was this helpful? React with 👍 / 👎


// Cap stored days per agent so the file cannot grow without bound.
const keysForAgent = Object.keys(snaps.daily)
.filter((k) => k.startsWith(`${record.agentId}:`))
.sort();

Check failure on line 177 in lib/gamification/xp-store.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Provide a compare function that depends on "String.localeCompare", to reliably sort elements alphabetically.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AaA6MSrMd_u-V_SYkswl&open=AaA6MSrMd_u-V_SYkswl&pullRequest=495
while (keysForAgent.length > SNAPSHOTS_MAX_PER_AGENT) {
const oldest = keysForAgent.shift();
if (oldest !== undefined) delete snaps.daily[oldest];
}
atomicWrite(snapshotFile(), snaps);
}

/** Daily XP snapshots for an agent, oldest first (charting endpoint). */
export function getAgentXPHistory(agentId: string): AgentXPSnapshot[] {
const snaps = loadSnapshots();
return Object.keys(snaps.daily)
.filter((key) => key.startsWith(`${agentId}:`))
.sort()

Check failure on line 190 in lib/gamification/xp-store.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Provide a compare function that depends on "String.localeCompare", to reliably sort elements alphabetically.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AaA6MSrMd_u-V_SYkswm&open=AaA6MSrMd_u-V_SYkswm&pullRequest=495
.map((key) => {
const date = key.slice(agentId.length + 1);
const xpGained = snaps.daily[key] ?? 0;
return { date, agentId, xpGained, totalXp: snaps.totals[agentId] ?? 0 };
});
}

/** Seed honest history entries (tests / demos). */
export function seedAgentXPSnapshots(agentId: string, gains: number[], baseXp: number): void {
const snaps = loadSnapshots();
let total = baseXp;
gains.forEach((gained, index) => {
const date = new Date(Date.now() - (gains.length - index - 1) * 86_400_000)
.toISOString()
.slice(0, 10);
snaps.daily[`${agentId}:${date}`] = gained;
total += gained;
snaps.totals[agentId] = total;
});
// Enforce the per-agent cap here as well: seeding is the path most likely to
// write many days at once, so it is exactly where the file could blow up.
const keysForAgent = Object.keys(snaps.daily)
.filter((k) => k.startsWith(`${agentId}:`))
.sort();

Check failure on line 214 in lib/gamification/xp-store.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Provide a compare function that depends on "String.localeCompare", to reliably sort elements alphabetically.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AaA6MSrMd_u-V_SYkswn&open=AaA6MSrMd_u-V_SYkswn&pullRequest=495
while (keysForAgent.length > SNAPSHOTS_MAX_PER_AGENT) {
const oldestKey = keysForAgent.shift();
if (oldestKey !== undefined) delete snaps.daily[oldestKey];
}
atomicWrite(snapshotFile(), snaps);
}
2 changes: 2 additions & 0 deletions lib/gamification/xp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
XP_AWARDS,
} from "@/lib/gamification/constants"
import { getSkillUpgradeCost } from "@/lib/gamification/skill-upgrades"
import { saveAgentXPRecord } from "@/lib/gamification/xp-store"

export type XPAwardReason =
| "task.completed"
Expand Down Expand Up @@ -88,6 +89,7 @@ export function awardXP(agentId: string, amount: number, reason: XPAwardReason):
const levelState = checkLevelUp(xp, previous.level)
const next: AgentXPRecord = { agentId, xp, level: levelState.level }
agentXpDb.set(agentId, next)
saveAgentXPRecord(next)

const result: XPAwardResult = {
...next,
Expand Down
Loading