import {
  jobRuns
} from "./chunk-OKXZ63IA.js";
import {
  JOB_HANDLER_REGISTRY
} from "./chunk-CO6LUM72.js";
import {
  JOB_ORCHESTRATOR,
  JOB_RUN_SERVICE,
  JOB_STEP_SERVICE
} from "./chunk-ZPL74UQN.js";
import {
  JOBS_WAKE_CHANNEL,
  PgNotifyListener
} from "./chunk-MYQIQ27N.js";
import {
  tokenKey
} from "./chunk-GYGNEQSC.js";
import {
  DRIZZLE
} from "./chunk-U64T4YZE.js";
import {
  __decorateClass,
  __decorateParam
} from "./chunk-2E224ZSN.js";

// runtime/subsystems/jobs/job-worker.ts
import { Inject, Injectable, Logger } from "@nestjs/common";
import { and, asc, desc, eq, inArray, lt, lte, sql } from "drizzle-orm";
var JOB_WORKER_OPTIONS = Symbol.for(tokenKey("jobs", "worker-options"));
var DEFAULT_POLL_INTERVAL_MS = 1e3;
var DEFAULT_STALE_SWEEPER_INTERVAL_MS = 6e4;
var DEFAULT_STALE_THRESHOLD_MS = 5 * 6e4;
var DEFAULT_SHUTDOWN_TIMEOUT_MS = 3e4;
var TERMINAL_STATUSES = [
  "completed",
  "failed",
  "timed_out",
  "canceled"
];
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 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 buildClaimQuery(db, pool) {
  return db.select({ id: jobRuns.id }).from(jobRuns).where(
    and(
      eq(jobRuns.status, "pending"),
      eq(jobRuns.pool, pool),
      lte(jobRuns.runAt, /* @__PURE__ */ new Date())
    )
  ).orderBy(desc(jobRuns.priority), asc(jobRuns.runAt)).limit(1).for("update", { skipLocked: true });
}
function buildStaleSweepQuery(db, staleThresholdMs) {
  const threshold = new Date(Date.now() - staleThresholdMs);
  return db.select({ id: jobRuns.id }).from(jobRuns).where(
    and(
      eq(jobRuns.status, "running"),
      lt(jobRuns.claimedAt, threshold)
    )
  ).for("update", { skipLocked: true });
}
function serialiseError(err, attempt, retryable) {
  const e = err;
  return {
    message: e?.message ?? String(err),
    stack: e?.stack,
    retryable,
    attempt
  };
}
var JobWorker = class {
  constructor(db, orchestrator, runService, stepService, options, moduleRef) {
    this.db = db;
    this.orchestrator = orchestrator;
    this.runService = runService;
    this.stepService = stepService;
    this.options = options;
    this.moduleRef = moduleRef;
    this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
    this.staleSweeperIntervalMs = options.staleSweeperIntervalMs ?? DEFAULT_STALE_SWEEPER_INTERVAL_MS;
    this.staleThresholdMs = options.staleThresholdMs ?? DEFAULT_STALE_THRESHOLD_MS;
    this.shutdownTimeoutMs = options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS;
    this.listenNotifyEnabled = options.listenNotify ?? false;
    this.sigtermHandler = () => {
      if (this.sigtermHandled) return;
      this.sigtermHandled = true;
      void this.onModuleDestroy();
    };
    void this.runService;
  }
  db;
  orchestrator;
  runService;
  stepService;
  options;
  moduleRef;
  logger = new Logger(JobWorker.name);
  shuttingDown = false;
  inFlight = /* @__PURE__ */ new Set();
  pollTimer = null;
  sweeperTimer = null;
  sigtermHandled = false;
  sigtermHandler;
  pollIntervalMs;
  staleSweeperIntervalMs;
  staleThresholdMs;
  shutdownTimeoutMs;
  // LISTEN-NOTIFY-1 — dedicated listener + debounce state. `null` when
  // `listenNotify` is off (the common case); polling is the only driver then.
  listenNotifyEnabled;
  notifyListener = null;
  /** True while a wake-driven claim cycle is in flight (debounce gate). */
  wakeDraining = false;
  /** A notify arrived mid-cycle → re-check once when the cycle ends. */
  wakeRecheckPending = false;
  // ============================================================================
  // Lifecycle
  // ============================================================================
  onModuleInit() {
    this.pollTimer = setInterval(() => {
      void this.pollAndProcess();
    }, this.pollIntervalMs);
    this.sweeperTimer = setInterval(() => {
      void this.sweepStaleClaims();
    }, this.staleSweeperIntervalMs);
    process.on("SIGTERM", this.sigtermHandler);
    if (this.listenNotifyEnabled) {
      const pool = this.db.$client;
      if (!pool || typeof pool.connect !== "function") {
        this.logger.warn(
          `listen_notify enabled but the Drizzle client exposes no pg Pool ($client.connect missing) \u2014 falling back to interval polling only.`
        );
      } else {
        this.notifyListener = new PgNotifyListener({
          channel: JOBS_WAKE_CHANNEL,
          pool,
          label: `jobs:${this.options.pool}`,
          onNotify: (payload) => this.onWake(payload)
        });
        void this.notifyListener.start();
      }
    }
  }
  /**
   * Wake handler — a `codegen_jobs_wake` notification arrived. Only payloads
   * naming THIS worker's pool are relevant (other pools have their own workers).
   * Debounced: if a claim cycle is already running we just flag a re-check so a
   * burst of N enqueues collapses to at most one extra cycle (D3).
   */
  onWake(payload) {
    if (this.shuttingDown) return;
    if (payload !== this.options.pool) return;
    if (this.wakeDraining) {
      this.wakeRecheckPending = true;
      return;
    }
    void this.drainOnWake();
  }
  /**
   * Claim-until-empty on a wake. Unlike the interval `pollAndProcess` (one
   * claim per tick), a wake drains greedily up to the concurrency ceiling so a
   * burst that arrived together is dispatched without waiting for N ticks. The
   * `wakeRecheckPending` flag coalesces notifies that land mid-drain.
   */
  async drainOnWake() {
    this.wakeDraining = true;
    try {
      do {
        this.wakeRecheckPending = false;
        let progressed = true;
        while (progressed && !this.shuttingDown && this.inFlight.size < this.options.concurrency) {
          const before = this.inFlight.size;
          await this.pollAndProcess();
          progressed = this.inFlight.size > before;
        }
      } while (this.wakeRecheckPending && !this.shuttingDown);
    } finally {
      this.wakeDraining = false;
    }
  }
  async onModuleDestroy() {
    if (this.shuttingDown) {
      await this.drainInFlight();
      return;
    }
    this.shuttingDown = true;
    if (this.pollTimer) {
      clearInterval(this.pollTimer);
      this.pollTimer = null;
    }
    if (this.sweeperTimer) {
      clearInterval(this.sweeperTimer);
      this.sweeperTimer = null;
    }
    process.removeListener("SIGTERM", this.sigtermHandler);
    if (this.notifyListener) {
      try {
        await this.notifyListener.stop();
      } catch (err) {
        this.logger.error(`notify listener stop failed: ${err.message}`);
      }
      this.notifyListener = null;
    }
    await this.drainInFlight();
    try {
      await this.db.update(jobRuns).set({ status: "pending", claimedAt: null, startedAt: null }).where(
        and(eq(jobRuns.status, "running"), eq(jobRuns.pool, this.options.pool))
      );
    } catch (err) {
      this.logger.error(`shutdown reset failed: ${err.message}`);
    }
  }
  async drainInFlight() {
    if (this.inFlight.size === 0) return;
    const timeout = new Promise(
      (resolve) => setTimeout(resolve, this.shutdownTimeoutMs)
    );
    await Promise.race([
      Promise.allSettled([...this.inFlight]).then(() => void 0),
      timeout
    ]);
  }
  // ============================================================================
  // Poll loop
  // ============================================================================
  async pollAndProcess() {
    if (this.shuttingDown) return;
    if (this.inFlight.size >= this.options.concurrency) return;
    let claimed;
    try {
      claimed = await this.claimNext(this.options.pool);
    } catch (err) {
      this.logger.error(`claimNext failed: ${err.message}`);
      return;
    }
    if (!claimed) return;
    const run = claimed;
    const promise = this.processRun(run).catch((err) => {
      this.logger.error(
        `processRun(${run.id}) unhandled: ${err.message}`
      );
    });
    this.inFlight.add(promise);
    promise.finally(() => {
      this.inFlight.delete(promise);
    });
  }
  /**
   * Claim the next runnable row from the pool. Transaction ensures the
   * select-candidate + update-to-running pair is atomic; FOR UPDATE SKIP
   * LOCKED lets multiple workers share the table without serialising.
   */
  async claimNext(pool) {
    return this.db.transaction(async (tx) => {
      const candidates = await tx.select({ id: jobRuns.id }).from(jobRuns).where(
        and(
          eq(jobRuns.status, "pending"),
          eq(jobRuns.pool, pool),
          lte(jobRuns.runAt, /* @__PURE__ */ new Date())
        )
      ).orderBy(desc(jobRuns.priority), asc(jobRuns.runAt)).limit(1).for("update", { skipLocked: true });
      const candidate = candidates[0];
      if (!candidate) return null;
      const [claimed] = await tx.update(jobRuns).set({
        status: "running",
        claimedAt: /* @__PURE__ */ new Date(),
        startedAt: /* @__PURE__ */ new Date(),
        updatedAt: /* @__PURE__ */ new Date()
      }).where(eq(jobRuns.id, candidate.id)).returning();
      return claimed ?? null;
    });
  }
  // ============================================================================
  // Stale claim sweeper
  // ============================================================================
  /**
   * Release rows whose `claimed_at` is older than the threshold. Safe to
   * run concurrently across workers — the two-phase tx (select-for-update
   * then update) guarantees each stranded row is only reset once.
   */
  async sweepStaleClaims() {
    if (this.shuttingDown) return;
    try {
      await this.db.transaction(async (tx) => {
        const threshold = new Date(Date.now() - this.staleThresholdMs);
        const stale = await tx.select({ id: jobRuns.id }).from(jobRuns).where(
          and(eq(jobRuns.status, "running"), lt(jobRuns.claimedAt, threshold))
        ).for("update", { skipLocked: true });
        if (stale.length === 0) return;
        const ids = stale.map((r) => r.id);
        await tx.update(jobRuns).set({ status: "pending", claimedAt: null, startedAt: null }).where(inArray(jobRuns.id, ids));
        for (const id of ids) {
          this.logger.warn(`Recovered stale claim on run ${id}`);
        }
      });
    } catch (err) {
      this.logger.error(`sweepStaleClaims failed: ${err.message}`);
    }
  }
  // ============================================================================
  // processRun
  // ============================================================================
  async processRun(claimed) {
    const registryEntry = JOB_HANDLER_REGISTRY.get(claimed.jobType);
    if (!registryEntry) {
      this.logger.error(
        `No handler registered for jobType='${claimed.jobType}' (run ${claimed.id})`
      );
      await this.markFailed(
        claimed,
        new Error(`No handler registered for jobType='${claimed.jobType}'`),
        /*finalAttempts*/
        (claimed.attempts ?? 0) + 1
      );
      return;
    }
    if (claimed.concurrencyKey) {
      const inflight = await this.db.select({ id: jobRuns.id }).from(jobRuns).where(
        and(
          eq(jobRuns.concurrencyKey, claimed.concurrencyKey),
          eq(jobRuns.status, "running")
        )
      );
      const other = inflight.find((r) => r.id !== claimed.id);
      if (other) {
        await this.db.update(jobRuns).set({
          status: "pending",
          claimedAt: null,
          startedAt: null,
          updatedAt: /* @__PURE__ */ new Date()
        }).where(eq(jobRuns.id, claimed.id));
        return;
      }
    }
    const meta = registryEntry.meta;
    const HandlerClass = registryEntry.handlerClass;
    const handler = this.moduleRef.get(
      HandlerClass,
      { strict: false }
    );
    const ctx = {
      input: claimed.input,
      run: claimed,
      step: this.makeStepFn(claimed),
      spawnChild: this.makeSpawnFn(claimed),
      logger: new Logger(`JobRun:${claimed.id}`)
    };
    const attemptsBefore = claimed.attempts ?? 0;
    try {
      const output = await handler.run(ctx);
      await this.db.update(jobRuns).set({
        status: "completed",
        output: output ?? {},
        finishedAt: /* @__PURE__ */ new Date(),
        updatedAt: /* @__PURE__ */ new Date(),
        attempts: attemptsBefore + 1
      }).where(eq(jobRuns.id, claimed.id));
    } 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.db.update(jobRuns).set({
          status: "pending",
          attempts: nextAttempts,
          runAt: new Date(Date.now() + delay),
          startedAt: null,
          claimedAt: null,
          error: serialiseError(err, nextAttempts, true),
          updatedAt: /* @__PURE__ */ new Date()
        }).where(eq(jobRuns.id, claimed.id));
      } else {
        await this.markFailed(claimed, err, nextAttempts);
      }
    }
  }
  async markFailed(claimed, err, finalAttempts) {
    await this.db.update(jobRuns).set({
      status: "failed",
      attempts: finalAttempts,
      finishedAt: /* @__PURE__ */ new Date(),
      error: serialiseError(err, finalAttempts, false),
      updatedAt: /* @__PURE__ */ new Date()
    }).where(eq(jobRuns.id, claimed.id));
    if (claimed.parentClosePolicy === "terminate") {
      try {
        await this.orchestrator.cancel(claimed.id, {
          cascade: true,
          reason: "parent-failed",
          tenantId: claimed.tenantId
        });
      } catch (cascadeErr) {
        this.logger.warn(
          `cascade on failed run ${claimed.id}: ${cascadeErr.message}`
        );
      }
    }
  }
  // ============================================================================
  // ctx.step / ctx.spawnChild builders
  // ============================================================================
  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 = await 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.orchestrator.start(type, input, {
        parentRunId: run.id,
        parentClosePolicy: opts?.closePolicy,
        runAt: opts?.runAt,
        priority: opts?.priority,
        tags: opts?.tags,
        triggerSource: "parent",
        triggerRef: run.id
      });
    };
  }
  /**
   * Allocate the next `seq` for a given run. SELECT-max approach — runs
   * typically have <100 steps so the scan is cheap, and correctness across
   * retries is more important than the microseconds saved by an in-memory
   * counter (which would drift if the worker crashes mid-run and another
   * worker resumes via stale-claim sweep).
   */
  async nextStepSeq(runId) {
    const result = await this.db.execute(
      sql`SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM job_step WHERE job_run_id = ${runId}`
    );
    const raw = result;
    const rows = Array.isArray(raw) ? raw : Array.isArray(raw?.rows) ? raw.rows : [];
    const next = rows[0]?.next;
    return typeof next === "undefined" ? 1 : Number(next);
  }
  // ============================================================================
  // (suppress unused-import noise)
  // ============================================================================
};
JobWorker = __decorateClass([
  Injectable(),
  __decorateParam(0, Inject(DRIZZLE)),
  __decorateParam(1, Inject(JOB_ORCHESTRATOR)),
  __decorateParam(2, Inject(JOB_RUN_SERVICE)),
  __decorateParam(3, Inject(JOB_STEP_SERVICE)),
  __decorateParam(4, Inject(JOB_WORKER_OPTIONS))
], JobWorker);

export {
  JOB_WORKER_OPTIONS,
  TERMINAL_STATUSES,
  computeBackoff,
  classifyError,
  buildClaimQuery,
  buildStaleSweepQuery,
  JobWorker
};
//# sourceMappingURL=chunk-JM3T27ZW.js.map