import { Buffer } from "node:buffer"; import { createHash } from "node:crypto"; import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs"; import { dirname } from "node:path"; import type { InboundAgentCommand } from "./client.ts"; export const COMMAND_MARKER_TYPE = "anon-notify:agent-command"; export interface JournalCommand { id: string; eventId: string; sessionId: string; instruction: string; imageDigest?: string; imageCount?: number; imageBytes?: number; createdAt: string; acceptedAt: string; } type AcceptedRecord = { op: "accepted"; commandId: string; eventId: string; sessionId: string; instruction: string; imageDigest?: string; imageCount?: number; imageBytes?: number; createdAt: string; at: string; }; function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function stringField(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } function positiveInteger(value: unknown): number | undefined { const number = typeof value === "number" ? value : typeof value === "string" && /^\d+$/.test(value) ? Number(value) : NaN; return Number.isSafeInteger(number) && number > 0 ? number : undefined; } function imageCount(value: unknown): number | undefined { return positiveInteger(value); } export function commandImageDigest(command: Pick): string | undefined { if (!command.images?.length) return undefined; return createHash("sha256") .update(JSON.stringify(command.images.map(({ data, mimeType }) => ({ data, mimeType })))) .digest("hex"); } export function commandImageBytes(command: Pick): number | undefined { if (!command.images?.length) return undefined; return command.images.reduce((sum, image) => sum + Buffer.from(image.data, "base64").length, 0) || undefined; } function appendDurable(path: string, record: AcceptedRecord): void { mkdirSync(dirname(path), { recursive: true }); const descriptor = openSync(path, "a", 0o600); try { writeSync(descriptor, `${JSON.stringify(record)}\n`, undefined, "utf8"); fsyncSync(descriptor); } finally { closeSync(descriptor); } } function commandFromMarker(value: unknown): JournalCommand | undefined { if (!isRecord(value) || value.type !== "custom" || value.customType !== COMMAND_MARKER_TYPE) return undefined; const data = isRecord(value.data) ? value.data : undefined; if (!data) return undefined; const id = stringField(data.commandId); const eventId = stringField(data.eventId); const sessionId = stringField(data.sessionId); const instruction = stringField(data.instruction); const createdAt = stringField(data.createdAt); if (!id || !eventId || !sessionId || !instruction || !createdAt) return undefined; const digest = stringField(data.imageDigest); return { id, eventId, sessionId, instruction, imageDigest: digest, imageCount: imageCount(data.imageCount), imageBytes: positiveInteger(data.imageBytes), createdAt, acceptedAt: stringField(data.acceptedAt) || createdAt, }; } export class CommandJournal { private readonly path: string; private readonly commands = new Map(); constructor(path: string) { this.path = path; this.load(); } get(commandId: string): JournalCommand | undefined { return this.commands.get(commandId); } has(commandId: string): boolean { return this.commands.has(commandId); } reconcileSessionEntries(entries: readonly unknown[]): void { for (const entry of entries) { const command = commandFromMarker(entry); if (command && !this.commands.has(command.id)) this.commands.set(command.id, command); } } accept(command: InboundAgentCommand): JournalCommand { const digest = commandImageDigest(command); const count = command.images?.length || undefined; const bytes = commandImageBytes(command); const existing = this.commands.get(command.id); if (existing) { if ( existing.eventId !== command.eventId || existing.sessionId !== command.sessionId || existing.instruction !== command.instruction || existing.createdAt !== command.createdAt || existing.imageDigest !== digest ) { throw new Error(`command ID ${command.id} conflicts with the local command journal`); } return existing; } const saved: JournalCommand = { id: command.id, eventId: command.eventId, sessionId: command.sessionId, instruction: command.instruction, imageDigest: digest, imageCount: count, imageBytes: bytes, createdAt: command.createdAt, acceptedAt: new Date().toISOString(), }; appendDurable(this.path, { op: "accepted", commandId: saved.id, eventId: saved.eventId, sessionId: saved.sessionId, instruction: saved.instruction, imageDigest: saved.imageDigest, imageCount: saved.imageCount, imageBytes: saved.imageBytes, createdAt: saved.createdAt, at: saved.acceptedAt, }); this.commands.set(saved.id, saved); return saved; } private load(): void { if (!existsSync(this.path)) return; const lines = readFileSync(this.path, "utf8").split(/\r?\n/).filter(Boolean); for (const line of lines) { try { const value = JSON.parse(line) as unknown; if (!isRecord(value) || value.op !== "accepted") continue; const id = stringField(value.commandId); const eventId = stringField(value.eventId); const sessionId = stringField(value.sessionId); const instruction = stringField(value.instruction); const createdAt = stringField(value.createdAt); const acceptedAt = stringField(value.at); if (!id || !eventId || !sessionId || !instruction || !createdAt || !acceptedAt) continue; if (this.commands.has(id)) continue; this.commands.set(id, { id, eventId, sessionId, instruction, imageDigest: stringField(value.imageDigest), imageCount: imageCount(value.imageCount), imageBytes: positiveInteger(value.imageBytes), createdAt, acceptedAt, }); } catch { // Ignore a torn or malformed line; earlier fsynced records remain usable. } } } }