/** * Pure, storage-agnostic builders for the `wf-owner-epoch:` / * `wf-owner-holder:` compare-and-swap fragments described in * [ADR 0002 § Ownership transitions](../../../documentation/contributing/architecture-decisions/0002-multiengine-per-workflow-ownership.md#ownership-transitions). * * Every function here returns a `{ conditions, operations }` fragment meant to * be passed straight to {@link storageConditionalBatch}, or folded into a * larger batch alongside an enabling write (a start, a delayed-start fire, a * terminal-state commit). None of them perform IO, read a clock, or import * `EngineInternals` — `now` and every previously-read byte value are supplied * by the caller, which is what makes every CAS branch here exhaustively * unit-testable without a real `Storage` or a live claim manager (a later * stage). This mirrors why `lease-codec.ts` was split out of * `lease-manager.ts`: the transition math has no engine state of its own. * * **Why raw bytes, not re-encoded values, for every condition.** A * `conditionalBatch` precondition compares the whole stored value as bytes. * Re-deriving expected bytes from a decoded value (`encode(decode(raw))`) * only round-trips by luck — a storage adapter's byte-level transport (e.g. a * `BYTEA` column) or an older writer's differently-ordered JSON keys can be * logically equal but byte-different, which would spuriously fail a CAS that * should have succeeded. Every builder below therefore takes the exact bytes * the caller most recently read or wrote, never a value reconstructed from a * decoded record. See `lease-manager.ts`'s `readState`/`takeOwnership` for the * same discipline applied to the global lease. * * @module core/engine/workflow-claim-transitions */ import type { BatchOperation, ConditionalBatchCondition } from '../../storage/interface.ts'; /** * `workflowClaimTtl` must be at least this many multiples of * `workflowClaimRenewInterval`, enforced at `Engine` construction. Proposed * default per ADR 0002; not yet a fixed final value (see the ADR's Open * questions), but exported here so callers reference the constant rather than * a repeated literal. */ export declare const WORKFLOW_CLAIM_TTL_SAFETY_MULTIPLIER = 3; /** * Grace multiplier applied to `workflowClaimRenewInterval` and added to a * holder's `expiresAt` before the `expire` judgment considers it stale. This * dampens clock-skew-driven false-expiry judgments. Proposed default per ADR * 0002 (see the ADR's Open questions for the not-yet-fixed final value). */ export declare const WORKFLOW_CLAIM_TAKEOVER_GRACE_MULTIPLIER = 2; /** A `{ conditions, operations }` fragment for `storageConditionalBatch`. */ export type WorkflowClaimTransitionFragment = { conditions: ConditionalBatchCondition[]; operations: BatchOperation[]; }; /** Input to {@link buildWorkflowClaimAcquireTransition}. */ export type WorkflowClaimAcquireInput = { workflowId: string; /** This engine's identity, minted once per process. */ engineId: string; now: number; claimTtlMs: number; /** * The exact bytes last read for `wf-owner-epoch:`, or `null` * when genuinely never written. `acquire` always reads this key first — it * never assumes absence — so a never-seen id observes `null` and mints * epoch `1`, while a reused id (e.g. after release, or `start-new` reusing * a purged id) observes its true prior epoch and mints one past it. This is * the ABA-safety property: the epoch counter is never reset, so a stale * zombie's cached epoch from an earlier generation can never coincide with * a later one. */ observedEpochBytes: Uint8Array | null; }; /** * `acquire`: holder expected absent, epoch expected the exact bytes just * read. Grants a fresh claim at `(observedEpoch ?? 0) + 1`. Meant to be * folded into the same atomic batch as the enabling write (a create-batch or * a pending-to-running transition), except for the named `acquire (standalone * resume)` exception described in the ADR, which commits this fragment alone. */ export declare function buildWorkflowClaimAcquireTransition(input: WorkflowClaimAcquireInput): WorkflowClaimTransitionFragment; /** Input to {@link buildWorkflowClaimRenewTransition}. */ export type WorkflowClaimRenewInput = { workflowId: string; now: number; claimTtlMs: number; /** * The exact holder bytes this engine last wrote (from its own prior * acquire, takeover, or renew) — the sole CAS condition. The epoch key is * not conditioned on separately: any epoch change already changes these * bytes, since `epoch` is one of the holder record's fields. */ currentHolderBytes: Uint8Array; }; /** * `renew`: holder expected the exact bytes this engine last wrote. Rewrites * the holder with the SAME `engineId`, SAME `epoch`, and SAME `claimedAt`, * and a fresh `expiresAt`. `currentHolderBytes` must decode (it is this * engine's own prior write); a caller that no longer has a valid cached * holder should not attempt renewal. */ export declare function buildWorkflowClaimRenewTransition(input: WorkflowClaimRenewInput): WorkflowClaimTransitionFragment; /** Input to {@link buildWorkflowClaimReleaseTransition}. */ export type WorkflowClaimReleaseInput = { workflowId: string; /** This engine's cached epoch bytes — CAS condition on `wf-owner-epoch:`. */ currentEpochBytes: Uint8Array; /** This engine's last-known holder bytes — CAS condition on `wf-owner-holder:`. */ currentHolderBytes: Uint8Array; }; /** * `release`: epoch expected this engine's cached epoch bytes AND holder * expected last-known holder bytes. Deletes ONLY the holder — the epoch key * is never deleted, so a successor's next `acquire` reads the true prior * epoch and mints one past it rather than re-minting a stale generation. */ export declare function buildWorkflowClaimReleaseTransition(input: WorkflowClaimReleaseInput): WorkflowClaimTransitionFragment; /** Input to {@link buildWorkflowClaimTakeoverTransition}. */ export type WorkflowClaimTakeoverInput = { workflowId: string; engineId: string; now: number; claimTtlMs: number; /** The exact stale holder bytes read during the `expire` judgment — CAS condition. */ observedHolderBytes: Uint8Array; /** The exact epoch bytes read alongside `observedHolderBytes` — CAS condition and the source for the minted epoch, never self-reported by the stale holder. */ observedEpochBytes: Uint8Array; }; /** * `takeover`: holder expected the exact stale holder bytes read AND epoch * expected the exact epoch bytes read alongside it. Grants a fresh claim at * `readEpoch + 1`, minted from the epoch bytes just read — never from the * stale holder's self-reported `epoch` field, which a corrupt or hostile * record could understate. */ export declare function buildWorkflowClaimTakeoverTransition(input: WorkflowClaimTakeoverInput): WorkflowClaimTransitionFragment; /** Input to {@link buildWorkflowClaimExternalTerminalRotationTransition}. */ export type WorkflowClaimExternalTerminalRotationInput = { workflowId: string; /** * The exact bytes last read for `wf-owner-epoch:`, or `null` * when the workflow was never claimed (e.g. cancelling a workflow that * never resumed). The rotated epoch is `(observedEpoch ?? 0) + 1`, the same * never-a-literal minting rule `acquire` uses. */ observedEpochBytes: Uint8Array | null; }; /** * External terminal rotation: rotates `wf-owner-epoch:` to * `readEpoch + 1` and deletes `wf-owner-holder:`, in one fragment * meant to be folded into the SAME atomic batch that writes a cancel, * timeout, suspend, or purge terminal/suspended state. Any engine may commit * this — that is what makes these transitions "intentionally external" per * the ADR's entry-point classification. Rotation is what deposes a still-running * owner: its next write carries the now-stale epoch and loses its CAS. * * This must NOT be used for non-terminal external mutations (signal delivery, * tag/search-attribute edits) — those do not end the run, so the owner's * continued execution is correct and the epoch must not rotate under it. */ export declare function buildWorkflowClaimExternalTerminalRotationTransition(input: WorkflowClaimExternalTerminalRotationInput): WorkflowClaimTransitionFragment; /** Input to {@link isWorkflowClaimExpired}. */ export type WorkflowClaimExpiryInput = { /** `expiresAt` from the holder bytes last read. */ expiresAt: number; /** This engine's own clock. */ now: number; /** The configured `workflowClaimRenewInterval`, scaled by {@link WORKFLOW_CLAIM_TAKEOVER_GRACE_MULTIPLIER}. */ renewIntervalMs: number; }; /** * `expire`: an observed condition, not a storage transition. A holder is * eligible for `takeover` once its `expiresAt` plus a grace term * (`WORKFLOW_CLAIM_TAKEOVER_GRACE_MULTIPLIER * renewIntervalMs`) is strictly * earlier than `now` — matching the ADR's "is earlier than this engine's own * clock" wording exactly, so a grace-adjusted deadline equal to `now` is NOT * yet expired. The grace term dampens clock-skew-driven false-expiry * judgments; it carries no write-safety weight on its own — only the * subsequent `takeover` CAS does. */ export declare function isWorkflowClaimExpired(input: WorkflowClaimExpiryInput): boolean; /** * Pull the exact bytes a just-built transition fragment wrote for `key`, * rather than re-encoding a value from the fields the caller happens to * know. This is what lets {@link WorkflowClaimRegistry} cache "the bytes it * actually wrote" without silently drifting if `workflow-claim-transitions.ts`'s * internal object-literal field order ever changed. Exported so the * not-found branch — unreachable through the registry itself, since every * fragment it extracts from is one it just built — has direct unit coverage. */ export declare function extractPutOperationValue(operations: BatchOperation[], key: string): Uint8Array;