/* RFC-0564: Command handlers for swim.join, swim.leave, swim.members, and swim.status. Uses ephemeral per-command SWIM lifecycle — swim.join starts a UDP probe to the seed node, records the alive event to the genome log, and exits. No long-running daemon. swim.members and swim.status read from the genome log only. Do not implement long-running SWIM gossip daemon — deferred to Phase 2. Do not implement genome log compaction — that is a future RFC. Do not implement key rotation — that is RFC-0558's responsibility. Handlers delegate to the SWIM node — no membership logic lives here. RFC-0564: initial implementation — four command handlers with ephemeral SWIM lifecycle, Ed25519-signed genome log entries, and identity integration via werkstatt.identity.json. 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 { createSocket, type Socket } from "node:dgram"; import { randomBytes } from "node:crypto"; import type { KernelCommandInput, KernelCommandResult, KernelRuntimeContext, } from "@warpgogol/werkstatt-shared/kernel"; import type { SwimConfig, SwimMembershipView, SwimMember, GenomeLogEntry } from "./types.ts"; import { loadSwimConfig, loadOrCreateSwimConfig, CONFIG_FILENAME } from "./config.ts"; import { appendGenomeEntry, readGenomeLog, deriveMembershipView, signGenomeEntry, getGenomeLogSize, isGenomeLogSizeWarning, } from "./genome-log.ts"; import { WerkstattIdentityConfigSchema, type WerkstattIdentityConfig, } from "@warpgogol/werkstatt-shared/passport"; import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; const IDENTITY_FILENAME = "werkstatt.identity.json"; const WERKSTATT_OPERATOR_SIGNING_KEY_ENV = "WERKSTATT_OPERATOR_SIGNING_KEY"; const PROBE_TIMEOUT_MS = 2000; interface SwimJoinResult { joined: boolean; workshopId: string; seedNode: string; members: SwimMembershipView; diagnostics?: string[]; } interface SwimLeaveResult { left: boolean; workshopId: string; diagnostics?: string[]; } interface SwimStatusResult { configured: boolean; workshopId: string | null; bindAddr: string | null; genomeLogSize: number; genomeLogSizeWarning: boolean; members: SwimMembershipView; diagnostics?: string[]; } async function loadIdentityConfig( workspaceRoot: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const identityPath = join(workspaceRoot, IDENTITY_FILENAME); const raw = await io.readFile(identityPath); const parsed = JSON.parse(raw); return WerkstattIdentityConfigSchema.parse(parsed); } function getSigningKey(): string { const key = process.env[WERKSTATT_OPERATOR_SIGNING_KEY_ENV]; if (!key || key.length === 0) { throw new Error(`${WERKSTATT_OPERATOR_SIGNING_KEY_ENV} environment variable is not set`); } return key; } async function probeSeedNode(seedAddr: string): Promise { return new Promise((resolve) => { const socket: Socket = createSocket("udp4"); const message = Buffer.from(randomBytes(16)); let settled = false; const cleanup = () => { if (!settled) { settled = true; socket.close(); } }; socket.on("error", () => { cleanup(); resolve(false); }); socket.on("message", () => { cleanup(); resolve(true); }); setTimeout(() => { cleanup(); resolve(false); }, PROBE_TIMEOUT_MS); const [host, portStr] = seedAddr.split(":"); const port = Number.parseInt(portStr, 10); if (!host || !Number.isFinite(port)) { cleanup(); resolve(false); return; } socket.send(message, 0, message.length, port, host, (err) => { if (err) { cleanup(); resolve(false); } }); }); } export async function runSwimJoin( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot } = context; const seed = input.flags["seed"] as string | undefined; if (!seed) { return { data: { joined: false, workshopId: "", seedNode: "", members: { members: [], total: 0, alive: 0, suspect: 0, dead: 0 }, diagnostics: ["swim.join: --seed flag is required"], }, exitCode: 1, summary: "[swim.join] --seed flag is required", nextSteps: [ { action: "Provide the --seed flag (host:port) and re-run swim.join", kind: "required" }, ], }; } let identity: WerkstattIdentityConfig; try { identity = await loadIdentityConfig(workspaceRoot); } catch { return { data: { joined: false, workshopId: "", seedNode: seed, members: { members: [], total: 0, alive: 0, suspect: 0, dead: 0 }, diagnostics: [ `swim.join: no ${IDENTITY_FILENAME} found — run identity.bootstrap first (RFC-0558)`, ], }, exitCode: 1, summary: `[swim.join] identity not bootstrapped — run identity.bootstrap first`, nextSteps: [ { action: "Run identity.bootstrap first (RFC-0558), then re-run swim.join", kind: "required", }, ], }; } let signingKey: string; try { signingKey = getSigningKey(); } catch { return { data: { joined: false, workshopId: "", seedNode: seed, members: { members: [], total: 0, alive: 0, suspect: 0, dead: 0 }, diagnostics: [ `swim.join: ${WERKSTATT_OPERATOR_SIGNING_KEY_ENV} environment variable is not set`, ], }, exitCode: 1, summary: `[swim.join] ${WERKSTATT_OPERATOR_SIGNING_KEY_ENV} not set`, nextSteps: [ { action: `Set the ${WERKSTATT_OPERATOR_SIGNING_KEY_ENV} environment variable and re-run swim.join`, kind: "required", }, ], }; } const config = await loadOrCreateSwimConfig(workspaceRoot, seed); const seedReachable = await probeSeedNode(seed); if (!seedReachable) { return { data: { joined: false, workshopId: config.workshopId, seedNode: seed, members: { members: [], total: 0, alive: 0, suspect: 0, dead: 0 }, diagnostics: [`swim.join: seed node ${seed} is unreachable`], }, exitCode: 1, summary: `[swim.join] seed node ${seed} is unreachable`, nextSteps: [ { action: `Check that seed node ${seed} is reachable and re-run swim.join`, kind: "required", }, ], }; } const timestamp = new Date().toISOString(); const entry = await signGenomeEntry( { workshopId: config.workshopId, event: "alive", timestamp, source: config.workshopId, }, signingKey, ); await appendGenomeEntry(workspaceRoot, entry); const { entries } = await readGenomeLog( workspaceRoot, identity.operatorKeyPair.publicKeyMultibase, ); const membershipView = deriveMembershipView(entries); return { data: { joined: true, workshopId: config.workshopId, seedNode: seed, members: membershipView, }, exitCode: 0, summary: `[swim.join] joined as ${config.workshopId} via seed ${seed} (${membershipView.alive} alive, ${membershipView.total} total)`, }; } export async function runSwimLeave( _input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot } = context; let config: SwimConfig; try { config = await loadSwimConfig(workspaceRoot); } catch { return { data: { left: false, workshopId: "", diagnostics: [`swim.leave: no ${CONFIG_FILENAME} found — run swim.join first`], }, exitCode: 1, summary: `[swim.leave] no ${CONFIG_FILENAME} found`, nextSteps: [ { action: "Run swim.join first to configure SWIM, then re-run swim.leave", kind: "required", }, ], }; } let signingKey: string; try { signingKey = getSigningKey(); } catch { return { data: { left: false, workshopId: config.workshopId, diagnostics: [ `swim.leave: ${WERKSTATT_OPERATOR_SIGNING_KEY_ENV} environment variable is not set`, ], }, exitCode: 1, summary: `[swim.leave] ${WERKSTATT_OPERATOR_SIGNING_KEY_ENV} not set`, nextSteps: [ { action: `Set the ${WERKSTATT_OPERATOR_SIGNING_KEY_ENV} environment variable and re-run swim.leave`, kind: "required", }, ], }; } const timestamp = new Date().toISOString(); const entry: GenomeLogEntry = await signGenomeEntry( { workshopId: config.workshopId, event: "left", timestamp, source: config.workshopId, }, signingKey, ); await appendGenomeEntry(workspaceRoot, entry); return { data: { left: true, workshopId: config.workshopId, }, exitCode: 0, summary: `[swim.leave] workshop ${config.workshopId} left the network`, }; } interface SwimMembersResult { members: SwimMember[]; total: number; alive: number; suspect: number; dead: number; diagnostics?: string[]; } export async function runSwimMembers( _input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot } = context; let _config: SwimConfig; try { _config = await loadSwimConfig(workspaceRoot); } catch { return { data: { members: [], total: 0, alive: 0, suspect: 0, dead: 0, diagnostics: [`swim.members: no ${CONFIG_FILENAME} found — run swim.join first`], }, exitCode: 1, summary: `[swim.members] no ${CONFIG_FILENAME} found`, nextSteps: [ { action: "Run swim.join first to configure SWIM, then re-run swim.members", kind: "required", }, ], }; } let publicKey: string | undefined; try { const identity = await loadIdentityConfig(workspaceRoot); publicKey = identity.operatorKeyPair.publicKeyMultibase; } catch { // Read without signature verification if identity is not available } const { entries } = await readGenomeLog(workspaceRoot, publicKey); const view = deriveMembershipView(entries); return { data: { members: view.members, total: view.total, alive: view.alive, suspect: view.suspect, dead: view.dead, }, exitCode: 0, summary: `[swim.members] ${view.total} members (${view.alive} alive, ${view.suspect} suspect, ${view.dead} dead)`, }; } export async function runSwimStatus( _input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot } = context; let config: SwimConfig | null = null; try { config = await loadSwimConfig(workspaceRoot); } catch { // Config not yet created } const genomeLogSize = await getGenomeLogSize(workspaceRoot); const sizeWarning = isGenomeLogSizeWarning(genomeLogSize); let publicKey: string | undefined; try { const identity = await loadIdentityConfig(workspaceRoot); publicKey = identity.operatorKeyPair.publicKeyMultibase; } catch { // Identity not bootstrapped } const { entries, skipped } = await readGenomeLog(workspaceRoot, publicKey); const view = deriveMembershipView(entries); const diagnostics: string[] = []; if (sizeWarning) { diagnostics.push( `swim.status: genome log exceeds 10MB (${Math.round(genomeLogSize / 1024 / 1024)}MB) — consider compaction`, ); } if (skipped > 0) { diagnostics.push(`swim.status: ${skipped} genome log entries skipped (invalid signatures)`); } if (config && view.alive === 0) { diagnostics.push("swim.status: no alive members in genome log — network may be empty"); } return { data: { configured: config !== null, workshopId: config?.workshopId ?? null, bindAddr: config?.bindAddr ?? null, genomeLogSize, genomeLogSizeWarning: sizeWarning, members: view, diagnostics: diagnostics.length > 0 ? diagnostics : undefined, }, exitCode: 0, summary: config ? `[swim.status] workshop ${config.workshopId}, ${view.total} members (${view.alive} alive), genome log ${Math.round(genomeLogSize / 1024)}KB` : `[swim.status] not configured — run swim.join --seed first`, }; }