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
115 changes: 115 additions & 0 deletions src/__tests__/hatch-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { randomUUID } from 'crypto';
import { initDb, db, repairDuplicateCompanions } from '../db/schema.js';
import {
companionExists,
createCompanion,
rescueCompanion,
loadCompanion,
} from '../lib/companion.js';

// Fresh, isolated DB state before each test. Child tables are cleared before
// companions because foreign_keys is ON (initReasoningSchema enables it).
beforeEach(() => {
initDb();
db.prepare('DELETE FROM xp_events').run();
db.prepare('DELETE FROM memories').run();
db.prepare('DELETE FROM sessions').run();
db.prepare('DELETE FROM evolution_history').run();
db.prepare('DELETE FROM companions').run();
});

function countCompanions(): number {
return (db.prepare('SELECT count(*) AS n FROM companions').get() as any).n;
}

function addXpEvent(companionId: string): void {
db.prepare(
"INSERT INTO xp_events (id, companion_id, event_type, xp_gained) VALUES (?, ?, 'observe', 5)"
).run(randomUUID(), companionId);
}

describe('repairDuplicateCompanions', () => {
it('is a no-op with zero companions', () => {
expect(repairDuplicateCompanions()).toBe(0);
expect(companionExists()).toBeNull();
});

it('is a no-op with a single companion', () => {
const { id } = createCompanion({ userId: 'solo', name: 'Solo' });
expect(repairDuplicateCompanions()).toBe(0);
expect(countCompanions()).toBe(1);
expect((companionExists() as any).id).toBe(id);
});

it('collapses several rows down to the highest-XP companion', () => {
const { id: a } = createCompanion({ userId: 'a', name: 'Ayy' });
const { id: b } = createCompanion({ userId: 'b', name: 'Bee' });
const { id: c } = createCompanion({ userId: 'c', name: 'Cee' });
db.prepare('UPDATE companions SET xp = ? WHERE id = ?').run(10, a);
db.prepare('UPDATE companions SET xp = ? WHERE id = ?').run(500, b); // most progressed
db.prepare('UPDATE companions SET xp = ? WHERE id = ?').run(0, c);

expect(repairDuplicateCompanions()).toBe(2);
expect(countCompanions()).toBe(1);
expect((db.prepare('SELECT id FROM companions').get() as any).id).toBe(b);
});

it('deletes orphaned child rows of removed companions but keeps the survivor\'s', () => {
const { id: keep } = createCompanion({ userId: 'keep', name: 'Keeper' });
const { id: drop } = createCompanion({ userId: 'drop', name: 'Dropped' });
db.prepare('UPDATE companions SET xp = ? WHERE id = ?').run(100, keep);

addXpEvent(keep);
addXpEvent(drop);
addXpEvent(drop);

repairDuplicateCompanions();

expect((db.prepare('SELECT count(*) AS n FROM xp_events WHERE companion_id = ?').get(drop) as any).n).toBe(0);
expect((db.prepare('SELECT count(*) AS n FROM xp_events WHERE companion_id = ?').get(keep) as any).n).toBe(1);
});

it('breaks XP ties toward the earliest-inserted row', () => {
const { id: first } = createCompanion({ userId: 'first', name: 'First' });
createCompanion({ userId: 'second', name: 'Second' }); // both default xp = 0

repairDuplicateCompanions();

expect(countCompanions()).toBe(1);
expect((db.prepare('SELECT id FROM companions').get() as any).id).toBe(first);
});

it('runs automatically as part of initDb()', () => {
createCompanion({ userId: 'x1', name: 'One' });
createCompanion({ userId: 'x2', name: 'Two' });
expect(countCompanions()).toBe(2);

initDb(); // migration pass repairs duplicates

expect(countCompanions()).toBe(1);
});
});

describe('rescue-then-hatch guard', () => {
it('a rescued buddy makes companionExists() truthy, so buddy_hatch refuses', () => {
const { id } = rescueCompanion({ name: 'Fernsquire', species: 'Shell Turtle' });

// The buddy_hatch handler returns early whenever companionExists() is truthy,
// so the rescued buddy is never overwritten by a follow-up hatch.
const existing = companionExists();
expect(existing).not.toBeNull();
expect((existing as any).id).toBe(id);
expect(loadCompanion(existing)!.name).toBe('Fernsquire');
});

it('leaves the rescued buddy intact (row + data still present)', () => {
rescueCompanion({ name: 'Fernsquire', species: 'Shell Turtle', personality: 'a wise, unflappable turtle' });

const row = db.prepare('SELECT * FROM companions LIMIT 1').get() as any;
expect(countCompanions()).toBe(1);
expect(row.name).toBe('Fernsquire');
expect(row.species).toBe('Shell Turtle');
expect(row.personality_bio).toBe('a wise, unflappable turtle');
});
});
51 changes: 51 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,55 @@ export function initDb() {

// Reasoning-layer migration (claims/edges tables + guard_mode column).
initReasoningSchema(db);

// Collapse any legacy duplicate companion rows down to a single canonical
// buddy. Runs last, after every table exists, so child-row cleanup is safe.
repairDuplicateCompanions();
}

/**
* Repair legacy databases that accumulated more than one companion row.
*
* The old buddy_hatch did a bare INSERT, so hatching repeatedly left several
* rows behind. Every read path uses `SELECT ... LIMIT 1` with no ORDER BY, so
* those extras are stale duplicates that can shadow the real buddy (deleting a
* single row — as an earlier fix attempt did — is not enough when three or more
* exist). This collapses them down to one.
*
* The survivor is the most-progressed companion (highest XP): because the buggy
* LIMIT-1 read funnelled all observe/pet XP into whichever single row it kept
* surfacing, that row is the buddy the user actually raised; the rest are
* level-1 duplicates. Ties resolve to the earliest row (natural rowid order) —
* the buddy that was surfaced first.
*
* Returns the number of duplicate rows removed. Idempotent: a no-op at 0 or 1.
*/
export function repairDuplicateCompanions(): number {
const rows = db.prepare('SELECT id, xp FROM companions').all() as Array<{ id: string; xp: number }>;
if (rows.length <= 1) return 0;

let keep = rows[0];
for (const r of rows) {
if ((r.xp || 0) > (keep.xp || 0)) keep = r;
}
const doomed = rows.filter(r => r.id !== keep.id).map(r => r.id);

// One transaction so a crash mid-repair can never leave the DB half-collapsed.
// Legacy tables (sessions/xp_events/memories/evolution_history) have no
// ON DELETE CASCADE and foreign_keys is ON, so clear children before the
// parent row; the reasoning_* tables cascade via their own FK.
const purge = db.transaction((ids: string[]) => {
for (const id of ids) {
db.prepare('DELETE FROM sessions WHERE companion_id = ?').run(id);
db.prepare('DELETE FROM evolution_history WHERE companion_id = ?').run(id);
db.prepare('DELETE FROM xp_events WHERE companion_id = ?').run(id);
db.prepare('DELETE FROM memories WHERE companion_id = ?').run(id);
db.prepare('DELETE FROM reasoning_findings_log WHERE companion_id = ?').run(id);
db.prepare('DELETE FROM reasoning_observe_seq WHERE companion_id = ?').run(id);
db.prepare('DELETE FROM companions WHERE id = ?').run(id);
}
});
purge(doomed);

return doomed.length;
}
21 changes: 20 additions & 1 deletion src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { randomUUID } from "crypto";
import { readFileSync, unlinkSync, mkdirSync } from "fs";
import { join, dirname } from "path";
import { homedir } from "os";
import { loadCompanion, writeBuddyStatus, createCompanion } from "../lib/companion.js";
import { loadCompanion, writeBuddyStatus, createCompanion, companionExists } from "../lib/companion.js";
import { renderCard, hatchAnimation } from "../lib/card.js";
import { captureSnapshot } from "../lib/snapshot.js";
import { BUDDY_STATUS_PATH } from "../lib/constants.js";
Expand Down Expand Up @@ -320,6 +320,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
name?: string; species?: string; user_id?: string;
};

// Never silently replace an existing companion. Onboarding may have rescued
// a buddy (the installer then tells the user to hatch); overwriting it here
// would destroy that buddy and its history. Releasing is an explicit,
// separate action — the user must run buddy_respawn first.
const existing = companionExists();
if (existing) {
const current = loadCompanion(existing);
const who = current
? `${current.name} the ${current.species} (Lv.${current.level})`
: 'a companion';
return {
content: [{
type: "text",
text: `You already have ${who}. Hatching would abandon them and erase their history.\n\n` +
`If you truly want a new buddy, run buddy_respawn first to release ${current?.name ?? 'the current one'}, then buddy_hatch again.`,
}],
};
}

const { companion } = createCompanion({
userId: user_id,
name: requestedName,
Expand Down