/** * Conversations that were created by an authorised rotation whose handshake never committed. * * This is the residue a transaction cannot avoid. The bridge can guarantee that its own * `current` pointer never moves before the new conversation is proved usable, but it cannot * un-create something on ChatGPT's side -- so after a failed verification the account holds * a conversation the bridge is not using, and pretending otherwise would be the dishonest * part, not the leftover. * * They are recorded and not deleted. Deleting would be a second external side effect * triggered by a failure, and the external ruling is explicit that a failed rotation must * not cause one; it is also why the cleanup inventory only ever lists and never acts. * * Persisted, unlike the first version of this, which kept them in an in-process array. An * array is lost when the process exits, and a rotation that fails is quite likely to be * followed by the process exiting -- so the record would have been written exactly when it * was least likely to survive. */ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { createHash, randomBytes } from "node:crypto"; import { dirname, join } from "node:path"; import { conversationIdOf } from "../core/identity.ts"; import { orphanStorePath } from "./paths.ts"; export interface OrphanCandidate { /** Stable audit id used by `/brb orphan clear `. */ id: string; /** Bare URL or `/c/`; a conversation created seconds ago may not have an id yet. */ conversationUrl: string; targetId: string; localSessionId: string; /** The grant spent on the creation that did not commit. */ grantId: string; /** Why verification failed, in the words of the failure itself. */ reason: string; at: number; state: "PENDING" | "RESOLVED_BOUND" | "RESOLVED_DISCARDED" | "RESOLVED_INVALID_CANDIDATE"; resolvedAt?: number; } export type OrphanInput = Omit & { id?: string; state?: OrphanCandidate["state"]; }; export interface OrphanFile { version: 1; orphans: OrphanCandidate[]; } export function defaultOrphanPath(): string { return orphanStorePath(); } /** Keep the file bounded; the oldest are the least likely to still matter. */ export const MAX_ORPHANS = 200; export class OrphanStore { readonly path: string; constructor(options: { path?: string } = {}) { this.path = options.path ?? defaultOrphanPath(); } load(): OrphanFile { try { if (!existsSync(this.path)) return { version: 1, orphans: [] }; const parsed = JSON.parse(readFileSync(this.path, "utf8")) as Partial; const entries = Array.isArray(parsed.orphans) ? parsed.orphans : []; return { version: 1, orphans: entries.map((entry, index) => ({ ...entry, id: typeof entry.id === "string" && entry.id ? entry.id : legacyOrphanId(entry, index), state: entry.state ?? "PENDING", } as OrphanCandidate)), }; } catch { // A corrupt file must not lose the fact that orphans exist, but it also cannot be // trusted. Reporting none is the recoverable direction: the account is unaffected. return { version: 1, orphans: [] }; } } list(): OrphanCandidate[] { return this.load().orphans.slice().sort((a, b) => b.at - a.at); } /** * An orphan is unresolved until a person deals with it. Auto-create must not * reinterpret a second command as permission to create another conversation. */ pendingForSession(localSessionId: string): OrphanCandidate | null { return this.list().find((entry) => entry.localSessionId === localSessionId && entry.state === "PENDING") ?? null; } hasPendingForSession(localSessionId: string): boolean { return this.pendingForSession(localSessionId) !== null; } /** * Creation attempts that may have left a conversation still consume the safety budget. * * A PENDING orphan remains uncertain even when its page URL is bare. Once a person clears * it, a bare URL is a resolved non-creation and no longer spends quota; a durable * conversation id remains evidence that the external creation happened. */ countSince(windowMs: number, now = Date.now()): number { const cutoff = now - windowMs; return this.list().filter((entry) => entry.at >= cutoff && (entry.state === "PENDING" || conversationIdOf(entry.conversationUrl) !== null)).length; } record(candidate: OrphanInput): OrphanCandidate { const file = this.load(); const stored: OrphanCandidate = { ...candidate, id: candidate.id ?? newOrphanId(), state: candidate.state ?? "PENDING", }; file.orphans = [...file.orphans, stored].slice(-MAX_ORPHANS); mkdirSync(dirname(this.path), { recursive: true }); const temp = `${this.path}.tmp-${process.pid}`; writeFileSync(temp, `${JSON.stringify(file, null, 2)}\n`, "utf8"); renameSync(temp, this.path); return stored; } /** Resolve one pending record without deleting its audit history. */ clear(id: string, now = Date.now()): { ok: true; orphan: OrphanCandidate } | { ok: false; reason: string } { const file = this.load(); const index = file.orphans.findIndex((entry) => entry.id === id); if (index < 0) return { ok: false, reason: `没有找到创建残留 ${id}。` }; const entry = file.orphans[index]; if (entry.state !== "PENDING") return { ok: false, reason: `创建残留 ${id} 已是 ${entry.state},无需重复清除。` }; const resolved = { ...entry, state: "RESOLVED_DISCARDED" as const, resolvedAt: now }; file.orphans[index] = resolved; mkdirSync(dirname(this.path), { recursive: true }); const temp = `${this.path}.tmp-${process.pid}`; writeFileSync(temp, `${JSON.stringify(file, null, 2)}\n`, "utf8"); renameSync(temp, this.path); return { ok: true, orphan: resolved }; } /** * Mark one as seen by a person, without deleting anything. * * Nothing here removes a conversation or forgets one automatically: the file is the * bridge's memory of what it left behind, and forgetting is a decision too. */ count(): number { return this.load().orphans.length; } } function newOrphanId(): string { return `orphan-${randomBytes(6).toString("hex")}`; } function legacyOrphanId(entry: unknown, index: number): string { return `orphan-legacy-${createHash("sha256").update(`${index}:${JSON.stringify(entry)}`).digest("hex").slice(0, 12)}`; }