import { normalizeTableNamespace, sha256Hex, stableStringify, // Relative (not '@shared_libs/...') because this file ships inside the // packed SDK's dist/bundling-sources graph, where only relative imports // resolve. } from '../plays/row-identity'; import { resolveDurableCallCachePolicy } from './durable-call-policy'; import { normalizeToolResponseReceiptRevision } from './tool-response-contract'; export { DURABLE_CALL_STALE_AFTER_SECONDS_ERROR, resolveDurableCallCachePolicy, } from './durable-call-policy'; export type { DurableCallCachePolicy } from './durable-call-policy'; export const DURABLE_CALL_CACHE_POLICY_VERSION = 'call-cache-v1'; export type DurableCallKind = 'tool' | 'step' | 'fetch'; export function durableCacheStaleBucket(input: { staleAfterSeconds?: number | null; nowMs?: number; }): string | null { const policy = resolveDurableCallCachePolicy(input.staleAfterSeconds); if (policy.staleAfterSeconds === null) { return null; } const nowMs = input.nowMs ?? Date.now(); return `${policy.staleAfterSeconds}:${Math.floor( nowMs / (policy.staleAfterSeconds * 1000), )}`; } export const durableCallStaleBucket = durableCacheStaleBucket; export function buildDurableToolCallCacheKey(input: { orgId?: string | null; toolId: string; requestInput: Record; authScopeDigest?: string | null; providerActionVersion?: string | null; cachePolicyVersion?: string | null; staleAfterSeconds?: number | null; /** Run-stable clock used to choose the stale bucket. */ cacheEpochMs?: number; playLocalScope?: string | null; /** * Explicit revision for a response transformation. Bump it only when the * serialized tool output changes; omit it for historical artifacts. */ toolResponseReceiptRevision?: string | null; }): string { const orgId = input.orgId?.trim() || 'org'; const toolId = input.toolId.trim(); if (!toolId) { throw new Error('Durable tool call cache key requires a non-empty toolId.'); } const providerActionVersion = input.providerActionVersion?.trim(); if (!providerActionVersion) { throw new Error( 'Durable tool call cache key requires a provider action version.', ); } const playLocalScope = input.playLocalScope?.trim() || 'play'; // Keep historical digests byte-for-byte stable when no explicit revision // exists on the stored artifact. const toolResponseReceiptRevision = normalizeToolResponseReceiptRevision( input.toolResponseReceiptRevision, ); const digest = sha256Hex( stableStringify({ kind: 'tool' satisfies DurableCallKind, orgId, playLocalScope, toolId, normalizedToolId: normalizeTableNamespace(toolId), requestInput: input.requestInput, authScopeDigest: input.authScopeDigest ?? null, providerActionVersion, cachePolicyVersion: input.cachePolicyVersion ?? DURABLE_CALL_CACHE_POLICY_VERSION, ...(toolResponseReceiptRevision ? { toolResponseReceiptRevision } : {}), staleBucket: durableCacheStaleBucket({ staleAfterSeconds: input.staleAfterSeconds, nowMs: input.cacheEpochMs, }), }), ); return `ctx:${orgId}:call:tool:${normalizeTableNamespace(toolId)}:${digest}`; } export function buildDurableCtxCallCacheKey(input: { orgId?: string | null; playId?: string | null; kind: Exclude; id: string; semanticKey?: string | null; cachePolicyVersion?: string | null; staleAfterSeconds?: number | null; /** Run-stable clock used to choose the stale bucket. */ cacheEpochMs?: number; }): string { const orgId = input.orgId?.trim() || 'org'; const playId = input.playId?.trim() || 'play'; const id = input.id.trim(); if (!id) { throw new Error(`Durable ${input.kind} key needs id.`); } const normalizedId = normalizeTableNamespace(id); const digest = sha256Hex( stableStringify({ kind: input.kind, orgId, playId, id, normalizedId, semanticKey: input.semanticKey ?? null, cachePolicyVersion: input.cachePolicyVersion ?? DURABLE_CALL_CACHE_POLICY_VERSION, staleBucket: durableCacheStaleBucket({ staleAfterSeconds: input.staleAfterSeconds, nowMs: input.cacheEpochMs, }), }), ); return `ctx:${orgId}:call:${input.kind}:${normalizedId}:${digest}`; } export function buildDurableRunPlayInvocationScope(input: { childPlayName: string; input: Record; rowScope?: Record | null; }): string { return sha256Hex( stableStringify({ childPlayName: input.childPlayName.trim(), input: input.input, rowScope: input.rowScope ?? null, }), ); } export function buildDurableToolCallAuthScopeDigest(input: { orgId?: string | null; toolId: string; executionAuthScopeDigest?: string | null; }): string { return sha256Hex( stableStringify({ orgId: input.orgId?.trim() || 'org', toolId: input.toolId.trim(), executionAuthScopeDigest: input.executionAuthScopeDigest?.trim() || null, }), ); } export function buildDurableToolReceiptPrefix(input: { orgId?: string | null; toolId: string; }): string { const orgId = input.orgId?.trim() || 'org'; const toolId = input.toolId.trim(); if (!toolId) { throw new Error('Durable tool receipt prefix requires a non-empty toolId.'); } return `ctx:${orgId}:call:tool:${normalizeTableNamespace(toolId)}:`; } export function buildDurableToolProviderIdempotencyKey(input: { receiptKey: string; force?: boolean; receiptLeaseId?: string | null; fallbackAttemptId?: string | null; }): string { const receiptKey = input.receiptKey.trim(); if (!receiptKey) { throw new Error('Need receipt key.'); } if (input.force !== true) { return receiptKey; } const attemptId = input.receiptLeaseId?.trim() || input.fallbackAttemptId?.trim() || null; if (!attemptId) { throw new Error('Forced idempotency needs lease id.'); } return `${receiptKey}:force:${attemptId}`; } export function buildDurableToolAggregateReceiptKey(input: { receiptKeys: string[]; prefix?: 'batch' | null; aggregateReceiptPrefix?: string | null; }): string { const receiptKeys = input.receiptKeys.map((key) => key.trim()).sort(); const digest = sha256Hex(stableStringify(receiptKeys)); if (input.prefix !== 'batch') { return digest; } const scopedPrefix = input.aggregateReceiptPrefix?.trim() || commonDurableToolReceiptPrefix(receiptKeys); if (!scopedPrefix) { throw new Error('Batch receipt key needs same-tool members.'); } return `${scopedPrefix}batch:${digest}`; } function commonDurableToolReceiptPrefix( receiptKeys: readonly string[], ): string { let commonPrefix: string | null = null; for (const receiptKey of receiptKeys) { const match = /^(ctx:[^:]+:call:tool:[^:]+:)/.exec(receiptKey); if (!match) { return ''; } const prefix = match[1] ?? ''; if (!commonPrefix) { commonPrefix = prefix; continue; } if (commonPrefix !== prefix) { return ''; } } return commonPrefix ?? ''; } export function buildDurableToolAggregateProviderIdempotencyKey(input: { aggregateReceiptKey: string; receiptKeys: string[]; providerIdempotencyKeys: string[]; }): string { const receiptKeys = input.receiptKeys.map((key) => key.trim()); const providerIdempotencyKeys = input.providerIdempotencyKeys.map((key) => key.trim(), ); return `${input.aggregateReceiptKey}:ordered:${sha256Hex( stableStringify({ receiptKeys, providerIdempotencyKeys }), )}`; }