server/boat-delete-journal.ts
Rama main · réplica de lectura del repositorio original.
import { createHash, randomUUID } from "node:crypto";
import { linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { writeFileAtomic } from "./atomic.ts";
import { DATA_DIR } from "./config.ts";
const FILE = join(DATA_DIR, "box-delete-requests.json");
const LOCK_FILE = join(DATA_DIR, "box-delete-requests.lock");
const MAX_RECORDS = 4_096;
const LOCK_WAIT_MS = 2_000;
const LOCK_RETRY_MS = 20;
const MAX_REAPER_GENERATIONS = 128;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const BOT_ID = /^[A-Za-z0-9_-]{1,120}$/;
const BOAT_ID = /^bx_[23456789abcdefghjkmnpqrstuvwxyz]{8}$/;
const OPERATION_ID = /^bdop_[a-f0-9]{32}$/;
const PROVIDER_STATUSES = new Set(["pending", "processing", "blocked", "completed"]);
/** Codes of a create refused while the bot's previous computer is being removed, or right after its removal finished. */
export const BOAT_DELETING = "computer_deleting", BOAT_DELETION_FINISHED = "previous_deletion_finished";
export type BoatDeletionPhase = "prepared" | "accepted" | "blocked";
export type BoatDeletionStatus = "pending" | "processing" | "blocked" | "completed";
export interface BoatDeletionRecord {
boxId: string;
name: string;
ownerBotId: string | null;
phase: BoatDeletionPhase;
operationId?: string;
status?: BoatDeletionStatus;
requestedAt: number;
updatedAt: number;
}
export interface BoatDeletionIdentity {
boxId: string;
name: string;
ownerBotId: string | null;
}
export interface BoatDeletionOperationReceipt {
id: string;
kind: "sandbox";
targetId: string;
status: BoatDeletionStatus;
}
interface JournalFile {
version: 1;
records: BoatDeletionRecord[];
}
interface JournalLockOwner {
version: 1;
pid: number;
token: string;
createdAt: number;
}
interface JournalReaperOwner extends JournalLockOwner {
targetToken: string;
}
interface LegacyJournalReaperOwner {
legacy: true;
pid: number;
}
const lockWait = new Int32Array(new SharedArrayBuffer(4));
function validTimestamp(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
}
function validName(value: unknown): value is string {
return typeof value === "string"
&& value.length > 0
&& value.length <= 100
&& !/[\r\n\0]/.test(value);
}
function isRecord(value: unknown): value is BoatDeletionRecord {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const record = value as Record<string, unknown>;
if (
typeof record.boxId !== "string"
|| !BOAT_ID.test(record.boxId)
|| !validName(record.name)
|| !(record.ownerBotId === null || (typeof record.ownerBotId === "string" && BOT_ID.test(record.ownerBotId)))
|| !(["prepared", "accepted", "blocked"] as unknown[]).includes(record.phase)
|| !validTimestamp(record.requestedAt)
|| !validTimestamp(record.updatedAt)
|| record.updatedAt < record.requestedAt
|| !(record.operationId === undefined || (typeof record.operationId === "string" && OPERATION_ID.test(record.operationId)))
|| !(record.status === undefined || (typeof record.status === "string" && PROVIDER_STATUSES.has(record.status)))
) return false;
if (record.phase === "prepared") return record.operationId === undefined && record.status === undefined;
if (record.phase === "accepted") {
return typeof record.operationId === "string"
&& (record.status === "pending" || record.status === "processing" || record.status === "completed");
}
return record.status === "blocked";
}
function stateError(detail: string, cause?: unknown): Error & { status: number } {
return Object.assign(
new Error(
`Cloud computer deletion is paused because its recovery state is ${detail}. `
+ "Check the Boat provider before repairing OpenMausBot's local state.",
),
{ status: 503, cause },
);
}
function clone(record: BoatDeletionRecord): BoatDeletionRecord {
return { ...record };
}
function nextUpdatedAt(record: BoatDeletionRecord): number {
return Math.max(Date.now(), record.updatedAt);
}
function loadFresh(): BoatDeletionRecord[] {
let raw: unknown;
try {
raw = JSON.parse(readFileSync(FILE, "utf8"));
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return [];
throw stateError("unreadable", error);
}
const journal = raw as Partial<JournalFile>;
if (
!journal
|| typeof journal !== "object"
|| Array.isArray(journal)
|| journal.version !== 1
|| !Array.isArray(journal.records)
|| journal.records.length > MAX_RECORDS
|| !journal.records.every(isRecord)
) throw stateError("invalid");
const boatIds = new Set<string>();
const operationIds = new Set<string>();
for (const record of journal.records) {
if (boatIds.has(record.boxId)) throw stateError("invalid");
boatIds.add(record.boxId);
if (record.operationId) {
if (operationIds.has(record.operationId)) throw stateError("invalid");
operationIds.add(record.operationId);
}
}
return journal.records.map(clone);
}
function save(records: BoatDeletionRecord[]): void {
try {
mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 });
writeFileAtomic(FILE, `${JSON.stringify({ version: 1, records }, null, 2)}\n`, { mode: 0o600 });
} catch (error) {
throw stateError("unavailable", error);
}
}
function isLockOwner(value: unknown): value is JournalLockOwner {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const owner = value as Record<string, unknown>;
return owner.version === 1
&& Number.isInteger(owner.pid)
&& Number(owner.pid) > 0
&& Number(owner.pid) <= 0x7fffffff
&& typeof owner.token === "string"
&& UUID.test(owner.token)
&& validTimestamp(owner.createdAt);
}
function readLockOwner(): JournalLockOwner | null {
let raw: string;
try {
raw = readFileSync(LOCK_FILE, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return null;
throw stateError("lock is unreadable", error);
}
try {
const owner: unknown = JSON.parse(raw);
if (!isLockOwner(owner)) throw stateError("lock is invalid");
return owner;
} catch (error) {
if ((error as Error & { status?: number }).status === 503) throw error;
throw stateError("lock is invalid", error);
}
}
function processIsAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code;
if (code === "ESRCH") return false;
if (code === "EPERM") return true;
throw stateError("lock owner could not be verified", error);
}
}
function unlinkCandidate(path: string): void {
try {
unlinkSync(path);
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") {
throw stateError("lock cleanup failed", error);
}
}
}
function readReaperOwner(
path: string,
targetToken: string,
): JournalReaperOwner | LegacyJournalReaperOwner | null {
let raw: string;
try {
raw = readFileSync(path, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return null;
throw stateError("stale-lock recovery record is unreadable", error);
}
if (/^[1-9][0-9]*\n?$/.test(raw)) {
const pid = Number(raw.trim());
if (Number.isInteger(pid) && pid <= 0x7fffffff) return { legacy: true, pid };
}
try {
const owner: unknown = JSON.parse(raw);
if (!isLockOwner(owner) || !("targetToken" in owner) || owner.targetToken !== targetToken) {
throw stateError("stale-lock recovery record is invalid");
}
return owner as JournalReaperOwner;
} catch (error) {
if ((error as Error & { status?: number }).status === 503) throw error;
throw stateError("stale-lock recovery record is invalid", error);
}
}
function publishReaper(path: string, owner: JournalReaperOwner): boolean {
const candidate = `${path}.candidate-${owner.pid}-${owner.token}`;
try {
writeFileSync(candidate, `${JSON.stringify(owner)}\n`, { flag: "wx", mode: 0o600, flush: true });
} catch (error) {
throw stateError("stale-lock recovery candidate is unavailable", error);
}
try {
try {
linkSync(candidate, path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "EEXIST") return false;
throw stateError("stale-lock recovery is unavailable", error);
}
} finally {
unlinkCandidate(candidate);
}
}
function successorReaperPath(targetToken: string, identity: string): string {
const digest = createHash("sha256").update(identity).digest("hex").slice(0, 32);
return `${LOCK_FILE}.reap-${targetToken}-${digest}`;
}
function claimReaperAuthority(expected: JournalLockOwner): boolean {
let reaperPath = `${LOCK_FILE}.reap-${expected.token}`;
for (let generation = 0; generation < MAX_REAPER_GENERATIONS; generation += 1) {
const candidate: JournalReaperOwner = {
version: 1,
pid: process.pid,
token: randomUUID(),
createdAt: Date.now(),
targetToken: expected.token,
};
if (publishReaper(reaperPath, candidate)) return true;
const current = readReaperOwner(reaperPath, expected.token);
if (!current) continue;
if (processIsAlive(current.pid)) return false;
const identity = "legacy" in current ? `legacy:${current.pid}` : current.token;
reaperPath = successorReaperPath(expected.token, identity);
}
throw stateError("stale lock could not be recovered after repeated interrupted attempts");
}
function reapDeadLock(expected: JournalLockOwner): boolean {
if (!claimReaperAuthority(expected)) return false;
const current = readLockOwner();
if (!current || current.token !== expected.token) return true;
if (processIsAlive(current.pid)) return false;
try {
unlinkSync(LOCK_FILE);
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") {
throw stateError("stale lock could not be retired", error);
}
}
return true;
}
function acquireJournalLock(): JournalLockOwner {
try {
mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 });
} catch (error) {
throw stateError("lock directory is unavailable", error);
}
const owner: JournalLockOwner = {
version: 1,
pid: process.pid,
token: randomUUID(),
createdAt: Date.now(),
};
const candidate = `${LOCK_FILE}.candidate-${owner.pid}-${owner.token}`;
try {
writeFileSync(candidate, `${JSON.stringify(owner)}\n`, { flag: "wx", mode: 0o600, flush: true });
} catch (error) {
throw stateError("lock candidate is unavailable", error);
}
const deadline = performance.now() + LOCK_WAIT_MS;
try {
for (;;) {
try {
linkSync(candidate, LOCK_FILE);
return owner;
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code !== "EEXIST") {
throw stateError("lock could not be acquired", error);
}
}
const current = readLockOwner();
const reaped = current !== null && !processIsAlive(current.pid) && reapDeadLock(current);
if (performance.now() >= deadline) throw stateError("locked by another OpenMausBot process");
if (reaped) continue;
Atomics.wait(lockWait, 0, 0, LOCK_RETRY_MS);
}
} finally {
unlinkCandidate(candidate);
}
}
function releaseJournalLock(owner: JournalLockOwner): void {
const current = readLockOwner();
if (!current || current.token !== owner.token || current.pid !== owner.pid) {
throw stateError("lock ownership changed unexpectedly");
}
try {
unlinkSync(LOCK_FILE);
} catch (error) {
throw stateError("lock could not be released", error);
}
}
function withJournalLock<T>(operation: (records: BoatDeletionRecord[]) => T): T {
const owner = acquireJournalLock();
try {
return operation(loadFresh());
} finally {
releaseJournalLock(owner);
}
}
function validateIdentity(identity: BoatDeletionIdentity): void {
if (!BOAT_ID.test(identity.boxId)) throw new Error("invalid cloud computer id for deletion");
if (!validName(identity.name)) throw new Error("invalid cloud computer name for deletion");
if (!(identity.ownerBotId === null || BOT_ID.test(identity.ownerBotId))) {
throw new Error("invalid cloud computer owner for deletion");
}
}
function parseOperation(
boxId: string,
value: unknown,
expectedStatus?: BoatDeletionStatus,
): BoatDeletionOperationReceipt {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("invalid cloud computer deletion operation");
}
const operation = value as Record<string, unknown>;
if (
typeof operation.id !== "string"
|| !OPERATION_ID.test(operation.id)
|| operation.kind !== "sandbox"
|| operation.targetId !== boxId
|| typeof operation.status !== "string"
|| !PROVIDER_STATUSES.has(operation.status)
|| (expectedStatus !== undefined && operation.status !== expectedStatus)
) throw new Error("invalid or mismatched cloud computer deletion operation");
return operation as unknown as BoatDeletionOperationReceipt;
}
/** The immutable target is written before DELETE is sent; in-flight work wins, and a blocked operation may be retried explicitly. */
export function prepareBoatDeletion(identity: BoatDeletionIdentity): BoatDeletionRecord {
validateIdentity(identity);
return withJournalLock((records) => {
const existing = records.find((record) => record.boxId === identity.boxId);
if (existing) {
if (existing.name !== identity.name || existing.ownerBotId !== identity.ownerBotId) {
throw stateError("conflicted with the remembered deletion target");
}
if (existing.phase !== "blocked") return clone(existing);
}
if (!existing && records.length >= MAX_RECORDS) throw stateError("full");
const now = Date.now();
const prepared: BoatDeletionRecord = {
...identity,
phase: "prepared",
requestedAt: now,
updatedAt: now,
};
save([...records.filter((record) => record.boxId !== identity.boxId), prepared]);
return clone(prepared);
});
}
/** Binds a provider receipt to this exact target, so another Boat's operation can never attach to the fence. */
export function markBoatDeletionAccepted(boxId: string, value: unknown): BoatDeletionRecord {
if (!BOAT_ID.test(boxId)) throw new Error("invalid cloud computer id for deletion");
const operation = parseOperation(boxId, value);
if (operation.status === "blocked") {
throw new Error("blocked cloud computer deletion must use markBoatDeletionBlocked");
}
const acceptedStatus = operation.status;
return withJournalLock((records) => {
const existing = records.find((record) => record.boxId === boxId);
if (!existing || existing.phase === "blocked") throw stateError("out of date");
if (existing.operationId && existing.operationId !== operation.id) {
throw stateError("conflicted with another deletion operation");
}
if (records.some((record) => record.boxId !== boxId && record.operationId === operation.id)) {
throw stateError("conflicted with another deletion operation");
}
const ranks: Record<Exclude<BoatDeletionStatus, "blocked">, number> = {
pending: 0,
processing: 1,
completed: 2,
};
if (
existing.phase === "accepted"
&& existing.status !== undefined
&& existing.status !== "blocked"
&& ranks[acceptedStatus] < ranks[existing.status]
) return clone(existing);
const accepted: BoatDeletionRecord = {
...existing,
phase: "accepted",
operationId: operation.id,
status: acceptedStatus,
updatedAt: nextUpdatedAt(existing),
};
save(records.map((record) => record.boxId === boxId ? accepted : record));
return clone(accepted);
});
}
/** The target stays fenced; only a deliberate deletion retry may prepare it again. */
export function markBoatDeletionBlocked(boxId: string, value?: unknown): BoatDeletionRecord {
if (!BOAT_ID.test(boxId)) throw new Error("invalid cloud computer id for deletion");
const operation = value === undefined ? null : parseOperation(boxId, value, "blocked");
return withJournalLock((records) => {
const existing = records.find((record) => record.boxId === boxId);
if (!existing) throw stateError("out of date");
if (operation && existing.operationId && existing.operationId !== operation.id) {
throw stateError("conflicted with another deletion operation");
}
if (
operation
&& records.some((record) => record.boxId !== boxId && record.operationId === operation.id)
) throw stateError("conflicted with another deletion operation");
const blocked: BoatDeletionRecord = {
...existing,
phase: "blocked",
...(operation?.id || existing.operationId ? { operationId: operation?.id ?? existing.operationId } : {}),
status: "blocked",
updatedAt: nextUpdatedAt(existing),
};
save(records.map((record) => record.boxId === boxId ? blocked : record));
return clone(blocked);
});
}
export function boatDeletionSnapshot(): BoatDeletionRecord[] {
return withJournalLock((records) => records.map(clone));
}
export function getBoatDeletion(boxId: string): BoatDeletionRecord | null {
if (!BOAT_ID.test(boxId)) throw new Error("invalid cloud computer id for deletion");
return withJournalLock((records) => {
const record = records.find((candidate) => candidate.boxId === boxId);
return record ? clone(record) : null;
});
}
export function hasPendingBoatDeletionForBot(botId: string): boolean {
if (!BOT_ID.test(botId)) throw new Error("invalid bot id for cloud computer deletion");
return withJournalLock((records) => records.some((record) => record.ownerBotId === botId));
}
/** Only after a direct absence or a completed target-bound operation proves the Boat is gone. */
export function retireBoatDeletion(boxId: string): void {
if (!BOAT_ID.test(boxId)) throw new Error("invalid deleted cloud computer id");
withJournalLock((records) => {
const next = records.filter((record) => record.boxId !== boxId);
if (next.length !== records.length) save(next);
});
}