import {
  MemoryJobStepService
} from "./chunk-PNZSGAB2.js";
import {
  MemoryJobStore
} from "./chunk-SNQ3TOWP.js";
import {
  JobCollisionError,
  JobNotReplayableError,
  JobTemplateFieldMissingError,
  JobTypeNotFoundError,
  MissingTenantIdError
} from "./chunk-T4BIIU5E.js";
import {
  JOBS_MULTI_TENANT
} from "./chunk-ZPL74UQN.js";
import {
  __decorateClass,
  __decorateParam
} from "./chunk-2E224ZSN.js";

// runtime/subsystems/jobs/job-orchestrator.memory-backend.ts
import { randomUUID } from "crypto";
import { Inject, Injectable, Logger, Optional } from "@nestjs/common";
import { ModuleRef } from "@nestjs/core";
var QUEUED_RUN_AT = /* @__PURE__ */ new Date(864e13);
var TERMINAL_STATUSES = [
  "completed",
  "failed",
  "timed_out",
  "canceled"
];
var DEDUPE_EXCLUDED_STATUSES = ["canceled", "failed"];
var IN_FLIGHT_STATUSES = ["pending", "running"];
function isTerminal(status) {
  return TERMINAL_STATUSES.includes(status);
}
function evaluateKeyTemplate(template, input) {
  return template.replace(
    /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g,
    (_m, field) => {
      const value = input[field];
      if (value === void 0 || value === null) {
        throw new JobTemplateFieldMissingError(template, field);
      }
      return String(value);
    }
  );
}
var PromiseMutex = class {
  queue = Promise.resolve();
  async run(fn) {
    const next = this.queue.then(() => fn());
    this.queue = next.then(
      () => void 0,
      () => void 0
    );
    return next;
  }
};
var MemoryJobOrchestrator = class {
  constructor(store, stepService, multiTenant, moduleRef) {
    this.store = store;
    this.stepService = stepService;
    this.multiTenant = multiTenant;
    this.moduleRef = moduleRef;
  }
  store;
  stepService;
  multiTenant;
  moduleRef;
  logger = new Logger(MemoryJobOrchestrator.name);
  mutex = new PromiseMutex();
  handlerRegistry = /* @__PURE__ */ new Map();
  /**
   * `runId → dependent runId[]` — when a run with `concurrencyKey = K`
   * blocks on an incumbent, its id is added here under the incumbent's id.
   * On incumbent terminal transition we advance every dependent's `runAt`
   * back to `now()` so it becomes claimable.
   */
  queueBlockers = /* @__PURE__ */ new Map();
  /**
   * JOB-8 — mirror of the Drizzle backend's `resolveTenantId`. Returns the
   * value to stamp on `tenant_id` / compare against in memory predicates.
   * Off → always `null`. On + `undefined` → throw. On + `null`/string → pass.
   */
  resolveTenantId(method, tenantId) {
    if (!this.multiTenant) return null;
    if (tenantId === void 0) throw new MissingTenantIdError(method);
    return tenantId;
  }
  // ==========================================================================
  // registerHandler — replaces Drizzle's `job` table upsert
  // ==========================================================================
  /**
   * Populate the in-memory job definition row plus handler class lookup.
   * Called by `JobWorkerModule.onModuleInit` in memory mode, or directly by
   * unit tests that want to seed the registry without NestJS.
   */
  registerHandler(type, meta, handlerClass) {
    const concurrencyKeyTemplate = meta.concurrency?.key ?? null;
    const dedupeKeyTemplate = meta.dedupe?.key ?? null;
    const dedupeWindowMs = meta.dedupe?.windowMs ?? null;
    const now = /* @__PURE__ */ new Date();
    const def = {
      type,
      version: 1,
      pool: meta.pool ?? "batch",
      scopeEntityType: meta.scope?.entity ?? null,
      retryPolicy: meta.retry ?? {
        attempts: 1,
        backoff: "fixed",
        baseMs: 0
      },
      timeoutMs: meta.timeoutMs ?? null,
      concurrencyKeyTemplate: typeof concurrencyKeyTemplate === "string" ? concurrencyKeyTemplate : null,
      collisionMode: meta.concurrency?.collisionMode ?? "queue",
      dedupeKeyTemplate: typeof dedupeKeyTemplate === "string" ? dedupeKeyTemplate : null,
      dedupeWindowMs,
      priorityDefault: 0,
      replayFrom: meta.replayFrom ?? "last_checkpoint",
      createdAt: now,
      updatedAt: now
    };
    this.store.jobs.set(type, def);
    this.handlerRegistry.set(type, {
      type,
      meta,
      handlerClass
    });
  }
  /** Test helper — look up a registered handler without exposing the map. */
  getHandlerRegistration(type) {
    return this.handlerRegistry.get(type);
  }
  /**
   * Boot-time upsert per `IJobOrchestrator.upsertJobRows`. Memory backend
   * just funnels each entry through `registerHandler`. The validator is
   * skipped entirely in memory mode (Q4 resolution 2026-04-19), so the
   * orphaned list is always empty — there are no DB rows to compare against.
   */
  async upsertJobRows(entries, poolConfig) {
    void poolConfig;
    for (const entry of entries) {
      this.registerHandler(
        entry.type,
        entry.meta,
        entry.handlerClass
      );
    }
    return { orphaned: [] };
  }
  // ==========================================================================
  // start
  // ==========================================================================
  async start(type, input, opts = {}, _tx) {
    const tenantId = this.resolveTenantId("start", opts.tenantId);
    return this.mutex.run(async () => {
      const payload = input ?? {};
      const definition = this.store.jobs.get(type);
      if (!definition) throw new JobTypeNotFoundError(type);
      if (definition.dedupeKeyTemplate && definition.dedupeWindowMs) {
        const dedupeKey2 = evaluateKeyTemplate(
          definition.dedupeKeyTemplate,
          payload
        );
        const windowStart = Date.now() - definition.dedupeWindowMs;
        const existing = this.findDedupeCandidate(type, dedupeKey2, windowStart);
        if (existing) return existing;
      }
      let concurrencyKey = null;
      let queueBlockedBy = null;
      if (definition.concurrencyKeyTemplate) {
        concurrencyKey = evaluateKeyTemplate(
          definition.concurrencyKeyTemplate,
          payload
        );
        const incumbent = this.findInFlightByConcurrencyKey(concurrencyKey);
        if (incumbent) {
          switch (definition.collisionMode) {
            case "reject":
              throw new JobCollisionError(type, concurrencyKey, incumbent);
            case "replace":
              this.cancelLocked(
                incumbent.id,
                { cascade: true, reason: "replaced" },
                incumbent.tenantId
              );
              break;
            case "queue":
              queueBlockedBy = incumbent.id;
              break;
          }
        }
      }
      const newId = randomUUID();
      let rootRunId = newId;
      if (opts.parentRunId) {
        const parent = this.store.runs.get(opts.parentRunId);
        if (!parent) {
          throw new Error(
            `parentRunId ${opts.parentRunId} does not reference an existing job_run`
          );
        }
        rootRunId = parent.rootRunId;
      }
      const dedupeKey = definition.dedupeKeyTemplate ? evaluateKeyTemplate(definition.dedupeKeyTemplate, payload) : null;
      const now = /* @__PURE__ */ new Date();
      const runAt = queueBlockedBy ? QUEUED_RUN_AT : opts.runAt ?? now;
      const row = {
        id: newId,
        jobType: type,
        jobVersion: definition.version,
        parentRunId: opts.parentRunId ?? null,
        rootRunId,
        parentClosePolicy: opts.parentClosePolicy ?? "terminate",
        scopeEntityType: opts.scope?.entityType ?? null,
        scopeEntityId: opts.scope?.entityId ?? null,
        tenantId,
        tags: opts.tags ?? {},
        pool: opts.pool ?? definition.pool,
        priority: opts.priority ?? definition.priorityDefault,
        concurrencyKey,
        dedupeKey,
        status: "pending",
        input: payload,
        output: null,
        error: null,
        triggerSource: opts.triggerSource ?? "manual",
        triggerRef: opts.triggerRef ?? null,
        runAt,
        startedAt: null,
        finishedAt: null,
        claimedAt: null,
        attempts: 0,
        waitKind: null,
        resumeToken: null,
        waitDeadline: null,
        createdAt: now,
        updatedAt: now
      };
      this.store.runs.set(newId, row);
      if (queueBlockedBy) {
        const list = this.queueBlockers.get(queueBlockedBy) ?? [];
        list.push(newId);
        this.queueBlockers.set(queueBlockedBy, list);
      }
      return row;
    });
  }
  // ==========================================================================
  // cancel
  // ==========================================================================
  async cancel(runId, opts = {}) {
    const tenantId = this.resolveTenantId("cancel", opts.tenantId);
    await this.mutex.run(async () => {
      this.cancelLocked(runId, opts, tenantId);
    });
  }
  /**
   * Internal cancel that assumes the caller already holds the mutex.
   * Synchronous because all store ops are in-memory. Idempotent.
   *
   * `tenantForGate` is the already-validated tenant id (or `null`). When
   * non-null it gates the initial cancellation to that tenant's run; the
   * cascade step then sweeps descendants on the same `rootRunId` without
   * re-checking — children of a tenant-gated parent always share the
   * tenant (enforced at `start` time).
   */
  cancelLocked(runId, opts, tenantForGate) {
    const run = this.store.runs.get(runId);
    if (!run) return;
    if (this.multiTenant && run.tenantId !== tenantForGate) return;
    if (isTerminal(run.status)) return;
    const now = /* @__PURE__ */ new Date();
    const descendants = opts.cascade === false ? [] : Array.from(this.store.runs.values()).filter(
      (r) => r.rootRunId === run.rootRunId && r.id !== runId && !isTerminal(r.status)
    );
    const terminateChildren = descendants.filter(
      (d) => d.parentClosePolicy === "terminate" /* Terminate */
    );
    const cancelChildren = descendants.filter(
      (d) => d.parentClosePolicy === "cancel" /* Cancel */
    );
    for (const child of terminateChildren) {
      this.transitionToCanceled(child.id, now);
    }
    for (const child of cancelChildren) {
      this.transitionToCanceled(child.id, now);
    }
    this.transitionToCanceled(runId, now);
    void opts.reason;
  }
  transitionToCanceled(runId, at) {
    const run = this.store.runs.get(runId);
    if (!run) return;
    if (isTerminal(run.status)) return;
    const next = {
      ...run,
      status: "canceled",
      finishedAt: at,
      updatedAt: at
    };
    this.store.runs.set(runId, next);
    this.unblockQueuedDependents(runId);
  }
  /**
   * When `runId` transitions to a terminal state, advance every dependent
   * `queue`-blocked run's `run_at` back to `now()` so `claimNext` picks
   * them up.
   */
  unblockQueuedDependents(runId) {
    const dependents = this.queueBlockers.get(runId);
    if (!dependents || dependents.length === 0) return;
    const now = /* @__PURE__ */ new Date();
    for (const dep of dependents) {
      const depRun = this.store.runs.get(dep);
      if (!depRun) continue;
      if (depRun.status !== "pending") continue;
      this.store.runs.set(dep, { ...depRun, runAt: now, updatedAt: now });
    }
    this.queueBlockers.delete(runId);
  }
  // ==========================================================================
  // claimNext — consumed by JobWorker in memory mode (tests exercise directly)
  // ==========================================================================
  async claimNext(pool) {
    return this.mutex.run(async () => {
      const now = Date.now();
      const candidates = Array.from(this.store.runs.values()).filter(
        (r) => r.status === "pending" && r.pool === pool && r.runAt.getTime() <= now
      );
      if (candidates.length === 0) return null;
      candidates.sort((a, b) => {
        if (a.priority !== b.priority) return b.priority - a.priority;
        return a.runAt.getTime() - b.runAt.getTime();
      });
      const winner = candidates[0];
      const claimedAt = /* @__PURE__ */ new Date();
      const next = {
        ...winner,
        status: "running",
        claimedAt,
        startedAt: claimedAt,
        updatedAt: claimedAt
      };
      this.store.runs.set(winner.id, next);
      return next;
    });
  }
  // ==========================================================================
  // replay
  // ==========================================================================
  async replay(runId) {
    return this.mutex.run(async () => {
      const run = this.store.runs.get(runId);
      if (!run) throw new Error(`replay: run ${runId} not found`);
      if (!isTerminal(run.status)) {
        throw new JobNotReplayableError(runId, run.status);
      }
      const def = this.store.jobs.get(run.jobType);
      if (!def) throw new JobTypeNotFoundError(run.jobType);
      const mode = def.replayFrom;
      if (mode === "scratch") {
        this.stepService.clearStepsForRun(runId);
      } else {
        this.stepService.clearIncompleteSteps(runId);
      }
      const now = /* @__PURE__ */ new Date();
      const next = {
        ...run,
        status: "pending",
        attempts: 0,
        runAt: now,
        startedAt: null,
        finishedAt: null,
        claimedAt: null,
        error: null,
        output: null,
        updatedAt: now
      };
      this.store.runs.set(runId, next);
      return next;
    });
  }
  // ==========================================================================
  // tick — used by unit tests + memory-mode JobWorker
  // ==========================================================================
  /**
   * Execute a single claimed run to completion, retry, or failure. Not on
   * `IJobOrchestrator` — it's the memory equivalent of the Drizzle
   * `JobWorker.processRun` code path. The unit tests drive it directly so
   * they can assert memoization across ticks without spinning up a worker.
   */
  async tick(runId) {
    const run = this.store.runs.get(runId);
    if (!run) throw new Error(`tick: run ${runId} not found`);
    if (run.status !== "running") {
      throw new Error(
        `tick: run ${runId} must be 'running' (got '${run.status}')`
      );
    }
    const registration = this.handlerRegistry.get(run.jobType);
    if (!registration) {
      await this.markFailed(run, new Error(
        `No handler registered for jobType='${run.jobType}'`
      ), (run.attempts ?? 0) + 1);
      return;
    }
    const meta = registration.meta;
    const HandlerClass = registration.handlerClass;
    const handler = this.moduleRef ? this.moduleRef.get(
      HandlerClass,
      { strict: false }
    ) : new HandlerClass();
    const ctx = {
      input: run.input,
      run,
      step: this.makeStepFn(run),
      spawnChild: this.makeSpawnFn(run),
      logger: new Logger(`JobRun:${run.id}`)
    };
    const attemptsBefore = run.attempts ?? 0;
    try {
      const output = await handler.run(ctx);
      await this.markCompleted(run, output ?? {}, attemptsBefore + 1);
    } catch (err) {
      const policy = meta.retry;
      const decision = classifyError(err, policy, attemptsBefore);
      const nextAttempts = attemptsBefore + 1;
      if (decision === "retry" && policy) {
        const delay = computeBackoff(policy, nextAttempts);
        await this.rescheduleForRetry(run, err, nextAttempts, delay);
      } else {
        await this.markFailed(run, err, nextAttempts);
      }
    }
  }
  makeStepFn(run) {
    return async (stepId, fn, _opts) => {
      void _opts;
      const existing = await this.stepService.findStep(run.id, stepId);
      if (existing?.status === "completed") {
        return existing.output;
      }
      const seq = this.nextStepSeq(run.id);
      const startedAt = /* @__PURE__ */ new Date();
      const nextAttempts = (existing?.attempts ?? 0) + 1;
      await this.stepService.recordStep({
        jobRunId: run.id,
        stepId,
        kind: "task",
        seq,
        status: "running",
        startedAt,
        attempts: nextAttempts
      });
      try {
        const output = await fn();
        await this.stepService.recordStep({
          jobRunId: run.id,
          stepId,
          kind: "task",
          seq,
          status: "completed",
          output,
          finishedAt: /* @__PURE__ */ new Date(),
          attempts: nextAttempts
        });
        return output;
      } catch (err) {
        await this.stepService.recordStep({
          jobRunId: run.id,
          stepId,
          kind: "task",
          seq,
          status: "failed",
          error: serialiseError(err, nextAttempts, false),
          finishedAt: /* @__PURE__ */ new Date(),
          attempts: nextAttempts
        });
        throw err;
      }
    };
  }
  makeSpawnFn(run) {
    return async (type, input, opts) => {
      return this.start(type, input, {
        parentRunId: run.id,
        parentClosePolicy: opts?.closePolicy,
        runAt: opts?.runAt,
        priority: opts?.priority,
        tags: opts?.tags,
        triggerSource: "parent",
        triggerRef: run.id
      });
    };
  }
  nextStepSeq(runId) {
    const rows = this.store.steps.get(runId);
    if (!rows || rows.length === 0) return 1;
    let max = 0;
    for (const r of rows) if (r.seq > max) max = r.seq;
    return max + 1;
  }
  async markCompleted(run, output, attempts) {
    await this.mutex.run(async () => {
      const current = this.store.runs.get(run.id);
      if (!current || isTerminal(current.status)) return;
      const now = /* @__PURE__ */ new Date();
      this.store.runs.set(run.id, {
        ...current,
        status: "completed",
        output,
        finishedAt: now,
        updatedAt: now,
        attempts
      });
      this.unblockQueuedDependents(run.id);
    });
  }
  async markFailed(run, err, attempts) {
    await this.mutex.run(async () => {
      const current = this.store.runs.get(run.id);
      if (!current || isTerminal(current.status)) return;
      const now = /* @__PURE__ */ new Date();
      this.store.runs.set(run.id, {
        ...current,
        status: "failed",
        finishedAt: now,
        updatedAt: now,
        attempts,
        error: serialiseError(err, attempts, false)
      });
      this.unblockQueuedDependents(run.id);
    });
    if (run.parentClosePolicy === "terminate") {
      try {
        await this.cancel(run.id, {
          cascade: true,
          reason: "parent-failed",
          tenantId: run.tenantId
        });
      } catch (cascadeErr) {
        this.logger.warn(
          `cascade on failed run ${run.id}: ${cascadeErr.message}`
        );
      }
    }
  }
  async rescheduleForRetry(run, err, attempts, delayMs) {
    await this.mutex.run(async () => {
      const current = this.store.runs.get(run.id);
      if (!current || isTerminal(current.status)) return;
      const now = /* @__PURE__ */ new Date();
      this.store.runs.set(run.id, {
        ...current,
        status: "pending",
        attempts,
        runAt: new Date(Date.now() + delayMs),
        startedAt: null,
        claimedAt: null,
        updatedAt: now,
        error: serialiseError(err, attempts, true)
      });
    });
  }
  // ==========================================================================
  // Internal queries — used by start / cancel
  // ==========================================================================
  findDedupeCandidate(jobType, dedupeKey, windowStartMs) {
    let best = null;
    for (const r of this.store.runs.values()) {
      if (r.jobType !== jobType) continue;
      if (r.dedupeKey !== dedupeKey) continue;
      if (DEDUPE_EXCLUDED_STATUSES.includes(r.status)) continue;
      if (r.createdAt.getTime() <= windowStartMs) continue;
      if (!best || r.createdAt.getTime() > best.createdAt.getTime()) {
        best = r;
      }
    }
    return best;
  }
  findInFlightByConcurrencyKey(key) {
    for (const r of this.store.runs.values()) {
      if (r.concurrencyKey !== key) continue;
      if (!IN_FLIGHT_STATUSES.includes(r.status)) continue;
      return r;
    }
    return null;
  }
};
MemoryJobOrchestrator = __decorateClass([
  Injectable(),
  __decorateParam(0, Inject(MemoryJobStore)),
  __decorateParam(1, Inject(MemoryJobStepService)),
  __decorateParam(2, Inject(JOBS_MULTI_TENANT)),
  __decorateParam(3, Optional()),
  __decorateParam(3, Inject(ModuleRef))
], MemoryJobOrchestrator);
function classifyError(err, policy, currentAttempts) {
  if (!policy) return "fail";
  const errObj = err;
  const name = errObj?.name;
  const code = errObj?.code;
  const nonRetryable = policy.nonRetryableErrors ?? [];
  if (nonRetryable.some((n) => n === name || n === code)) return "fail";
  if (currentAttempts + 1 >= policy.attempts) return "fail";
  return "retry";
}
function computeBackoff(policy, attempts) {
  const base = Math.max(policy.baseMs, 0);
  if (policy.backoff === "fixed") return base;
  const exponent = Math.max(attempts - 1, 0);
  if (exponent >= 53) return Number.MAX_SAFE_INTEGER;
  const raw = base * Math.pow(2, exponent);
  if (!Number.isFinite(raw) || raw >= Number.MAX_SAFE_INTEGER) {
    return Number.MAX_SAFE_INTEGER;
  }
  return raw;
}
function serialiseError(err, attempt, retryable) {
  const e = err;
  return {
    message: e?.message ?? String(err),
    stack: e?.stack,
    retryable,
    attempt
  };
}

export {
  MemoryJobOrchestrator
};
//# sourceMappingURL=chunk-BULPAAD3.js.map