diff --git a/packages/agent-world-sdk/src/index.ts b/packages/agent-world-sdk/src/index.ts index 588bdf7..6be6fa3 100644 --- a/packages/agent-world-sdk/src/index.ts +++ b/packages/agent-world-sdk/src/index.ts @@ -29,6 +29,7 @@ export { } from "./bootstrap.js"; export { registerPeerRoutes } from "./peer-protocol.js"; export { createWorldServer } from "./world-server.js"; +export { WorldLedger } from "./world-ledger.js"; export type { Endpoint, PeerRecord, @@ -45,4 +46,8 @@ export type { WorldServer, KeyRotationRequest, KeyRotationIdentity, + LedgerEntry, + LedgerEvent, + AgentSummary, + LedgerQueryOpts, } from "./types.js"; diff --git a/packages/agent-world-sdk/src/types.ts b/packages/agent-world-sdk/src/types.ts index 95ca855..cbe195b 100644 --- a/packages/agent-world-sdk/src/types.ts +++ b/packages/agent-world-sdk/src/types.ts @@ -142,9 +142,50 @@ export interface WorldServer { /** Underlying Fastify instance — register additional routes here */ fastify: import("fastify").FastifyInstance identity: Identity + /** Append-only event ledger for agent activity */ + ledger: import("./world-ledger.js").WorldLedger stop(): Promise } +// ── World Ledger (append-only event log) ─────────────────────────────────────── + +export type LedgerEvent = + | "world.genesis" + | "world.join" + | "world.leave" + | "world.evict" + | "world.action" + +export interface LedgerEntry { + seq: number + prevHash: string + timestamp: number + event: LedgerEvent + agentId: string + alias?: string + data?: Record + hash: string + worldSig: string +} + +export interface AgentSummary { + agentId: string + alias: string + firstSeen: number + lastSeen: number + actions: number + joins: number + online: boolean +} + +export interface LedgerQueryOpts { + agentId?: string + event?: LedgerEvent | LedgerEvent[] + since?: number + until?: number + limit?: number +} + // ── Key rotation (AgentWorld v0.2 §6.10/§10.4) ──────────────────────────────── export interface KeyRotationIdentity { diff --git a/packages/agent-world-sdk/src/world-ledger.ts b/packages/agent-world-sdk/src/world-ledger.ts new file mode 100644 index 0000000..3be61bc --- /dev/null +++ b/packages/agent-world-sdk/src/world-ledger.ts @@ -0,0 +1,230 @@ +import fs from "fs" +import path from "path" +import crypto from "node:crypto" +import { signWithDomainSeparator, verifyWithDomainSeparator, DOMAIN_SEPARATORS } from "./crypto.js" +import type { Identity } from "./types.js" +import type { LedgerEntry, LedgerEvent, AgentSummary, LedgerQueryOpts } from "./types.js" + +const ZERO_HASH = "0".repeat(64) +const LEDGER_DOMAIN = `AgentWorld-Ledger-${DOMAIN_SEPARATORS.MESSAGE.split("-").slice(-1)[0].replace("\0", "")}` +const LEDGER_SEPARATOR = `AgentWorld-Ledger-${DOMAIN_SEPARATORS.MESSAGE.split("-")[2]}` + +/** + * Append-only event ledger for World Agent activity. + * + * Blockchain-inspired design: + * - Each entry references the previous entry's hash (hash chain) + * - Entries are signed by the world's identity (tamper-evident) + * - State is derived from replaying the event log + * - Persisted as JSON Lines (.jsonl) — one entry per line + */ +export class WorldLedger { + private entries: LedgerEntry[] = [] + private filePath: string + private identity: Identity + private worldId: string + /** Number of raw lines that failed to parse on load (0 = clean) */ + public corruptedLines = 0 + + constructor(dataDir: string, worldId: string, identity: Identity) { + this.filePath = path.join(dataDir, "world-ledger.jsonl") + this.identity = identity + this.worldId = worldId + this.load() + } + + private load(): void { + if (!fs.existsSync(this.filePath)) { + this.writeGenesis() + return + } + + const lines = fs.readFileSync(this.filePath, "utf8").trim().split("\n").filter(Boolean) + let corrupted = 0 + for (const line of lines) { + try { + this.entries.push(JSON.parse(line) as LedgerEntry) + } catch { + corrupted++ + } + } + this.corruptedLines = corrupted + + if (corrupted > 0) { + console.warn(`[ledger] WARNING: ${corrupted} corrupted line(s) detected in ${this.filePath}`) + } + + if (this.entries.length === 0) { + this.writeGenesis() + } + } + + private writeGenesis(): void { + const entry = this.buildEntry("world.genesis", this.identity.agentId, undefined, { + worldId: this.worldId, + }) + this.entries.push(entry) + fs.mkdirSync(path.dirname(this.filePath), { recursive: true }) + fs.writeFileSync(this.filePath, JSON.stringify(entry) + "\n") + } + + private lastHash(): string { + if (this.entries.length === 0) return ZERO_HASH + return this.entries[this.entries.length - 1].hash + } + + private buildEntry( + event: LedgerEvent, + agentId: string, + alias?: string, + data?: Record + ): LedgerEntry { + const seq = this.entries.length + const prevHash = this.lastHash() + const timestamp = Date.now() + + const core = { seq, prevHash, timestamp, event, agentId, ...(alias ? { alias } : {}), ...(data ? { data } : {}) } + const hash = crypto.createHash("sha256").update(JSON.stringify(core)).digest("hex") + + const sigPayload = { ...core, hash } + const worldSig = signWithDomainSeparator(LEDGER_SEPARATOR, sigPayload, this.identity.secretKey) + + return { ...core, hash, worldSig } + } + + append(event: LedgerEvent, agentId: string, alias?: string, data?: Record): LedgerEntry { + const entry = this.buildEntry(event, agentId, alias, data) + this.entries.push(entry) + fs.appendFileSync(this.filePath, JSON.stringify(entry) + "\n") + return entry + } + + getEntries(opts?: LedgerQueryOpts): LedgerEntry[] { + let result = this.entries + + if (opts?.agentId) { + result = result.filter(e => e.agentId === opts.agentId) + } + if (opts?.event) { + const events = Array.isArray(opts.event) ? opts.event : [opts.event] + result = result.filter(e => events.includes(e.event)) + } + if (opts?.since) { + result = result.filter(e => e.timestamp >= opts.since!) + } + if (opts?.until) { + result = result.filter(e => e.timestamp <= opts.until!) + } + if (opts?.limit) { + result = result.slice(-opts.limit) + } + return result + } + + /** + * Derive agent summaries from the event log. + * + * @param liveAgentIds Optional set of agent IDs currently in the live session. + * When provided, `online` is true only if the agent is in this set. + * When omitted, `online` is derived from the event log (may be stale after restart). + */ + getAgentSummaries(liveAgentIds?: Set): AgentSummary[] { + const map = new Map() + + for (const entry of this.entries) { + if (entry.event === "world.genesis") continue + const id = entry.agentId + let summary = map.get(id) + if (!summary) { + summary = { agentId: id, alias: "", firstSeen: entry.timestamp, lastSeen: entry.timestamp, actions: 0, joins: 0, online: false } + map.set(id, summary) + } + + if (entry.alias) summary.alias = entry.alias + summary.lastSeen = entry.timestamp + + switch (entry.event) { + case "world.join": + summary.joins++ + summary.online = true + break + case "world.action": + summary.actions++ + break + case "world.leave": + case "world.evict": + summary.online = false + break + } + } + + // If live session info is available, use it as the source of truth for online status + if (liveAgentIds) { + for (const summary of map.values()) { + summary.online = liveAgentIds.has(summary.agentId) + } + } + + return [...map.values()].sort((a, b) => b.lastSeen - a.lastSeen) + } + + /** + * Verify the entire chain's integrity: hash chain + world signatures. + * Returns { ok, errors } where errors lists any broken entries. + */ + verify(): { ok: boolean; errors: Array<{ seq: number; error: string }> } { + const errors: Array<{ seq: number; error: string }> = [] + + // Detect corrupted/dropped lines from load + if (this.corruptedLines > 0) { + errors.push({ seq: -1, error: `${this.corruptedLines} corrupted line(s) dropped during load — possible data loss` }) + } + + for (let i = 0; i < this.entries.length; i++) { + const entry = this.entries[i] + + // Detect seq gaps (entries dropped from middle of chain) + if (entry.seq !== i) { + errors.push({ seq: entry.seq, error: `seq gap: expected ${i}, got ${entry.seq}` }) + } + + // Verify prevHash chain + const expectedPrev = i === 0 ? ZERO_HASH : this.entries[i - 1].hash + if (entry.prevHash !== expectedPrev) { + errors.push({ seq: entry.seq, error: `prevHash mismatch: expected ${expectedPrev.slice(0, 8)}..., got ${entry.prevHash.slice(0, 8)}...` }) + } + + // Verify self-hash + const { hash, worldSig, ...core } = entry + const expectedHash = crypto.createHash("sha256").update(JSON.stringify(core)).digest("hex") + if (hash !== expectedHash) { + errors.push({ seq: entry.seq, error: "hash mismatch" }) + } + + // Verify world signature + const sigPayload = { ...core, hash } + const valid = verifyWithDomainSeparator(LEDGER_SEPARATOR, this.identity.pubB64, sigPayload, worldSig) + if (!valid) { + errors.push({ seq: entry.seq, error: "invalid worldSig" }) + } + } + + return { ok: errors.length === 0, errors } + } + + get length(): number { + return this.entries.length + } + + get head(): LedgerEntry | undefined { + return this.entries[this.entries.length - 1] + } +} diff --git a/packages/agent-world-sdk/src/world-server.ts b/packages/agent-world-sdk/src/world-server.ts index 52f8a44..1e75bd6 100644 --- a/packages/agent-world-sdk/src/world-server.ts +++ b/packages/agent-world-sdk/src/world-server.ts @@ -10,11 +10,13 @@ import { DOMAIN_SEPARATORS, signWithDomainSeparator, } from "./crypto.js"; +import { WorldLedger } from "./world-ledger.js"; import type { WorldConfig, WorldHooks, WorldServer, WorldManifest, + LedgerQueryOpts, } from "./types.js"; const DEFAULT_BOOTSTRAP_URL = @@ -92,6 +94,12 @@ export async function createWorldServer( // Track agents currently in world for idle eviction const agentLastSeen = new Map(); + // Append-only event ledger — blockchain-inspired agent activity log + const ledger = new WorldLedger(dataDir, worldId, identity); + console.log( + `[world] Ledger loaded — ${ledger.length} entries, head=${ledger.head?.hash.slice(0, 8) ?? "none"}` + ); + const fastify = Fastify({ logger: false }); // Register peer protocol routes @@ -127,6 +135,7 @@ export async function createWorldServer( } agentLastSeen.set(agentId, Date.now()); const result = await hooks.onJoin(agentId, data); + ledger.append("world.join", agentId, (data["alias"] ?? data["name"]) as string | undefined); sendReply({ ok: true, worldId, @@ -146,6 +155,7 @@ export async function createWorldServer( agentLastSeen.delete(agentId); if (wasPresent) { await hooks.onLeave(agentId); + ledger.append("world.leave", agentId); console.log( `[world] ${agentId.slice(0, 8)} left — ${ agentLastSeen.size @@ -163,6 +173,7 @@ export async function createWorldServer( } agentLastSeen.set(agentId, Date.now()); const { ok, state } = await hooks.onAction(agentId, data); + ledger.append("world.action", agentId, undefined, { action: data["action"] as string | undefined }); sendReply({ ok, state }); return; } @@ -173,6 +184,32 @@ export async function createWorldServer( }, }); + // World ledger HTTP endpoints + fastify.get("/world/ledger", async (req) => { + const query = req.query as Record; + const opts: LedgerQueryOpts = {}; + if (query.agent_id) opts.agentId = query.agent_id; + if (query.event) opts.event = query.event.split(",") as LedgerQueryOpts["event"]; + if (query.since) opts.since = parseInt(query.since); + if (query.until) opts.until = parseInt(query.until); + if (query.limit) opts.limit = parseInt(query.limit); + return { + ok: true, + worldId, + chainHead: ledger.head?.hash ?? null, + total: ledger.length, + entries: ledger.getEntries(opts), + }; + }); + + fastify.get("/world/agents", async () => { + return { + ok: true, + worldId, + agents: ledger.getAgentSummaries(new Set(agentLastSeen.keys())), + }; + }); + // Allow caller to register additional routes before listen if (setupRoutes) await setupRoutes(fastify); @@ -248,6 +285,7 @@ export async function createWorldServer( if (ts < cutoff) { agentLastSeen.delete(id); await hooks.onLeave(id).catch(() => {}); + ledger.append("world.evict", id, undefined, { reason: "idle" }); console.log(`[world] ${id.slice(0, 8)} evicted (idle)`); } } @@ -282,6 +320,7 @@ export async function createWorldServer( return { fastify, identity, + ledger, async stop() { clearInterval(broadcastTimer); clearInterval(evictionTimer); diff --git a/src/identity.ts b/src/identity.ts index dd55d0e..39ae91d 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -12,8 +12,12 @@ import * as path from "path" import * as os from "os" import { Identity, AwRequestHeaders, AwResponseHeaders } from "./types" +// Protocol version for HTTP signatures and domain separators. +// Uses major.minor from package.json — only changes on breaking protocol updates. +// This MUST match the SDK's PROTOCOL_VERSION to allow cross-node signature verification. // eslint-disable-next-line @typescript-eslint/no-var-requires -const { version: PROTOCOL_VERSION } = require("../package.json") +const pkgVersion: string = require("../package.json").version +const PROTOCOL_VERSION = pkgVersion.split(".").slice(0, 2).join(".") // ── did:key mapping ───────────────────────────────────────────────────────── diff --git a/src/peer-server.ts b/src/peer-server.ts index 08f6561..093a556 100644 --- a/src/peer-server.ts +++ b/src/peer-server.ts @@ -12,7 +12,8 @@ import Fastify, { FastifyInstance } from "fastify" import { P2PMessage, Identity, Endpoint } from "./types" import { verifySignature, agentIdFromPublicKey, verifyHttpRequestHeaders, signHttpResponse as signHttpResponseFn } from "./identity" // eslint-disable-next-line @typescript-eslint/no-var-requires -const { version: PROTOCOL_VERSION } = require("../package.json") +const pkgVersion: string = require("../package.json").version +const PROTOCOL_VERSION = pkgVersion.split(".").slice(0, 2).join(".") import { tofuVerifyAndCache, tofuReplaceKey, getPeersForExchange, upsertDiscoveredPeer, removePeer, getPeer } from "./peer-db" const MAX_MESSAGE_AGE_MS = 5 * 60 * 1000 // 5 minutes diff --git a/test/key-rotation.test.mjs b/test/key-rotation.test.mjs index 2a65ad7..6639c29 100644 --- a/test/key-rotation.test.mjs +++ b/test/key-rotation.test.mjs @@ -8,7 +8,8 @@ const nacl = (await import("tweetnacl")).default import { createRequire } from "node:module" const require = createRequire(import.meta.url) -const { version: PROTOCOL_VERSION } = require("../package.json") +const pkgVersion = require("../package.json").version +const PROTOCOL_VERSION = pkgVersion.split(".").slice(0, 2).join(".") const { startPeerServer, stopPeerServer } = await import("../dist/peer-server.js") const { initDb } = await import("../dist/peer-db.js") diff --git a/test/request-signing.test.mjs b/test/request-signing.test.mjs index f4278a8..9196a92 100644 --- a/test/request-signing.test.mjs +++ b/test/request-signing.test.mjs @@ -17,7 +17,8 @@ import crypto from "node:crypto" import { createRequire } from "node:module" const require = createRequire(import.meta.url) -const { version: PROTOCOL_VERSION } = require("../package.json") +const pkgVersion = require("../package.json").version +const PROTOCOL_VERSION = pkgVersion.split(".").slice(0, 2).join(".") const nacl = (await import("tweetnacl")).default diff --git a/test/response-signing.test.mjs b/test/response-signing.test.mjs index 56d819e..bc36f07 100644 --- a/test/response-signing.test.mjs +++ b/test/response-signing.test.mjs @@ -14,7 +14,8 @@ import crypto from "node:crypto" import { createRequire } from "node:module" const require = createRequire(import.meta.url) -const { version: PROTOCOL_VERSION } = require("../package.json") +const pkgVersion = require("../package.json").version +const PROTOCOL_VERSION = pkgVersion.split(".").slice(0, 2).join(".") const nacl = (await import("tweetnacl")).default diff --git a/test/world-ledger.test.mjs b/test/world-ledger.test.mjs new file mode 100644 index 0000000..fe15d21 --- /dev/null +++ b/test/world-ledger.test.mjs @@ -0,0 +1,255 @@ +import { describe, it, beforeEach, afterEach } from "node:test" +import assert from "node:assert/strict" +import fs from "fs" +import path from "path" +import os from "os" +import { WorldLedger } from "../packages/agent-world-sdk/dist/world-ledger.js" +import { loadOrCreateIdentity } from "../packages/agent-world-sdk/dist/identity.js" + +let tmpDir +let identity + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ledger-test-")) + identity = loadOrCreateIdentity(tmpDir, "test-identity") +}) + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +describe("WorldLedger", () => { + it("creates genesis entry on first init", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + assert.equal(ledger.length, 1) + const entries = ledger.getEntries() + assert.equal(entries[0].event, "world.genesis") + assert.equal(entries[0].seq, 0) + assert.equal(entries[0].prevHash, "0".repeat(64)) + assert.equal(entries[0].agentId, identity.agentId) + assert.ok(entries[0].data?.worldId, "genesis should contain worldId") + assert.ok(entries[0].hash) + assert.ok(entries[0].worldSig) + }) + + it("appends join/action/leave events with hash chain", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + const agentId = "aw:sha256:aabbccdd" + + const joinEntry = ledger.append("world.join", agentId, "TestBot") + assert.equal(joinEntry.seq, 1) + assert.equal(joinEntry.event, "world.join") + assert.equal(joinEntry.agentId, agentId) + assert.equal(joinEntry.alias, "TestBot") + assert.equal(joinEntry.prevHash, ledger.getEntries()[0].hash) + + const actionEntry = ledger.append("world.action", agentId, undefined, { action: "move" }) + assert.equal(actionEntry.seq, 2) + assert.equal(actionEntry.prevHash, joinEntry.hash) + assert.deepEqual(actionEntry.data, { action: "move" }) + + const leaveEntry = ledger.append("world.leave", agentId) + assert.equal(leaveEntry.seq, 3) + assert.equal(leaveEntry.prevHash, actionEntry.hash) + + assert.equal(ledger.length, 4) + }) + + it("persists to disk and reloads on new instance", () => { + const ledger1 = new WorldLedger(tmpDir, "test-world", identity) + ledger1.append("world.join", "aw:sha256:agent1", "Alpha") + ledger1.append("world.action", "aw:sha256:agent1", undefined, { action: "attack" }) + assert.equal(ledger1.length, 3) + + const ledger2 = new WorldLedger(tmpDir, "test-world", identity) + assert.equal(ledger2.length, 3) + const entries = ledger2.getEntries() + assert.equal(entries[0].event, "world.genesis") + assert.equal(entries[1].event, "world.join") + assert.equal(entries[1].alias, "Alpha") + assert.equal(entries[2].event, "world.action") + }) + + it("verify() passes on valid chain", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + ledger.append("world.join", "aw:sha256:a1", "Bot1") + ledger.append("world.action", "aw:sha256:a1") + ledger.append("world.leave", "aw:sha256:a1") + + const result = ledger.verify() + assert.equal(result.ok, true) + assert.equal(result.errors.length, 0) + }) + + it("verify() detects tampered entry on reload", () => { + const ledger1 = new WorldLedger(tmpDir, "test-world", identity) + ledger1.append("world.join", "aw:sha256:a1", "Bot1") + + // Tamper with the file: change the alias in the second line + const filePath = path.join(tmpDir, "world-ledger.jsonl") + const lines = fs.readFileSync(filePath, "utf8").trim().split("\n") + const entry = JSON.parse(lines[1]) + entry.alias = "TAMPERED" + lines[1] = JSON.stringify(entry) + fs.writeFileSync(filePath, lines.join("\n") + "\n") + + const ledger2 = new WorldLedger(tmpDir, "test-world", identity) + const result = ledger2.verify() + assert.equal(result.ok, false) + assert.ok(result.errors.length > 0) + }) + + it("getAgentSummaries() derives correct state from events", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + const a1 = "aw:sha256:agent1" + const a2 = "aw:sha256:agent2" + + ledger.append("world.join", a1, "Alpha") + ledger.append("world.join", a2, "Beta") + ledger.append("world.action", a1, undefined, { action: "move" }) + ledger.append("world.action", a1, undefined, { action: "attack" }) + ledger.append("world.action", a2, undefined, { action: "defend" }) + ledger.append("world.leave", a2) + + const summaries = ledger.getAgentSummaries() + assert.equal(summaries.length, 2) + + const alpha = summaries.find(s => s.agentId === a1) + assert.ok(alpha) + assert.equal(alpha.alias, "Alpha") + assert.equal(alpha.joins, 1) + assert.equal(alpha.actions, 2) + assert.equal(alpha.online, true) + + const beta = summaries.find(s => s.agentId === a2) + assert.ok(beta) + assert.equal(beta.alias, "Beta") + assert.equal(beta.joins, 1) + assert.equal(beta.actions, 1) + assert.equal(beta.online, false) + }) + + it("getAgentSummaries() tracks re-joins", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + const a1 = "aw:sha256:agent1" + + ledger.append("world.join", a1, "Alpha") + ledger.append("world.leave", a1) + ledger.append("world.join", a1, "Alpha v2") + + const summaries = ledger.getAgentSummaries() + const alpha = summaries.find(s => s.agentId === a1) + assert.equal(alpha.joins, 2) + assert.equal(alpha.online, true) + assert.equal(alpha.alias, "Alpha v2") + }) + + it("getEntries() supports filtering by agentId", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + ledger.append("world.join", "aw:sha256:a1", "Alpha") + ledger.append("world.join", "aw:sha256:a2", "Beta") + ledger.append("world.action", "aw:sha256:a1") + + const filtered = ledger.getEntries({ agentId: "aw:sha256:a1" }) + assert.equal(filtered.length, 2) + assert.ok(filtered.every(e => e.agentId === "aw:sha256:a1")) + }) + + it("getEntries() supports filtering by event type", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + ledger.append("world.join", "aw:sha256:a1") + ledger.append("world.action", "aw:sha256:a1") + ledger.append("world.leave", "aw:sha256:a1") + + const joins = ledger.getEntries({ event: "world.join" }) + assert.equal(joins.length, 1) + + const multi = ledger.getEntries({ event: ["world.join", "world.leave"] }) + assert.equal(multi.length, 2) + }) + + it("getEntries() supports limit (returns last N)", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + for (let i = 0; i < 10; i++) { + ledger.append("world.action", "aw:sha256:a1") + } + const last3 = ledger.getEntries({ limit: 3 }) + assert.equal(last3.length, 3) + assert.equal(last3[0].seq, 8) + }) + + it("head returns the last entry", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + const entry = ledger.append("world.join", "aw:sha256:a1", "Alpha") + assert.equal(ledger.head?.hash, entry.hash) + }) + + it("evict event is recorded properly", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + ledger.append("world.join", "aw:sha256:a1", "Alpha") + ledger.append("world.evict", "aw:sha256:a1", undefined, { reason: "idle" }) + + const summaries = ledger.getAgentSummaries() + const alpha = summaries.find(s => s.agentId === "aw:sha256:a1") + assert.equal(alpha.online, false) + + const evicts = ledger.getEntries({ event: "world.evict" }) + assert.equal(evicts.length, 1) + assert.deepEqual(evicts[0].data, { reason: "idle" }) + }) + + it("each entry hash is unique", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + ledger.append("world.join", "aw:sha256:a1") + ledger.append("world.join", "aw:sha256:a2") + ledger.append("world.action", "aw:sha256:a1") + + const hashes = ledger.getEntries().map(e => e.hash) + const uniqueHashes = new Set(hashes) + assert.equal(uniqueHashes.size, hashes.length) + }) + + it("verify() detects corrupted/truncated lines on load", () => { + const ledger1 = new WorldLedger(tmpDir, "test-world", identity) + ledger1.append("world.join", "aw:sha256:a1", "Bot1") + assert.equal(ledger1.length, 2) + + // Append a corrupted line to the file + const filePath = path.join(tmpDir, "world-ledger.jsonl") + fs.appendFileSync(filePath, '{"broken":true, invalid json\n') + + const ledger2 = new WorldLedger(tmpDir, "test-world", identity) + assert.equal(ledger2.corruptedLines, 1) + assert.equal(ledger2.length, 2) // corrupted line dropped + + const result = ledger2.verify() + assert.equal(result.ok, false) + assert.ok(result.errors.some(e => e.error.includes("corrupted"))) + }) + + it("getAgentSummaries() uses liveAgentIds to determine online status", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + const a1 = "aw:sha256:agent1" + const a2 = "aw:sha256:agent2" + + ledger.append("world.join", a1, "Alpha") + ledger.append("world.join", a2, "Beta") + + // Without liveAgentIds — both online from log + const all = ledger.getAgentSummaries() + assert.equal(all.find(s => s.agentId === a1).online, true) + assert.equal(all.find(s => s.agentId === a2).online, true) + + // With liveAgentIds — only a1 is actually online + const live = new Set([a1]) + const filtered = ledger.getAgentSummaries(live) + assert.equal(filtered.find(s => s.agentId === a1).online, true) + assert.equal(filtered.find(s => s.agentId === a2).online, false) + + // After restart — empty live set + const empty = new Set() + const restarted = ledger.getAgentSummaries(empty) + assert.equal(restarted.find(s => s.agentId === a1).online, false) + assert.equal(restarted.find(s => s.agentId === a2).online, false) + }) +})