Zero-dependency Discord connector under packages/discord: binding
validation, REST and gateway clients, pi engine adapter, journal with
append-only inbox, outbox, admissions and notices, and a run.lock
ownership record {pid, start, boot} whose identity is checked three ways
and whose cleanup is gated by STOP. CLI check|run|stop|unlock via
scripts/discord.sh; offline suite scripts/test-discord.sh (28 checks,
87 node tests).
Reviewed by rev-code-02 on #1509 over nine rounds; approved exact tree
4e0feb6758c0a7e4a71483912a8e0d3e3ec95aef at comment 26170. Corrections
(1) to (12) recorded in BUILD-LOG. No listener started, no token read,
no Discord write; the live pilot follows this commit per the brief.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
390 lines
16 KiB
JavaScript
390 lines
16 KiB
JavaScript
// Durable records for one binding under <dataRoot>/discord/<name>/:
|
|
// inbox.jsonl every accepted Discord message id, appended before any
|
|
// other action; the restart guard reads it back
|
|
// outbox.jsonl one line per delivery state change: intent, confirmed,
|
|
// refused, unknown; keyed by nonce
|
|
// drops.jsonl one counter line per dropped or refused inbound message
|
|
// admissions.jsonl one line per turn admitted, before the engine is asked
|
|
// turns/<id>.json one write-once record per turn
|
|
// STOP presence refuses new turns
|
|
// notices.jsonl once-per-day fixed lines already attempted (ceiling)
|
|
// run.lock/ ownership directory (mkdir is atomic) holding owner.json
|
|
// {pid, start, boot}; `stop` signals only a live pid whose
|
|
// start time and boot id match; a stale lock refuses `run`
|
|
// until `unlock`, which is gated by STOP
|
|
// Directories are 0700, files 0600. Lines are appended, never rewritten.
|
|
|
|
import {
|
|
appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync,
|
|
unlinkSync, writeFileSync, writeSync,
|
|
} from "node:fs";
|
|
import { join } from "node:path";
|
|
import { DiscordError } from "./errors.mjs";
|
|
|
|
export const OUTBOX_STATUS = Object.freeze(["intent", "confirmed", "refused", "unknown"]);
|
|
|
|
export function ensureJournal(dir) {
|
|
mkdirSync(join(dir, "turns"), { recursive: true, mode: 0o700 });
|
|
return dir;
|
|
}
|
|
|
|
function appendLine(path, record) {
|
|
const line = JSON.stringify(record);
|
|
if (line.includes("\n")) throw new DiscordError("journal line must not contain a newline", 1);
|
|
appendFileSync(path, line + "\n", { mode: 0o600 });
|
|
}
|
|
|
|
function readLines(path) {
|
|
if (!existsSync(path)) return [];
|
|
const out = [];
|
|
const text = readFileSync(path, "utf8");
|
|
for (const [i, line] of text.split("\n").entries()) {
|
|
if (line.length === 0) continue;
|
|
try {
|
|
out.push(JSON.parse(line));
|
|
} catch (err) {
|
|
throw new DiscordError(`${path}:${i + 1}: not valid JSON (${err.message})`);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// --- inbox ---
|
|
|
|
export function appendInbox(dir, entry) {
|
|
if (typeof entry.id !== "string" || entry.id.length === 0) throw new DiscordError("inbox entry needs a message id", 1);
|
|
appendLine(join(dir, "inbox.jsonl"), entry);
|
|
}
|
|
|
|
export function readInboxIds(dir) {
|
|
return new Set(readLines(join(dir, "inbox.jsonl")).map((e) => e.id).filter((id) => typeof id === "string"));
|
|
}
|
|
|
|
// --- outbox ---
|
|
|
|
export function appendOutbox(dir, entry) {
|
|
if (!OUTBOX_STATUS.includes(entry.status)) throw new DiscordError(`outbox status must be one of ${OUTBOX_STATUS.join(", ")}`, 1);
|
|
if (typeof entry.nonce !== "string" || entry.nonce.length === 0) throw new DiscordError("outbox entry needs a nonce", 1);
|
|
appendLine(join(dir, "outbox.jsonl"), entry);
|
|
}
|
|
|
|
// Latest state per nonce, in first-seen order. An intent with no later line
|
|
// is an "unknown": the process died between the POST and its receipt.
|
|
// `intentAt` is the first line's timestamp for that nonce and never moves;
|
|
// reconcile measures the dedupe window from it, not from the latest retry.
|
|
export function readOutbox(dir) {
|
|
const byNonce = new Map();
|
|
for (const e of readLines(join(dir, "outbox.jsonl"))) {
|
|
if (typeof e.nonce !== "string") continue;
|
|
const prev = byNonce.get(e.nonce);
|
|
const intentAt = prev ? prev.intentAt : e.at;
|
|
byNonce.set(e.nonce, { ...prev, ...e, intentAt });
|
|
}
|
|
return byNonce;
|
|
}
|
|
|
|
export function unresolvedOutbox(dir) {
|
|
return [...readOutbox(dir).values()].filter((e) => e.status === "intent" || e.status === "unknown");
|
|
}
|
|
|
|
// --- drops ---
|
|
|
|
export function appendDrop(dir, entry) {
|
|
appendLine(join(dir, "drops.jsonl"), entry);
|
|
}
|
|
|
|
export function readDrops(dir) {
|
|
return readLines(join(dir, "drops.jsonl"));
|
|
}
|
|
|
|
// --- turns (write-once) ---
|
|
|
|
const TURN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
|
|
export function turnPath(dir, id) {
|
|
if (!TURN_ID.test(String(id))) throw new DiscordError(`invalid turn id: ${JSON.stringify(id)}`, 1);
|
|
return join(dir, "turns", `${id}.json`);
|
|
}
|
|
|
|
export function writeTurn(dir, id, record) {
|
|
const path = turnPath(dir, id);
|
|
let fd;
|
|
try {
|
|
fd = openSync(path, "wx", 0o600);
|
|
} catch (err) {
|
|
if (err.code === "EEXIST") throw new DiscordError(`turn record already exists: ${path}`, 1);
|
|
throw err;
|
|
}
|
|
try {
|
|
writeSync(fd, JSON.stringify({ ...record, id }, null, 2) + "\n");
|
|
} finally {
|
|
closeSync(fd);
|
|
}
|
|
return path;
|
|
}
|
|
|
|
export function readTurn(dir, id) {
|
|
return JSON.parse(readFileSync(turnPath(dir, id), "utf8"));
|
|
}
|
|
|
|
export function listTurns(dir) {
|
|
const turns = join(dir, "turns");
|
|
if (!existsSync(turns)) return [];
|
|
return readdirSync(turns)
|
|
.filter((f) => f.endsWith(".json"))
|
|
.map((f) => JSON.parse(readFileSync(join(turns, f), "utf8")));
|
|
}
|
|
|
|
// The daily ceiling counts admissions on the current UTC date. An admission
|
|
// is appended before the engine is asked, so a turn interrupted by a crash
|
|
// still counts after restart. Refusals are drop lines, not admissions.
|
|
export function utcDate(now) {
|
|
return new Date(now).toISOString().slice(0, 10);
|
|
}
|
|
|
|
export function appendAdmission(dir, entry) {
|
|
if (typeof entry.id !== "string" || typeof entry.at !== "string") throw new DiscordError("admission needs id and at", 1);
|
|
appendLine(join(dir, "admissions.jsonl"), entry);
|
|
}
|
|
|
|
export function countAdmissionsOn(dir, date) {
|
|
const ids = new Set();
|
|
for (const e of readLines(join(dir, "admissions.jsonl"))) {
|
|
if (typeof e.id === "string" && typeof e.at === "string" && e.at.slice(0, 10) === date) ids.add(e.id);
|
|
}
|
|
return ids.size;
|
|
}
|
|
|
|
export function countTurnsOn(dir, date) {
|
|
return listTurns(dir).filter((t) => typeof t.startedAt === "string" && t.startedAt.slice(0, 10) === date).length;
|
|
}
|
|
|
|
// --- stop switch and pid ---
|
|
|
|
export function stopPath(dir) {
|
|
return join(dir, "STOP");
|
|
}
|
|
|
|
export function stopRequested(dir) {
|
|
return existsSync(stopPath(dir));
|
|
}
|
|
|
|
export function requestStop(dir, reason = "stop") {
|
|
const path = stopPath(dir);
|
|
const fd = openSync(path, "a", 0o600);
|
|
try {
|
|
writeSync(fd, JSON.stringify({ at: new Date().toISOString(), reason }) + "\n");
|
|
} finally {
|
|
closeSync(fd);
|
|
}
|
|
return path;
|
|
}
|
|
|
|
export function clearStop(dir) {
|
|
const path = stopPath(dir);
|
|
if (existsSync(path)) unlinkSync(path);
|
|
}
|
|
|
|
// --- run lock ---
|
|
// One directory, <dir>/run.lock, is the ownership primitive: mkdir is atomic,
|
|
// so two starts cannot both create it. The owner record is published inside
|
|
// it by write-then-rename. Nothing reclaims a lock on its own: a lock whose
|
|
// record is missing (a start in progress, or one that crashed between mkdir
|
|
// and rename), or whose owner is dead or a reused pid, refuses `run` until
|
|
// an operator runs `unlock`.
|
|
//
|
|
// STOP is the quiescence gate that serializes `unlock` with every claim.
|
|
// `unlock` writes STOP before it inspects or touches the lock, and a claim
|
|
// re-checks STOP after it has published its record; a claim that finds STOP
|
|
// releases itself and refuses. So no process that claims during an unlock
|
|
// can ever hold the binding, and `unlock` only ever removes a lock whose
|
|
// owner is verified dead or that can no longer be held. Automatic reclaim
|
|
// and compare-then-restore were both rejected in review (#1509 comments
|
|
// 26123 and 26132): a rename proves nothing about which directory it moved.
|
|
|
|
export function lockPath(dir) {
|
|
return join(dir, "run.lock");
|
|
}
|
|
|
|
export function ownerPath(dir) {
|
|
return join(lockPath(dir), "owner.json");
|
|
}
|
|
|
|
// Process identity beyond the pid number: the /proc start time (ticks since
|
|
// boot, which a reused pid cannot reproduce within one boot) and the boot id
|
|
// (so the same pid and ticks after a reboot do not match either). Each is
|
|
// null where it cannot be read.
|
|
// Identity values have a fixed syntax: a start time is the tick count from
|
|
// /proc/<pid>/stat exactly as the kernel prints it (canonical unsigned
|
|
// decimal: no leading zeros, at most 2^64-1, and never zero for a process
|
|
// this connector could own), a boot id is the UUID from
|
|
// /proc/sys/kernel/random/boot_id. Anything else is not an identity and
|
|
// never compares: it reads as absent.
|
|
const START_RE = /^[1-9][0-9]{0,19}$/;
|
|
const START_MAX = 18446744073709551615n;
|
|
const BOOT_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
export function validStart(v) { return typeof v === "string" && START_RE.test(v) && BigInt(v) <= START_MAX; }
|
|
export function validBoot(v) { return typeof v === "string" && BOOT_RE.test(v); }
|
|
|
|
export function processStart(pid) {
|
|
try {
|
|
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
const start = fields[19] ?? null; // starttime is field 22 of stat; 20th after the comm field
|
|
return validStart(start) ? start : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function bootId() {
|
|
try {
|
|
const id = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
return validBoot(id) ? id : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function identityOf(pid) {
|
|
return { start: processStart(pid), boot: bootId() };
|
|
}
|
|
|
|
// {pid, start, boot} from the published owner record; null when there is no
|
|
// record file; {invalid: true} when the file exists but cannot be read or
|
|
// parsed or has no usable pid. An unreadable record is not the same as an
|
|
// absent one: absence is the STOP-gated publication interval, an unreadable
|
|
// record is an owner whose identity cannot be established, and that fails closed.
|
|
export function readPid(dir) {
|
|
const path = ownerPath(dir);
|
|
if (!existsSync(path)) return null;
|
|
try {
|
|
const rec = JSON.parse(readFileSync(path, "utf8"));
|
|
if (rec === null || typeof rec !== "object" || !Number.isInteger(rec.pid) || rec.pid <= 0) return { invalid: true };
|
|
return {
|
|
pid: rec.pid,
|
|
start: validStart(rec.start) ? rec.start : null,
|
|
boot: validBoot(rec.boot) ? rec.boot : null,
|
|
};
|
|
} catch {
|
|
return { invalid: true };
|
|
}
|
|
}
|
|
|
|
export function pidAlive(pid) {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (err) {
|
|
return err.code === "EPERM";
|
|
}
|
|
}
|
|
|
|
// Identity check for a record:
|
|
// "absent" no record file
|
|
// "invalid" a record file that cannot be read, parsed, or has no pid
|
|
// "dead" the pid is not alive
|
|
// "unknown" the pid is alive but identity cannot be established: the
|
|
// record lacks start or boot (an older record), carries a value
|
|
// that is not a start tick or a boot id (corrupt metadata), or
|
|
// the current /proc values cannot be read right now
|
|
// "mismatch" the pid is alive and its identity positively differs
|
|
// "live" the pid is alive and start time and boot id both match
|
|
// Only "live" is ever signaled. "unknown" and "invalid" refuse everything:
|
|
// never signaled, never removed, never claimed over. Once the pid is
|
|
// positively dead, "dead" applies and unlock may clear it. `identity` is a
|
|
// test seam.
|
|
export function ownerState(rec, { identity = identityOf } = {}) {
|
|
if (rec === null) return "absent";
|
|
if (rec.invalid) return "invalid";
|
|
if (!pidAlive(rec.pid)) return "dead";
|
|
if (rec.start === null || rec.boot === null) return "unknown";
|
|
const now = identity(rec.pid);
|
|
if (now.start === null || now.boot === null) return "unknown";
|
|
return now.start === rec.start && now.boot === rec.boot ? "live" : "mismatch";
|
|
}
|
|
|
|
export function ownerAlive(rec, opts) {
|
|
return ownerState(rec, opts) === "live";
|
|
}
|
|
|
|
export const UNLOCK_HINT = "if no connector is running for this binding, run `scripts/discord.sh unlock <binding>`";
|
|
|
|
// Explains why an existing lock refuses a new claim. Always a DiscordError.
|
|
function lockRefusal(dir, opts) {
|
|
const existing = readPid(dir);
|
|
const state = ownerState(existing, opts);
|
|
if (state === "absent") return new DiscordError(`run.lock exists without an owner record: a start is in progress or was interrupted; ${UNLOCK_HINT}`, 1);
|
|
if (state === "invalid") return new DiscordError(`run.lock has an owner record that cannot be read; refusing. Inspect ${ownerPath(dir)} by hand`, 1);
|
|
if (state === "live") return new DiscordError(`another connector is running for this binding (pid ${existing.pid})`, 1);
|
|
if (state === "unknown") return new DiscordError(`run.lock belongs to pid ${existing.pid}, which is alive but whose identity cannot be verified; refusing`, 1);
|
|
return new DiscordError(`run.lock belongs to pid ${existing.pid}, which is gone or is a different process now; ${UNLOCK_HINT}`, 1);
|
|
}
|
|
|
|
export function writePid(dir, pid, { now = Date.now(), identity = identityOf } = {}) {
|
|
const { start, boot } = identity(pid);
|
|
if (start === null || boot === null) throw new DiscordError("cannot read this process's start time or the boot id from /proc; refusing to claim the binding", 1);
|
|
const lock = lockPath(dir);
|
|
try {
|
|
mkdirSync(lock, { mode: 0o700 });
|
|
} catch (err) {
|
|
if (err.code !== "EEXIST") throw err;
|
|
throw lockRefusal(dir, { identity });
|
|
}
|
|
const tmp = join(lock, "owner.json.tmp");
|
|
writeFileSync(tmp, JSON.stringify({ pid, start, boot, at: new Date(now).toISOString() }) + "\n", { mode: 0o600 });
|
|
renameSync(tmp, ownerPath(dir));
|
|
// The gate: STOP written before this point (by `stop` or `unlock`) means
|
|
// this claim must not stand, however it interleaved with an unlock.
|
|
if (stopRequested(dir)) {
|
|
clearPid(dir, pid);
|
|
throw new DiscordError(`STOP is present in ${dir}; remove it to run`, 1);
|
|
}
|
|
}
|
|
|
|
// Operator cleanup, gated by STOP. Writes STOP first, so every claim that
|
|
// publishes from now on releases itself. Refuses while the recorded owner is
|
|
// live (use `stop`) or alive with unverifiable identity (never removed).
|
|
// Refuses an owner record it cannot read. Otherwise removes the lock.
|
|
// Returns the record that was cleared (null for a lock without one), or
|
|
// false when there was no lock. STOP stays in place;
|
|
// remove it to run again. `beforeRemove` and `identity` are test seams.
|
|
export function unlock(dir, { beforeRemove = null, identity = identityOf } = {}) {
|
|
requestStop(dir, "unlock");
|
|
const lock = lockPath(dir);
|
|
if (!existsSync(lock)) return false;
|
|
const rec = readPid(dir);
|
|
const state = ownerState(rec, { identity });
|
|
if (state === "live") throw new DiscordError(`refusing to unlock: the connector is running (pid ${rec.pid}); use stop, and unlock only a lock whose owner is gone`, 1);
|
|
if (state === "unknown") throw new DiscordError(`refusing to unlock: pid ${rec.pid} is alive and its identity cannot be verified; nothing removed. Stop that process first`, 1);
|
|
if (state === "invalid") throw new DiscordError(`refusing to unlock: the owner record cannot be read; nothing removed. Inspect ${ownerPath(dir)} by hand`, 1);
|
|
if (beforeRemove) beforeRemove();
|
|
rmSync(lock, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
return rec;
|
|
}
|
|
|
|
// The verified live owner to signal, or null. Never returns a pid whose
|
|
// identity cannot be proven.
|
|
export function stopTarget(dir, opts) {
|
|
const rec = readPid(dir);
|
|
return ownerAlive(rec, opts) ? rec.pid : null;
|
|
}
|
|
|
|
export function clearPid(dir, pid) {
|
|
const rec = readPid(dir);
|
|
if (rec !== null && !rec.invalid && rec.pid === pid) rmSync(lockPath(dir), { recursive: true, force: true });
|
|
}
|
|
|
|
// --- notices ---
|
|
// Fixed lines that must go out at most once per UTC day (the ceiling
|
|
// notice). The line is appended before the delivery attempt, so a crash
|
|
// mid-delivery does not produce a second attempt after restart.
|
|
export function appendNotice(dir, entry) {
|
|
if (typeof entry.kind !== "string" || typeof entry.date !== "string") throw new DiscordError("notice needs kind and date", 1);
|
|
appendLine(join(dir, "notices.jsonl"), entry);
|
|
}
|
|
|
|
export function noticeOn(dir, kind, date) {
|
|
return readLines(join(dir, "notices.jsonl")).some((e) => e.kind === kind && e.date === date);
|
|
}
|