import type { AmqCliAdapter, AmqCommandResult } from "./amq-cli-adapter.js"; import type { AmqMessage, AmqPriority } from "./amq-message.js"; const DEFAULT_LIMIT = 20; const MAX_LIMIT = 100; const DEFAULT_BODY_SLICE_BYTES = 8 * 1024; const MAX_BODY_SLICE_BYTES = 24 * 1024; export interface AmqOperationBinding { readonly root: string; readonly handle: string; readonly project: string; readonly session: string; } export interface AmqOperationError { readonly operation: string; readonly category: string; readonly message: string; readonly committed: boolean | "unknown"; readonly classification?: "primary-failure" | "delivery-committed-sender-audit-failed"; readonly evidence?: unknown; readonly diagnostic?: { readonly kind: "route" | "trace"; readonly ok: boolean; readonly value: unknown; }; } type AmqOperationDiagnostic = NonNullable; export type AmqOperationResult = | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: AmqOperationError }; export interface SendInput { readonly to: readonly string[]; readonly body: string; readonly subject?: string; readonly kind?: string; readonly priority?: AmqPriority; readonly labels?: readonly string[]; readonly context?: Readonly>; readonly thread?: string; readonly project?: string; readonly session?: string; readonly waitForDrained?: boolean; readonly waitTimeout?: string; } export interface ReplyInput { readonly id: string; readonly body: string; readonly subject?: string; readonly kind?: string; readonly priority?: AmqPriority; readonly labels?: readonly string[]; readonly context?: Readonly>; readonly waitForDrained?: boolean; readonly waitTimeout?: string; } export interface InboxInput { readonly box?: "new" | "cur"; readonly from?: string; readonly kind?: string; readonly priority?: AmqPriority; readonly labels?: readonly string[]; readonly limit?: number; readonly offset?: number; } export interface GetMessageInput { readonly id: string; readonly offsetBytes?: number; readonly maximumBytes?: number; } export interface ReceiverClaimPort { claimExact(id: string, signal?: AbortSignal): Promise; } export interface ReplyRouteLookup { lookup(messageId: string): { readonly to: string; readonly project?: string; readonly session?: string; } | undefined; } /** Narrow application service for typed AMQ tools and commands. */ export class AmqOperations { readonly #adapter: Pick; readonly #binding: AmqOperationBinding; readonly #claimPort: ReceiverClaimPort | undefined; readonly #replyRouteLookup: ReplyRouteLookup | undefined; constructor(options: { readonly adapter: Pick; readonly binding: AmqOperationBinding; readonly claimPort?: ReceiverClaimPort; readonly replyRouteLookup?: ReplyRouteLookup; }) { this.#adapter = options.adapter; this.#binding = options.binding; this.#claimPort = options.claimPort; this.#replyRouteLookup = options.replyRouteLookup; } async send(input: SendInput, signal?: AbortSignal): Promise> { if (input.to.length === 0) return invalid("send", "At least one recipient is required"); const argv = [ "send", ...this.#route(), "--to", input.to.join(","), ...optional("--subject", input.subject), ...optional("--kind", input.kind), ...optional("--priority", input.priority), ...optionalCsv("--labels", input.labels), ...optionalJson("--context", input.context), ...optional("--thread", input.thread), ...optional("--project", input.project), ...optional("--session", input.session), ...(input.waitForDrained ? ["--wait-for", "drained", "--wait-timeout", input.waitTimeout ?? "2m"] : []), ]; const result = await this.#adapter.runJsonWithBody(argv, input.body, signal ? { signal } : {}); const outboxFailure = senderOutboxAuditFailure(result.json); if (result.ok && !outboxFailure) return { ok: true, value: this.#project(result.json) }; if (result.ok && outboxFailure) { return { ok: false, error: { operation: "send", category: "sender-outbox-audit-failed", classification: "delivery-committed-sender-audit-failed", message: `Delivery committed, but sender outbox audit failed: ${outboxFailure.message}`, committed: true, evidence: { delivery: "committed", senderAudit: safeProjection(outboxFailure.value, 0, [this.#binding.root]), }, }, }; } const primary = operationError( "send", result, [this.#binding.root], result.json === undefined ? undefined : this.#project(result.json), ); if (result.category === "timeout") { const id = extractMessageId(result.json); return { ok: false, error: { ...primary, ...(id ? { diagnostic: await this.#traceDiagnostic(id, signal) } : {}), }, }; } return { ok: false, error: { ...primary, diagnostic: await this.#routeDiagnostic(input.to, input, signal) }, }; } async reply(input: ReplyInput, signal?: AbortSignal): Promise> { const argv = [ "reply", ...this.#route(), "--id", input.id, ...optional("--subject", input.subject), ...optional("--kind", input.kind), ...optional("--priority", input.priority), ...optionalCsv("--labels", input.labels), ...optionalJson("--context", input.context), ...(input.waitForDrained ? ["--wait-for", "drained", "--wait-timeout", input.waitTimeout ?? "2m"] : []), ]; const result = await this.#adapter.runJsonWithBody(argv, input.body, signal ? { signal } : {}); if (result.ok) return { ok: true, value: this.#project(result.json) }; const primary = operationError( "reply", result, [this.#binding.root], result.json === undefined ? undefined : this.#project(result.json), ); if (result.category === "timeout") { const id = extractMessageId(result.json); return { ok: false, error: { ...primary, ...(id ? { diagnostic: await this.#traceDiagnostic(id, signal) } : {}) }, }; } const route = this.#replyRouteLookup?.lookup(input.id); return { ok: false, error: { ...primary, diagnostic: await this.#routeDiagnostic( route ? [route.to] : [], route ?? {}, signal, ), }, }; } async inbox(input: InboxInput, signal?: AbortSignal): Promise> { const limit = boundedInteger(input.limit ?? DEFAULT_LIMIT, 1, MAX_LIMIT, "limit"); const offset = boundedInteger(input.offset ?? 0, 0, Number.MAX_SAFE_INTEGER, "offset"); const argv = [ "list", ...this.#route(), input.box === "cur" ? "--cur" : "--new", "--limit", String(limit), "--offset", String(offset), ...optional("--from", input.from), ...optional("--kind", input.kind), ...optional("--priority", input.priority), ...(input.labels ?? []).flatMap((label) => ["--label", label]), ]; return this.#readOnly("inbox", argv, signal); } async getMessage(input: GetMessageInput, signal?: AbortSignal): Promise> { const offset = boundedInteger(input.offsetBytes ?? 0, 0, Number.MAX_SAFE_INTEGER, "offsetBytes"); const maximum = boundedInteger( input.maximumBytes ?? DEFAULT_BODY_SLICE_BYTES, 1, MAX_BODY_SLICE_BYTES, "maximumBytes", ); if (!this.#claimPort) { return invalid("get_message", "An owning receiver lease is required for exact-ID retrieval"); } let message: AmqMessage; try { message = await this.#claimPort.claimExact(input.id, signal); } catch (error) { return { ok: false, error: { operation: "get_message", category: "claim-failed", message: redact(toError(error).message, [this.#binding.root]), committed: "unknown", }, }; } const body = utf8Slice(message.body, offset, maximum); return { ok: true, value: { header: this.#project(message.header), body: body.value, offsetBytes: offset, returnedBytes: Buffer.byteLength(body.value), totalBytes: Buffer.byteLength(message.body), truncated: body.truncated, }, }; } async thread(thread: string, limit = DEFAULT_LIMIT, signal?: AbortSignal): Promise> { return this.#readOnly( "thread", ["thread", ...this.#route(), "--id", thread, "--limit", String(boundedInteger(limit, 1, MAX_LIMIT, "limit"))], signal, ); } async receipts( messageId: string, options: { readonly wait?: boolean; readonly stage?: "drained" | "dlq"; readonly timeout?: string } = {}, signal?: AbortSignal, ): Promise> { const action = options.wait ? "wait" : "list"; return this.#readOnly( "receipts", [ "receipts", action, ...this.#route(), "--msg-id", messageId, ...optional("--stage", options.stage), ...(options.wait ? ["--timeout", options.timeout ?? "1m"] : []), ], signal, ); } async presenceList(signal?: AbortSignal): Promise> { return this.#readOnly("presence_list", ["presence", "list", ...this.#route()], signal); } async setStatus(status: string, note?: string, signal?: AbortSignal): Promise> { return this.#readOnly( "set_status", ["presence", "set", ...this.#route(), "--status", status, ...optional("--note", note)], signal, ); } async dlqList(limit = DEFAULT_LIMIT, signal?: AbortSignal): Promise> { const bound = boundedInteger(limit, 1, MAX_LIMIT, "limit"); const result = await this.#readOnly("dlq_list", ["dlq", "list", ...this.#route()], signal); if (!result.ok || !Array.isArray(result.value)) return result; return { ok: true, value: result.value.slice(0, bound) }; } async dlqRead(id: string, signal?: AbortSignal): Promise> { return this.#readOnly("dlq_read", ["dlq", "read", ...this.#route(), "--id", id], signal); } async trace(id: string, signal?: AbortSignal): Promise> { return this.#readOnly("trace", ["trace", id, "--root", this.#binding.root], signal); } async doctor(signal?: AbortSignal): Promise> { return this.#readOnly("doctor", ["doctor", "--root", this.#binding.root, "--ops"], signal); } async #readOnly(operation: string, argv: readonly string[], signal?: AbortSignal): Promise> { const result = await this.#adapter.runJson(argv, signal ? { signal } : {}); return result.ok ? { ok: true, value: this.#project(result.json) } : { ok: false, error: operationError(operation, result, [this.#binding.root]) }; } async #routeDiagnostic( to: readonly string[], target: { readonly project?: string; readonly session?: string }, signal?: AbortSignal, ): Promise { const result = await this.#adapter.runJson( [ "route", "explain", ...this.#route(), ...optional("--to", to.length > 0 ? to.join(",") : undefined), ...optional("--project", target.project), ...optional("--session", target.session), ], signal ? { signal } : {}, ); return { kind: "route", ok: result.ok, value: result.ok ? this.#project(result.json) : operationError("route_explain", result, [this.#binding.root]), }; } async #traceDiagnostic(id: string, signal?: AbortSignal): Promise { const result = await this.#adapter.runJson( ["trace", id, "--root", this.#binding.root], signal ? { signal } : {}, ); return { kind: "trace", ok: result.ok, value: result.ok ? this.#project(result.json) : operationError("trace", result, [this.#binding.root]), }; } #route(): string[] { return ["--root", this.#binding.root, "--me", this.#binding.handle]; } #project(value: unknown): unknown { return safeProjection(value, 0, [this.#binding.root]); } } export function safeProjection( value: unknown, depth = 0, redactions: readonly string[] = [], ): unknown { if (depth > 8) return "[projection depth exceeded]"; if (Array.isArray(value)) { return value.slice(0, MAX_LIMIT).map((item) => safeProjection(item, depth + 1, redactions)); } if (!value || typeof value !== "object") { if (typeof value === "string") { const redacted = redact(value, redactions); return redacted.length <= 24 * 1024 ? redacted : `${redacted.slice(0, 24 * 1024)}…`; } return value; } const output: Record = {}; for (const [key, child] of Object.entries(value)) { if (/(^|_)(paths?|dirs?|directories|files?|maildirs?|roots?|outbox(?:es)?)($|_)/i.test(key) || /(?:Paths?|Dirs?|Directories|Files?|Maildirs?|Roots?|Outbox(?:es)?)$/u.test(key)) continue; output[key] = safeProjection(child, depth + 1, redactions); } return output; } function operationError( operation: string, result: AmqCommandResult, redactions: readonly string[] = [], evidence?: unknown, ): AmqOperationError { const literal = result.stderr.trim() || result.jsonError?.message || result.processError?.message; return { operation, category: result.category, message: redact(literal || `${operation} failed (${result.category})`, redactions), committed: result.category === "timeout" || result.category === "process-crash" || ((operation === "send" || operation === "reply") && result.json !== undefined) ? "unknown" : false, classification: "primary-failure", ...(evidence === undefined ? {} : { evidence }), }; } function senderOutboxAuditFailure(value: unknown): { readonly message: string; readonly value: unknown } | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const outbox = (value as Record).outbox; if (!outbox || typeof outbox !== "object" || Array.isArray(outbox)) return undefined; const object = outbox as Record; const written = object.written; const durability = object.durability; const error = object.error; if (written === true && durability !== "indeterminate" && (error === "" || error === undefined || error === null)) { return undefined; } const message = typeof error === "string" && error.trim().length > 0 ? error.trim() : durability === "indeterminate" ? "outbox durability is indeterminate" : "outbox copy was not written"; return { message, value: object }; } function redact(value: string, redactions: readonly string[]): string { return redactions .filter((item) => item.length > 0) .reduce((current, item) => current.split(item).join(""), value); } function invalid(operation: string, message: string): AmqOperationResult { return { ok: false, error: { operation, category: "invalid-request", message, committed: false } }; } function optional(flag: string, value: string | undefined): string[] { return value === undefined || value.length === 0 ? [] : [flag, value]; } function optionalCsv(flag: string, value: readonly string[] | undefined): string[] { return !value || value.length === 0 ? [] : [flag, value.join(",")]; } function optionalJson(flag: string, value: Readonly> | undefined): string[] { return value === undefined ? [] : [flag, JSON.stringify(value)]; } function extractMessageId(value: unknown): string | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const object = value as Record; for (const candidate of [object.id, object.message_id, object.msg_id]) { if (typeof candidate === "string" && candidate.length > 0) return candidate; } return undefined; } function boundedInteger(value: number, minimum: number, maximum: number, name: string): number { if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { throw new TypeError(`${name} must be an integer between ${minimum} and ${maximum}`); } return value; } function utf8Slice(value: string, offset: number, maximum: number): { readonly value: string; readonly truncated: boolean } { const bytes = Buffer.from(value); const end = Math.min(bytes.length, offset + maximum); let start = Math.min(offset, bytes.length); while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start += 1; let safeEnd = end; while (safeEnd > start && safeEnd < bytes.length && (bytes[safeEnd]! & 0xc0) === 0x80) safeEnd -= 1; return { value: bytes.subarray(start, safeEnd).toString("utf8"), truncated: safeEnd < bytes.length }; } function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); }