Skip to content
Merged
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
80 changes: 74 additions & 6 deletions packages/franken-brain/src/brain-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,77 @@ import { createHash, randomUUID } from 'node:crypto';
import { chmodSync, existsSync, mkdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';

import { hiveMindAgentTypeNamespace } from './hive-mind-store.js';
import Database from 'better-sqlite3';

import { HiveMindStore, hiveMindAgentTypeNamespace, type HiveMindNamespace } from './hive-mind-store.js';
import { SqliteBrain } from './sqlite-brain.js';

const MAX_AGENT_TYPE_ID_BYTES = 255;
const MAX_DEFAULT_BRAIN_FILENAME_AGENT_TYPE_ID_BYTES = 244;
const UNSAFE_AGENT_TYPE_ID_CHARACTERS = /[<>:"/\\|?*\u0000-\u001f\u007f]/u;
const WINDOWS_RESERVED_AGENT_TYPE_ID =
/^(?:con|prn|aux|nul|clock\$|com[1-9]|lpt[1-9])(?:\.|$)/iu;
const HIVE_PUBLISHER_ID_METADATA_KEY = 'hive-publisher-id';

function durablePublisherId(
dbPath: string,
hiveDbPath: string,
namespace: HiveMindNamespace,
): string {
const db = new Database(dbPath);
try {
db.pragma('busy_timeout = 5000');
db.exec(`
CREATE TABLE IF NOT EXISTS brain_registry_metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
`);
const selectPublisherId = db.prepare(`
SELECT value FROM brain_registry_metadata WHERE key = ?
`);
const existing = selectPublisherId.get(HIVE_PUBLISHER_ID_METADATA_KEY) as
{ value: string } | undefined;
if (existing) {
try {
const hiveStore = new HiveMindStore(hiveDbPath);
try {
hiveStore.completeLegacyPublisherMigration(namespace, true);
} finally {
hiveStore.close();
}
} catch {
// Hive sharing is additive; an outage must not block an existing local brain.
}
return existing.value;
}

const initializePublisherId = db.transaction(() => {
const concurrent = selectPublisherId.get(HIVE_PUBLISHER_ID_METADATA_KEY) as
{ value: string } | undefined;
if (concurrent) return concurrent.value;

const hiveStore = new HiveMindStore(hiveDbPath);
try {
// Previous releases used process-random publisher IDs, so ownership cannot
// be reconstructed safely. The shared marker makes this purge atomic and
// once-only across distinct durable brain databases for the same type.
hiveStore.completeLegacyPublisherMigration(namespace, true);
} finally {
hiveStore.close();
}

const publisherId = randomUUID();
db.prepare(`
INSERT INTO brain_registry_metadata (key, value) VALUES (?, ?)
`).run(HIVE_PUBLISHER_ID_METADATA_KEY, publisherId);
return publisherId;
});
return initializePublisherId.immediate();
} finally {
db.close();
}
}

function assertSafeAgentTypeId(agentTypeId: string): void {
if (
Expand Down Expand Up @@ -63,7 +126,7 @@ export class BrainRegistry {
constructor(
private readonly brainsDir = join('.fbeast', 'brains'),
private readonly hiveDbPath = join(dirname(brainsDir), 'hive', 'hive.db'),
private readonly publisherId: string = randomUUID(),
private readonly publisherId?: string,
) {}

forAgentType(agentTypeId: string, dbPath?: string): SqliteBrain {
Expand Down Expand Up @@ -93,15 +156,20 @@ export class BrainRegistry {
if (dbPath !== undefined) this.preferredDbPaths.set(registryKey, resolvedDbPath);
return existing;
}
if (dbPath === undefined) {
mkdirSync(this.brainsDir, { recursive: true });
if (resolvedDbPath !== ':memory:') {
mkdirSync(dirname(resolvedDbPath), { recursive: true });
}
const namespace = hiveMindAgentTypeNamespace(agentTypeId);
const publisherId = this.publisherId
?? (resolvedDbPath === ':memory:'
? randomUUID()
: durablePublisherId(resolvedDbPath, this.hiveDbPath, namespace));
const brain = new SqliteBrain(resolvedDbPath, undefined, {
conversationWorkspaceId: null,
hiveMind: {
dbPath: resolvedDbPath === ':memory:' ? ':memory:' : this.hiveDbPath,
namespace: hiveMindAgentTypeNamespace(agentTypeId),
publisherId: this.publisherId,
namespace,
publisherId,
},
});
const paths = agentBrains ?? new Map<string, SqliteBrain>();
Expand Down
158 changes: 156 additions & 2 deletions packages/franken-brain/src/hive-mind-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,19 @@ const MAX_ENTRY_BYTES = 64 * 1024;
const MAX_POLL_LIMIT = 1_000;
const DEFAULT_MAX_ENTRIES_PER_NAMESPACE = 10_000;
const MAX_CONFIGURED_ENTRIES_PER_NAMESPACE = 1_000_000;
const SECURE_DELETE_PENDING_KEY = 'secure-delete-pending';
const MIGRATION_SECURE_DELETE_PENDING_VALUE = 'migration';
const LEGACY_PUBLISHER_MIGRATION_KEY_PREFIX = 'legacy-publisher-migration:';
const MIGRATION_CHECKPOINT_RETRY_MS = 10;
const MIGRATION_CHECKPOINT_TIMEOUT_MS = 5_000;
const UNSAFE_AGENT_TYPE_ID_CHARACTERS = /[<>:"/\\|?*\u0000-\u001f\u007f]/u;
const WINDOWS_RESERVED_AGENT_TYPE_ID =
/^(?:con|prn|aux|nul|clock\$|com[1-9]|lpt[1-9])(?:\.|$)/iu;

export interface HiveMindLessonPublishEntry {
readonly kind: 'lesson';
/** Stable review-candidate identity used for precise revision revocation. */
readonly candidateId?: string;
readonly key: string;
readonly status: 'pending' | 'approved';
readonly lesson: ConsolidatedLesson;
Expand Down Expand Up @@ -70,6 +77,30 @@ interface HiveMindRow {
publishedAt: string;
}

interface WalCheckpointResult {
busy: number;
log: number;
checkpointed: number;
}

function truncateWalOrThrow(db: Database.Database): void {
const [result] = db.pragma('wal_checkpoint(TRUNCATE)') as WalCheckpointResult[];
if (!result || result.busy !== 0) {
throw new Error('Secure deletion could not truncate the Hive WAL because a reader is active');
}
}

function truncateWalAfterConcurrentMigrationOrThrow(db: Database.Database): void {
const deadline = Date.now() + MIGRATION_CHECKPOINT_TIMEOUT_MS;
const waitBuffer = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
do {
const [result] = db.pragma('wal_checkpoint(TRUNCATE)') as WalCheckpointResult[];
if (result?.busy === 0) return;
Atomics.wait(waitBuffer, 0, 0, MIGRATION_CHECKPOINT_RETRY_MS);
} while (Date.now() < deadline);
throw new Error('Secure deletion could not truncate the Hive WAL because a reader is active');
}

function assertSafeAgentTypeId(agentTypeId: string): void {
if (
typeof agentTypeId !== 'string'
Expand Down Expand Up @@ -139,6 +170,7 @@ function parseRow(row: HiveMindRow): HiveMindEntry {
const lessonPayload = payload as HiveMindLessonPublishEntry;
if (
typeof lessonPayload.key !== 'string'
|| (lessonPayload.candidateId !== undefined && typeof lessonPayload.candidateId !== 'string')
|| (lessonPayload.status !== 'pending' && lessonPayload.status !== 'approved')
|| !lessonPayload.lesson
|| lessonPayload.lesson.kind !== 'consolidated-lesson'
Expand Down Expand Up @@ -171,7 +203,10 @@ export class HiveMindStore {
private readonly db: Database.Database;
private readonly maxEntriesPerNamespace: number;

constructor(dbPath = '.fbeast/hive/hive.db', options: HiveMindStoreOptions = {}) {
constructor(
private readonly dbPath = '.fbeast/hive/hive.db',
options: HiveMindStoreOptions = {},
) {
this.maxEntriesPerNamespace = options.maxEntriesPerNamespace ?? DEFAULT_MAX_ENTRIES_PER_NAMESPACE;
if (
!Number.isSafeInteger(this.maxEntriesPerNamespace)
Expand All @@ -185,6 +220,7 @@ export class HiveMindStore {
if (dbPath !== ':memory:') mkdirSync(dirname(dbPath), { recursive: true });
this.db = new Database(dbPath);
this.db.pragma('busy_timeout = 5000');
if (dbPath !== ':memory:') this.db.pragma('secure_delete = ON');
this.db.pragma('journal_mode = WAL');
this.db.pragma('busy_timeout = 5000');
this.db.exec(`
Expand All @@ -198,7 +234,12 @@ export class HiveMindStore {
);
CREATE INDEX IF NOT EXISTS idx_hive_mind_poll
ON hive_mind_entries(namespace, id);
CREATE TABLE IF NOT EXISTS hive_mind_metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
this.retryPendingSecureDelete();
}

publish(
Expand All @@ -208,6 +249,7 @@ export class HiveMindStore {
): HiveMindEntry {
assertNamespace(namespace);
assertPublisherId(publisherId);
this.retryPendingSecureDelete();
if (entry.kind !== 'lesson' && entry.kind !== 'episode') {
throw new TypeError('Hive mind entry kind must be lesson or episode');
}
Expand Down Expand Up @@ -245,6 +287,7 @@ export class HiveMindStore {

poll(namespace: HiveMindNamespace, options: HiveMindPollOptions = {}): HiveMindEntry[] {
assertNamespace(namespace);
this.retryPendingSecureDelete();
const { sinceId, limit } = assertPollOptions(options);
const clauses = ['namespace = ?', 'id > ?'];
const parameters: Array<string | number> = [namespace, sinceId];
Expand All @@ -271,6 +314,7 @@ export class HiveMindStore {
/** Return the newest bounded window, ordered newest first. */
recent(namespace: HiveMindNamespace, options: HiveMindRecentOptions = {}): HiveMindEntry[] {
assertNamespace(namespace);
this.retryPendingSecureDelete();
const { limit } = assertPollOptions(options);
const clauses = ['namespace = ?'];
const parameters: Array<string | number> = [namespace];
Expand Down Expand Up @@ -302,6 +346,78 @@ export class HiveMindStore {
);
}

deleteLessonPublication(
namespace: HiveMindNamespace,
publisherId: string,
candidateId: string,
key: string,
): number {
return this.deletePublishedWhere(
namespace,
publisherId,
entry => entry.kind === 'lesson'
&& (entry.candidateId === candidateId
|| (entry.candidateId === undefined && entry.key === key && entry.status === 'pending')),
);
}

/** Purge every legacy publication in one namespace before durable ownership is adopted. */
deleteNamespace(namespace: HiveMindNamespace): number {
assertNamespace(namespace);
this.retryPendingSecureDelete();
const remove = this.db.transaction(() => {
const result = this.db.prepare(`
DELETE FROM hive_mind_entries WHERE namespace = ?
`).run(namespace);
if (result.changes > 0 && this.dbPath !== ':memory:') {
this.db.prepare(`
INSERT OR REPLACE INTO hive_mind_metadata (key, value) VALUES (?, '1')
`).run(SECURE_DELETE_PENDING_KEY);
}
return result.changes;
});
const removed = remove.immediate();
if (removed > 0 && this.dbPath !== ':memory:') this.purgeDeletedContent();
return removed;
}

/**
* Record durable publisher adoption once per namespace, optionally purging
* publications from the preceding process-random ownership model.
*/
completeLegacyPublisherMigration(
namespace: HiveMindNamespace,
purgeLegacyPublications: boolean,
): boolean {
assertNamespace(namespace);
this.retryPendingSecureDelete();
const migrationKey = `${LEGACY_PUBLISHER_MIGRATION_KEY_PREFIX}${namespace}`;
const migrate = this.db.transaction(() => {
const migrated = this.db.prepare(`
SELECT 1 FROM hive_mind_metadata WHERE key = ?
`).get(migrationKey);
if (migrated) return { completed: false, removed: 0 };

const removed = purgeLegacyPublications
? this.db.prepare('DELETE FROM hive_mind_entries WHERE namespace = ?').run(namespace).changes
: 0;
this.db.prepare(`
INSERT INTO hive_mind_metadata (key, value) VALUES (?, '1')
`).run(migrationKey);
if (removed > 0 && this.dbPath !== ':memory:') {
this.db.prepare(`
INSERT OR REPLACE INTO hive_mind_metadata (key, value) VALUES (?, ?)
`).run(SECURE_DELETE_PENDING_KEY, MIGRATION_SECURE_DELETE_PENDING_VALUE);
}
return { completed: true, removed };
});
const result = migrate.immediate();
if (result.removed > 0 && this.dbPath !== ':memory:') {
this.purgeDeletedContent();
}
return result.completed;
}

deletePublishedWhere(
namespace: HiveMindNamespace,
publisherId: string,
Expand All @@ -317,16 +433,54 @@ export class HiveMindStore {
ORDER BY id ASC
`).all(namespace, publisherId) as HiveMindRow[];
const ids = rows.map(parseRow).filter(predicate).map(entry => entry.id);
if (ids.length === 0) return 0;
if (ids.length === 0) {
if (this.dbPath !== ':memory:' && this.hasPendingSecureDelete()) {
this.purgeDeletedContent();
}
Comment thread
djm204 marked this conversation as resolved.
return 0;
}
const remove = this.db.transaction(() => {
const statement = this.db.prepare('DELETE FROM hive_mind_entries WHERE id = ?');
for (const id of ids) statement.run(id);
if (this.dbPath !== ':memory:') {
this.db.prepare(`
INSERT OR REPLACE INTO hive_mind_metadata (key, value) VALUES (?, '1')
`).run(SECURE_DELETE_PENDING_KEY);
}
});
remove.immediate();
if (this.dbPath !== ':memory:') {
this.purgeDeletedContent();
}
return ids.length;
}

private hasPendingSecureDelete(): boolean {
return this.db.prepare(`
SELECT 1 FROM hive_mind_metadata WHERE key = ?
`).get(SECURE_DELETE_PENDING_KEY) !== undefined;
}

private retryPendingSecureDelete(): void {
if (this.dbPath !== ':memory:' && this.hasPendingSecureDelete()) {
this.purgeDeletedContent();
}
}

private purgeDeletedContent(): void {
const pending = this.db.prepare(`
SELECT value FROM hive_mind_metadata WHERE key = ?
`).get(SECURE_DELETE_PENDING_KEY) as { value: string } | undefined;
if (pending?.value === MIGRATION_SECURE_DELETE_PENDING_VALUE) {
truncateWalAfterConcurrentMigrationOrThrow(this.db);
} else {
truncateWalOrThrow(this.db);
}
this.db.prepare('DELETE FROM hive_mind_metadata WHERE key = ?').run(SECURE_DELETE_PENDING_KEY);
Comment thread
djm204 marked this conversation as resolved.
}

close(): void {
this.retryPendingSecureDelete();
this.db.close();
}
}
Loading
Loading