import { WeftError } from '../weft-error.ts'; /** * Durable effect log for replay deduplication. * * When workflow code is restored from a checkpoint, a non-idempotent external * effect can be requested again while the outcome of the original request is * still unknown. Without a durability fence at the effect boundary, payments, * state mutations, or single-use token presentations can execute twice. * * This module solves the problem with an effect log keyed by a *semantic hash* * of each effect call's intent-critical fields. Before executing an effect, * callers consult the log: * * - **committed** → replay the stored result; skip the effect entirely. * - **in-flight** → the previous run crashed mid-execution; throw * {@link EffectReplayConflictError} so the caller can escalate. * - **absent** → record as `in-flight`, execute the effect, then * {@link EffectLog.commit} or {@link EffectLog.abort}. * * The log is backed by the {@link Storage} interface (any KV adapter). * Records are scoped to `(workflowId, operationId)` so parallel branches do not * collide. * * @see arXiv 2603.20625 ("ACRFence") for the threat model and experimental * evidence that motivated this design. * * @module core/effect-log */ import type { Storage } from '../../storage/interface.ts'; import { type JSONValue } from '../json.ts'; /** * An effect record stored in the log. The `status` field drives deduplication. * * @example Inspect the record returned by EffectLog.lookup * ```ts * import type { EffectRecord } from '@lostgradient/weft'; * * function describeRecord(record: EffectRecord): string { * switch (record.status) { * case 'in-flight': return `${record.effectName} started at ${record.recordedAt}`; * case 'committed': return `${record.effectName} -> ${JSON.stringify(record.output)}`; * case 'aborted': return `${record.effectName} failed: ${record.reason}`; * } * } * ``` */ export type EffectRecord = { status: 'in-flight'; effectName: string; recordedAt: number; } | { status: 'committed'; effectName: string; output: JSONValue; completedAt: number; } | { status: 'aborted'; effectName: string; reason: string; completedAt: number; }; /** * Public contract callers rely on when deduplicating effect calls. * * This narrower type keeps tests honest without forcing them to construct a * full {@link EffectLog} instance when they only need the runtime-facing * methods and counter. * * @example * ```ts * import type { EffectLogLike } from '@lostgradient/weft'; * * async function dedupe(log: EffectLogLike, hash: string): Promise { * const existing = await log.lookup(hash); * return existing !== null; * } * ``` */ export type EffectLogLike = Pick; /** * Thrown when a caller detects a lingering `in-flight` record during a * checkpoint-restore cycle. This indicates the process crashed between * recording the in-flight intent and receiving the effect * result — the outcome of the original call is unknown. * * Callers should escalate (e.g. human review) rather than silently * re-executing a potentially non-idempotent tool. * * @example Catch a replay conflict and route to human review * ```ts * import { EffectReplayConflictError } from '@lostgradient/weft'; * * try { * // ... effect execution * } catch (error) { * if (error instanceof EffectReplayConflictError) { * console.error( * `Conflict for effect "${error.effectName}" (hash ${error.semanticHash}).`, * 'Route to human review before retrying.', * ); * } * } * ``` */ export declare class EffectReplayConflictError extends WeftError<'EffectReplayConflictError'> { readonly effectName: string; readonly semanticHash: string; constructor(semanticHash: string, effectName: string); } /** * Compute a stable 16-character hex semantic hash of an arbitrary input * value. Keys within objects are sorted recursively so that * `{a:1,b:2}` and `{b:2,a:1}` produce the same hash. * * Callers may override this default by hashing only the intent-critical fields * before recording an effect, ignoring fields whose variance does not affect * the observable effect (retry counters, timestamps, nonces). * * @example Hash only the fields that determine a payment's observable effect * ```ts * import { computeSemanticHash } from '@lostgradient/weft'; * * const hash = computeSemanticHash({ recipient: 'alice', amount: 100 }); * // Key order is irrelevant — same hash regardless of property insertion order. * const sameHash = computeSemanticHash({ amount: 100, recipient: 'alice' }); * console.log(hash === sameHash); // true * ``` */ export declare function computeSemanticHash(input: unknown): string; /** * Per-operation effect log. * * Scoped to a `(workflowId, operationId)` pair so that concurrent branches do * not share hash space. * * `operationId` should be stable across checkpoint-restore cycles for any * operation that wants deterministic effect replay. * * @example Create and use an EffectLog for durable deduplication * ```ts * import { EffectLog, computeSemanticHash } from '@lostgradient/weft'; * import { MemoryStorage } from '@lostgradient/weft/storage/memory'; * * const storage = new MemoryStorage(); * const log = new EffectLog(storage, 'workflow-abc', 'operation-1'); * * const hash = computeSemanticHash({ recipient: 'alice', amount: 100 }); * await log.record(hash, 'charge'); * await log.commit(hash, 'charge', { success: true }); * * const record = await log.lookup(hash); * console.log(record?.status); // 'committed' * ``` */ export declare class EffectLog { #private; constructor(storage: Storage, workflowId: string, operationId: string); /** Number of committed-replay short-circuits recorded during this instance's lifetime. */ get duplicatesPrevented(): number; /** * Increment the duplicate-prevention counter. * Called each time a committed replay short-circuits an effect invocation. * Separated from {@link lookup} so callers control when they count a replay. */ recordReplay(): void; /** * Look up the effect record for a given semantic hash. * Returns `null` when no record exists. */ lookup(semanticHash: string): Promise; /** * Record an effect call as `in-flight`. * * Call this **before** invoking the effect so that a crash between this * write and the effect response is detectable on restore. */ record(semanticHash: string, effectName: string): Promise; /** * Mark the call as `committed` and store the effect output. * * Call this after the effect has returned successfully so that a subsequent * restore will replay this output instead of re-executing. */ commit(semanticHash: string, effectName: string, output: unknown): Promise; /** * Mark the call as `aborted` with a reason string. * * Call this when an effect fails and that failure should not be replayed * from the effect log. On restore the caller can re-execute the effect rather * than replaying the error, so only use this for failures where a future retry * is safe and desired. */ abort(semanticHash: string, effectName: string, reason: string): Promise; }