UNPKG

@essentialai/cogent-bridge

Version:

Cogent Bridge — cross-agent comms for Claude Code, OpenAI Codex and Slack. Codex users: install with `curl -fsSL https://cogent.tools/install.sh | sh`, then start with `cogent-codex` for real-time peer wake.

312 lines • 12.9 kB
import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { getConfig } from "../config.js"; import { logger } from "../logger.js"; import { BridgeError, BridgeErrorCode } from "../errors.js"; /* ------------------------------------------------------------------ */ /* File-based shared state */ /* Two or more MCP server processes share a state JSON file */ /* State path is config-driven via COGENT_STATE_PATH */ /* ------------------------------------------------------------------ */ const MAX_MESSAGES = 1500; const LOCK_RETRY_MS = 50; const LOCK_MAX_WAIT_MS = 5_000; /** Derive state file path from config at call time (not module load time). */ function getStatePath() { return path.join(getConfig().COGENT_STATE_PATH, "cogent-state.json"); } /** Derive lock file path from state path at call time. */ function getLockPath() { return getStatePath() + ".lock"; } /* ---- low-level helpers ------------------------------------------- */ function emptyState() { return { peers: {}, messages: [] }; } /** Migrate legacy state: ensure all peers have lastSeenAt field. */ function migrateState(state) { for (const peer of Object.values(state.peers)) { if (!peer.lastSeenAt) { peer.lastSeenAt = peer.registeredAt; } } return state; } async function readState() { const statePath = getStatePath(); try { const raw = await fs.readFile(statePath, "utf-8"); return migrateState(JSON.parse(raw)); } catch (err) { if (err.code === "ENOENT") { return migrateState(emptyState()); } if (err instanceof SyntaxError) { // Corrupt JSON -- auto-recover with backup const backupPath = statePath + ".corrupt." + Date.now(); try { await fs.copyFile(statePath, backupPath); } catch { // Backup failure is non-fatal } logger.warn(`STATE_CORRUPT: State file corrupt (invalid JSON), backed up to ${backupPath}. Starting with empty state.`); return migrateState(emptyState()); } throw new BridgeError(BridgeErrorCode.STATE_WRITE_FAILED, `Failed to read state file: ${err.message}`, `Check permissions on ${statePath}`); } } async function writeState(state) { const statePath = getStatePath(); const tmp = statePath + "." + process.pid + ".tmp"; try { await fs.writeFile(tmp, JSON.stringify(state, null, 2), "utf-8"); await fs.rename(tmp, statePath); // atomic on same filesystem } catch (err) { throw new BridgeError(BridgeErrorCode.STATE_WRITE_FAILED, `Failed to write state file: ${err.message}`, `Check permissions on ${statePath}`); } } /* ---- file-lock (O_CREAT | O_EXCL) ------------------------------- */ async function acquireLock() { const lockPath = getLockPath(); const deadline = Date.now() + LOCK_MAX_WAIT_MS; while (Date.now() < deadline) { try { // Atomic create-if-not-exists await fs.writeFile(lockPath, String(process.pid), { flag: "wx", // O_WRONLY | O_CREAT | O_EXCL }); return; // lock acquired } catch (err) { if (err.code === "EEXIST") { // Check for stale lock (process died) try { const content = await fs.readFile(lockPath, "utf-8"); const lockPid = parseInt(content, 10); if (!isNaN(lockPid)) { try { process.kill(lockPid, 0); // just checks existence } catch { // Holder PID looks dead -- candidate stale lock. But it may have // been released and RE-acquired by another process since our read // (TOCTOU). Re-read and unlink ONLY if the holder PID is still the // same dead PID, so we never delete a freshly-acquired lock. let removed = false; try { const recheck = parseInt(await fs.readFile(lockPath, "utf-8"), 10); if (recheck === lockPid) { await fs.unlink(lockPath); removed = true; } } catch { // Lock vanished or re-read failed -- fall through and back off. } if (removed) { logger.info(`Cleaned up stale lock from PID ${lockPid}`); continue; // lock freed -- reclaim immediately } // Holder changed, or unlink failed: back off before retrying so a // persistently-undeletable lock cannot hot-spin the event loop. await sleep(LOCK_RETRY_MS); continue; } } } catch { /* lock file disappeared, retry */ continue; } await sleep(LOCK_RETRY_MS); continue; } throw err; } } // Timeout -- read lock to report holder PID let lockPid = "unknown"; try { const content = await fs.readFile(lockPath, "utf-8"); lockPid = content.trim(); } catch { /* ignore read failure */ } throw new BridgeError(BridgeErrorCode.LOCK_TIMEOUT, `Failed to acquire lock within ${LOCK_MAX_WAIT_MS}ms (held by PID ${lockPid})`, `Another cogent process may be stuck. Delete ${lockPath} if no other instance is running.`); } async function releaseLock() { await fs.unlink(getLockPath()).catch(() => { }); } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } /** Run a mutating operation under an exclusive file lock. */ async function withLock(fn) { await acquireLock(); try { return await fn(); } finally { await releaseLock(); } } /* ---- public API (all async) -------------------------------------- */ export async function registerPeer(peerId, sessionId, cwd, label, clientVersion, mode, channelSessionId, capabilities, workspaceId, threadId) { return withLock(async () => { const state = await readState(); const now = new Date().toISOString(); const peer = { peerId, sessionId, cwd, label, registeredAt: now, lastSeenAt: now, }; // Conditional set: never write { clientVersion: undefined } onto the // PeerInfo object — keeps JSON.stringify output free of placeholder fields // for older clients that omit the version (3.1.6 backward-compat contract). if (clientVersion !== undefined) { peer.clientVersion = clientVersion; } // Same conditional-set pattern for the new 3.2.0 mode field (Phase 17 // OBS-01). Absent === "agent" by default — don't serialize the field // unless an operator explicitly opts in. Keeps local state JSON clean // for upgrade-only operators. if (mode !== undefined) { peer.mode = mode; } // Same conditional-set contract for the 3.5.9 durable-identity rails — // never serialize undefined so pre-3.5.9 state stays byte-for-byte clean. if (channelSessionId !== undefined) { peer.channelSessionId = channelSessionId; } if (capabilities !== undefined) { peer.capabilities = capabilities; } // Slice 5 / Story 8.6 durable identity (same conditional-set contract). if (workspaceId !== undefined) { peer.workspaceId = workspaceId; } if (threadId !== undefined) { peer.threadId = threadId; } state.peers[peerId] = peer; await writeState(state); return peer; }); } export async function updateLastSeen(peerId) { return withLock(async () => { const state = await readState(); const peer = state.peers[peerId]; if (peer) { peer.lastSeenAt = new Date().toISOString(); await writeState(state); } }); } export async function deregisterPeer(peerId) { return withLock(async () => { const state = await readState(); if (!(peerId in state.peers)) return false; delete state.peers[peerId]; await writeState(state); return true; }); } export async function getPeer(peerId) { const state = await readState(); return state.peers[peerId]; } export async function listPeers() { const state = await readState(); return Object.values(state.peers); } export async function recordMessage(record) { return withLock(async () => { const state = await readState(); const full = { ...record, id: crypto.randomUUID(), timestamp: new Date().toISOString(), }; state.messages.push(full); // Cap history to prevent unbounded growth if (state.messages.length > MAX_MESSAGES) { state.messages = state.messages.slice(-MAX_MESSAGES); } await writeState(state); return full; }); } /** * The metadataOnly projection contract — the EXACT key set, sorted. * * One documented contract has two implementations: this file (local mode) and * server/src/routes/messages.ts (the relay). They silently disagreed for the * whole of Cogent Mail M2.5 — the relay gained attachmentCount, local mode did * not — because each was tested only against itself. This const is the bridge * side's single source of truth: the local projection is asserted against it, * and src/e2e/metadata-contract.e2e.test.ts asserts a REAL relay response * against the same list, so the two cannot drift apart without a test going * red. The tool description in src/tools/get-history.ts must name these keys. */ export const METADATA_ONLY_KEYS = [ "attachmentCount", "durationMs", "fromPeerId", "id", "isRelayEcho", "success", "timestamp", "toPeerId", ]; export async function getHistory(peerId, limit = 150, includeRelayEchoes = false, metadataOnly = false) { const state = await readState(); let filtered = state.messages; // 260511-o3p: local-mode mirror of the server-side filter. Strip // auto-relay echoes before peerId narrowing and before the limit slice, // so the budget is spent on real records, not on the duplicate // FE->BE response mirrors. if (!includeRelayEchoes) { filtered = filtered.filter((m) => m.isRelayEcho !== true); } if (peerId) { filtered = filtered.filter((m) => m.fromPeerId === peerId || m.toPeerId === peerId); } const sliced = filtered.slice(-limit); // HIST-02: when metadataOnly is true, build a NEW object literal with // exactly the 8 contract keys — message and response are absent (not // nulled, not truncated). Constructed via literal (not delete-on-clone) // so JSON.stringify output has no placeholder keys. For older records // without isRelayEcho, emit false so the 8-key contract is stable. // // 🔴 attachmentCount was added to the CLOUD projection by Cogent Mail M2.5 // (server/src/routes/messages.ts) and never mirrored here, so the same tool // with the same flag returned 7 keys locally and 8 through the relay — one // documented contract, two implementations. Local mode never persists // attachments, so the value is always 0; emitting it anyway is the whole // point of a STABLE contract, and is the same reason isRelayEcho is emitted // as false rather than omitted on records that predate the field. // Structural subset for HIST-02 metadataOnly contract; consumer treats // response as opaque JSON, so the MessageRecord[] cast is acceptable. if (metadataOnly) { const projected = sliced.map((m) => ({ id: m.id, fromPeerId: m.fromPeerId, toPeerId: m.toPeerId, timestamp: m.timestamp, durationMs: m.durationMs, isRelayEcho: m.isRelayEcho === true, success: m.success, attachmentCount: m.attachments?.length ?? 0, })); return projected; } return sliced; } //# sourceMappingURL=peer-registry.js.map