/*
RFC-0564: CRDT genome log implementation. The genome log is a G-Set (Grow-Only
Set) of signed GenomeLogEntry records stored as append-only NDJSON in
werkstatt.genome.log. Provides append, read (with signature verification),
G-Set union merge, membership view derivation, Ed25519 signing, and file size
reporting for the 10MB threshold warning.
- Do not implement SWIM protocol gossip — that lives in handlers.ts.
- Do not implement config loading — that lives in config.ts.
- Do not implement compaction — that is a future RFC.
- The genome log is a G-Set CRDT — entries are grow-only, never removed.
- RFC-0564: initial implementation — G-Set genome log with Ed25519 signing, signature verification on read, set-union merge, and membership view derivation.
- RFC-1097: step 6 — compass.migrate codemod run
Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order.
- RFC-1097: sweep — werkstatt-engine clean
Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics.
*/
import { join } from "node:path";
import { signBytes, verifyBytes } from "@warpgogol/werkstatt-shared/passport";
import type { GenomeLogEntry, SwimMembershipView, SwimMember, SwimMemberStatus } from "./types.ts";
import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
const GENOME_LOG_FILENAME = "werkstatt.genome.log";
const GENOME_LOG_SIZE_THRESHOLD = 10 * 1024 * 1024;
function canonicalEntryBytes(entry: Omit): Uint8Array {
const canonical = JSON.stringify({
workshopId: entry.workshopId,
event: entry.event,
timestamp: entry.timestamp,
source: entry.source,
});
return new Uint8Array(Buffer.from(canonical, "utf8"));
}
export async function signGenomeEntry(
entry: Omit,
privateKeyHex: string,
): Promise {
const signature = await signBytes(privateKeyHex, canonicalEntryBytes(entry));
return { ...entry, signature };
}
export async function verifyGenomeEntry(
entry: GenomeLogEntry,
publicKeyMultibase: string,
): Promise {
const { signature, ...rest } = entry;
return verifyBytes(publicKeyMultibase, canonicalEntryBytes(rest), signature);
}
export async function appendGenomeEntry(
workspaceRoot: string,
entry: GenomeLogEntry,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
const logPath = join(workspaceRoot, GENOME_LOG_FILENAME);
const line = JSON.stringify(entry) + "\n";
await io.appendFile(logPath, line);
}
export async function readGenomeLog(
workspaceRoot: string,
publicKeyMultibase?: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise<{ entries: GenomeLogEntry[]; skipped: number }> {
const logPath = join(workspaceRoot, GENOME_LOG_FILENAME);
let raw: string;
try {
raw = await io.readFile(logPath);
} catch {
return { entries: [], skipped: 0 };
}
const lines = raw.split("\n").filter((l) => l.trim().length > 0);
const entries: GenomeLogEntry[] = [];
let skipped = 0;
for (const line of lines) {
try {
const parsed = JSON.parse(line) as GenomeLogEntry;
if (publicKeyMultibase) {
const valid = await verifyGenomeEntry(parsed, publicKeyMultibase);
if (!valid) {
skipped++;
continue;
}
}
entries.push(parsed);
} catch {
skipped++;
}
}
return { entries, skipped };
}
function entryIdentity(entry: GenomeLogEntry): string {
return `${entry.workshopId}|${entry.event}|${entry.timestamp}|${entry.source}`;
}
export function mergeGenomeLogs(local: GenomeLogEntry[], peer: GenomeLogEntry[]): GenomeLogEntry[] {
const seen = new Set();
const result: GenomeLogEntry[] = [];
for (const entry of [...local, ...peer]) {
const id = entryIdentity(entry);
if (!seen.has(id)) {
seen.add(id);
result.push(entry);
}
}
return result;
}
export function deriveMembershipView(entries: GenomeLogEntry[]): SwimMembershipView {
const latestByWorkshop = new Map();
for (const entry of entries) {
const existing = latestByWorkshop.get(entry.workshopId);
if (!existing || entry.timestamp > existing.timestamp) {
latestByWorkshop.set(entry.workshopId, entry);
}
}
const members: SwimMember[] = [];
let alive = 0;
let suspect = 0;
let dead = 0;
for (const [workshopId, entry] of latestByWorkshop) {
const status = entry.event as SwimMemberStatus;
if (status === "alive") alive++;
else if (status === "suspect") suspect++;
else if (status === "dead") dead++;
members.push({
workshopId,
endpoint: "",
operatorVC: "",
status,
joinedAt: entry.timestamp,
lastSeen: entry.timestamp,
});
}
const total = members.length;
return { members, total, alive, suspect, dead };
}
export async function getGenomeLogSize(
workspaceRoot: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
const logPath = join(workspaceRoot, GENOME_LOG_FILENAME);
try {
const stats = await io.stat(logPath);
return stats.size;
} catch {
return 0;
}
}
export function isGenomeLogSizeWarning(size: number): boolean {
return size > GENOME_LOG_SIZE_THRESHOLD;
}
export { GENOME_LOG_FILENAME, GENOME_LOG_SIZE_THRESHOLD };