/* RFC-1112: durable receipt store for agent-action idempotency, backed by the same Upstash Redis EU REST endpoint as the RFC-0181 delivery ledger. Maps an Idempotency-Key to { receipt, bodyHash } so a replayed request returns the original receipt without re-dispatching, and a key reused with a different payload is rejected. Stores identifiers only — never lead payload fields. Do not log or embed secrets — the caller injects RestRedisConfig. Do not implement the port contract here — the engine's IdempotencyPort is structurally identical; this adapter satisfies it. RFC-1112: initial receipt store + Upstash Redis REST adapter. */ import type { ActionReceipt } from "@warpgogol/werkstatt-shared/agent"; import type { RestRedisConfig } from "./qstash.ts"; /** Stored record: the original receipt plus the request-body hash it was issued for. */ export interface ReceiptStoreRecord { receipt: ActionReceipt; /** sha256 of the raw request body (HTTP) or canonicalJsonHashV1 of arguments (MCP). */ bodyHash: string; } /** * Receipt store contract (RFC-1112). Structurally identical to the engine's * IdempotencyPort — kept here so the adapter type-checks without importing * engine code (werkstatt-shared is a leaf package). */ export interface ActionReceiptStore { get(key: string): Promise; put(key: string, bodyHash: string, receipt: ActionReceipt, ttlSeconds: number): Promise; } /** * Adapt an Upstash Redis REST endpoint to the ActionReceiptStore (RFC-1112). * `get` is a plain GET; `put` is `SET key JSON NX EX ttl` — NX keeps the first * stored receipt when two same-key requests race. Throws on transport errors * so the caller can decide fail-open semantics. */ export function restRedisReceiptStore( config: RestRedisConfig, fetchImpl: typeof fetch = fetch, ): ActionReceiptStore { const prefix = config.prefix ?? "gogol-agent-idem"; const headers = { authorization: `Bearer ${config.token}`, "content-type": "application/json", }; return { async get(key: string): Promise { const response = await fetchImpl(config.url, { method: "POST", headers, body: JSON.stringify(["GET", `${prefix}:${key}`]), }); if (!response.ok) { throw new Error(`redis receipt GET failed: ${response.status}`); } const data = (await response.json()) as { result?: string | null }; if (typeof data.result !== "string") return null; return JSON.parse(data.result) as ReceiptStoreRecord; }, async put( key: string, bodyHash: string, receipt: ActionReceipt, ttlSeconds: number, ): Promise { const record: ReceiptStoreRecord = { receipt, bodyHash }; const command = [ "SET", `${prefix}:${key}`, JSON.stringify(record), "NX", "EX", String(ttlSeconds), ]; const response = await fetchImpl(config.url, { method: "POST", headers, body: JSON.stringify(command), }); if (!response.ok) { throw new Error(`redis receipt SET failed: ${response.status}`); } }, }; }