/** * `WorkflowCatalog` — the immutable-entry, durable workflow catalog * (WFT-9/WFT-10). * * In-memory state is `Map>` for * installed entries and `Map` for each * name's active pointer — nested maps, never delimiter-joined keys, so a * workflow `name` or `revision` equal to `'__proto__'`/`'toString'` or * containing a colon is always handled correctly. * * Two activation entry points exist, matching WFT-9/WFT-10's distinct * producers: * * - {@link WorkflowCatalog.activateRegistered} — unconditional, used * exclusively by `engine.register()`'s drain path * (`core/engine/catalog-readiness.ts`). Never consults * `checkWorkflowCompatibility`; always wins via a bounded 5-attempt CAS * retry loop, since registering a different version of an * already-registered workflow must never hard-fail construction or * registration (the `version-mismatch-recovery.test.ts` precedent). * "Unconditional" is about bypassing `checkWorkflowCompatibility`, not * immunity to failure in general: sustained contention can still exhaust * the retry and throw {@link WorkflowCatalogActivationConflictError}, * which propagates out of `ensureWorkflowCatalogReady` and fails * `Engine.create()` itself (safely — disposing the half-booted engine). * - {@link WorkflowCatalog.activateCandidate} — the guarded primitive: * `checkWorkflowCompatibility`-gated, single-shot CAS (no retry — the * caller decides whether to re-read and retry), refuses on incompatibility * or a stale expected generation. Reachable via `engine.workflows.activate()`. * Because a multi-writer caller can activate a revision this process's * `#entries` cache never observed, the compatibility check reads through * to durable storage rather than trusting the cache. * * @module core/catalog/workflow-catalog */ import { type Storage } from '../../storage/interface.ts'; import type { WorkflowRevisionManifest } from '../contract/types.ts'; import type { RegisteredWorkflowDefinition } from '../types/workflow-registry.ts'; import { type ActivateCandidateOptions } from './activation-guards.ts'; import { type WorkflowCatalogRemovalOutcome } from './removal.ts'; import { type CatalogInstallFence, type RestoredWorkflowCatalogState } from './storage-io.ts'; import type { WorkflowCatalogActivationResult, WorkflowCatalogActivePointer, WorkflowCatalogEntry, WorkflowRevisionRecord } from './types.ts'; export type { ActivateCandidateOptions } from './activation-guards.ts'; export declare class WorkflowCatalog { #private; constructor(storage: Storage, seed?: RestoredWorkflowCatalogState); /** Look up one installed `(name, revision)` entry, or `undefined` when not installed. */ getEntry(name: string, revision: string): WorkflowCatalogEntry | undefined; /** Every installed revision of `name`, in no particular order. */ listRevisions(name: string): readonly WorkflowCatalogEntry[]; /** The current active pointer for `name`, or `undefined` when never activated. */ resolveActive(name: string): WorkflowCatalogActivePointer | undefined; /** * Resolve one installed `(name, revision)` entry as a public * {@link WorkflowRevisionRecord} — always durable-read-through via * {@link readCatalogEntry}, never a bare in-memory cache-hit return (WFT-21, * Codex review round 14, P2 item UXP-): a peer's `remove()` can durably * delete this exact entry while it stays cached here, and every current * public surface built on this method — `engine.resolveWorkflowSource()`/`preload()` * (via `resolveCachedOrHandle()`), `activateCandidate()`'s compatibility * check, `getWorkflowRevisionDiagnostics()` (via {@link hasInstalled}) — * must never report a durably-removed revision as installed. A durable hit * still (re-)populates the local cache, exactly like `install()`'s own * read-through; a durable miss evicts any stale cache entry. Returns * `undefined` when truly absent. */ resolveEntry(name: string, revision: string): Promise; /** * Every durably installed revision of `name`, sorted by {@link compareCodepoint} * on `revision` for a deterministic order — never `localeCompare`, per * this codebase's determinism rule. Durable scan via * {@link scanCatalogEntriesForName}, validated the same fail-closed way * {@link restoreWorkflowCatalog} validates every entry it restores. * Returns an empty array for an unknown name rather than throwing. */ listInstalledRevisions(name: string): Promise; /** * Whether `(name, revision)` is already durably installed — delegates to * {@link resolveEntry}, so it shares that method's durable-safe guarantee * (WFT-21, item UXP-): a peer's `remove()` is never misreported as still * installed just because this process's cache has not caught up. Used by * `catalog-events.ts`'s installed/activated/draining dispatch helper to * decide whether an activation call is installing genuinely new content, * and by `getWorkflowRevisionDiagnostics()`. */ hasInstalled(name: string, revision: string): Promise; /** * Durably resolve `name`'s active pointer — always reads through to * durable storage rather than trusting the in-memory `#active` cache, * the same durable-safe posture {@link hasInstalled}/{@link resolveEntry} * now share (WFT-21, item UXP-). A second engine/process can durably move * the active pointer (e.g. via `activateCandidate`, or a second engine * holding the ADR 0002 workflow-lease) to a revision this process * never installed, so a stale cache HIT here could otherwise misreport a * durably-active revision as inactive. Used by removal and diagnostics * (`core/engine/catalog-removal.ts`) so a durably-active revision is * never misreported as inactive/removable; `resolveActive` stays the * cheap, synchronous, best-effort accessor for in-process callers (e.g. * `reserveInFlightStart`) that only care about this process's own view. */ resolveActiveDurable(name: string): Promise; /** * Durably remove the installed `(name, revision)` entry — delegates the * CAS mechanics to {@link removeCatalogEntry}. On success, evicts the * entry from this process's in-memory `#entries` cache too, so a * subsequent `getEntry`/`listRevisions` call never observes a removed * revision. Refuses (`'active'`) when `revision` is currently the active * pointer for `name` (see `core/engine/catalog-removal.ts`). */ remove(name: string, revision: string): Promise; /** * Install `manifest` (paired with `definition`, when this process holds * one). Idempotent on a byte-identical reinstall for the same * `(name, revision)` key; throws {@link WorkflowCatalogConflictError} when * an existing entry for that key has different manifest content — checked * against BOTH this process's in-memory cache and, on a cache miss, * durable storage itself. The durable write is CAS-guarded * ({@link writeCatalogEntry}) rather than a plain `put`, so two processes * racing to install genuinely different content under the same key — * possible whenever `revision` is an explicit, non-content-derived caller * value — cannot silently last-write-win; the loser re-reads and resolves * through the same idempotent/conflict check. * * A cache HIT is also revalidated against durable storage (WFT-21, Codex * review round 14, P1 item TYR4) rather than trusted outright: a peer's * `remove()` + tombstone resolution can durably delete this exact entry * while it stays cached here from an earlier `install()`/`resolveEntry()` * call on this same process. A durable miss evicts the stale cache entry * and falls through to the ordinary not-cached path below, which * re-derives the correct outcome from scratch. * * Also CAS-guarded on the entry's tombstone key being absent (WFT-21, * items 1-3) — throws {@link import('./errors.ts').WorkflowRevisionTombstonedError} rather than * resurrecting an entry a concurrent removal is deleting/has deleted; see * that error's own JSDoc for the rationale and engine-layer translation. * * `fence`, when supplied, additionally CAS-guards the write on the * durable `catalog-removal-generation::` counter still * reading `fence.removalGeneration` (WFT-21, item Q7jH) — closes the * residual window a transient tombstone alone leaves open, where a * dynamic-source load that began BEFORE a removal only reaches this write * AFTER that removal's tombstone already resolved (entry and tombstone * both read `null`, indistinguishable from "never installed"). Only * `runSharedSourceLoad` (`core/engine/source-resolution.ts`) supplies a * fence, captured before invoking the host loader; every other caller * (`activateRegistered`, a deliberate direct `engine.workflows.install()` * reinstall) omits it and implicitly advances past whatever the counter * currently reads, matching a caller-intended reinstall after removal. * * Defensively re-validates `name` against the wire-safe name grammar even * though `engine.register()`'s own `validateWorkflowOrActivityName` check * already guarantees this for the only current producer. */ install(manifest: WorkflowRevisionManifest, definition?: RegisteredWorkflowDefinition, fence?: CatalogInstallFence): Promise; /** * Unconditionally activate `manifest` for `name`, installing it first if * needed. Used exclusively by `engine.register()`'s drain path — never * consults `checkWorkflowCompatibility`. Reactivating the currently active * revision is a no-op (generation unchanged); activating a different * revision bumps the generation by exactly 1. Retries the CAS write up to * {@link MAX_ACTIVATE_REGISTERED_ATTEMPTS} times under contention, throwing * {@link WorkflowCatalogActivationConflictError} on exhaustion. * * Each attempt's pointer-write CAS also fences on the candidate entry's * own bytes, read fresh every iteration (WFT-21, Codex review round 14, * P1 item UXP7) — the same fence {@link activateCandidate} applies, closing * the identical gap here: a peer's `remove()` landing between this call's * own `install()` and the pointer commit (or between retries) could * otherwise leave the active pointer naming a missing entry. Unlike * `activateCandidate`, a missing candidate entry does not fail this call * outright — this method already owns `manifest`/`definition`, so it * reinstalls (unfenced) and retries, preserving the "unconditional, * never hard-fails construction" contract. */ activateRegistered(name: string, manifest: WorkflowRevisionManifest, definition: RegisteredWorkflowDefinition): Promise; /** * The guarded activation primitive: reads the currently-active manifest * for `name` (absence is treated as automatically compatible — first * activation), checks `checkWorkflowCompatibility`, and refuses rather * than applies when incompatible or when `expectedGeneration` disagrees * with the durably-read generation. Single-shot CAS write — no retry; the * caller decides whether to re-read and retry. * * Installs `candidateManifest` first (via `install()`, unfenced — a * deliberate direct activation call, not a stale dynamic-source load, so * it advances past any prior removal like `activateRegistered` does), * then re-reads the freshly-durable entry bytes and CAS-fences the * active-pointer write on them (WFT-21, item TYR4): without this fence, a * peer's `remove()` + tombstone finalize landing between `install()` * returning and this method's own CAS could leave the active pointer * naming a revision whose catalog entry no longer exists. A CAS loss * re-reads the entry: durably absent means the race was that removal * (throws the already-public {@link WorkflowRevisionNotInstalledError}); * still present is an ordinary concurrent-activation race (`{ applied: * false, reason: 'conflict' }`, the caller re-decides). */ activateCandidate(name: string, candidateManifest: WorkflowRevisionManifest, options?: ActivateCandidateOptions): Promise; }