#!/usr/bin/env python3
import csv
import io
import json
import os
import re
import subprocess
import sys
import shutil
import textwrap
from urllib.request import urlopen
from urllib.error import URLError
from urllib.parse import quote, urlparse
from optparse import OptionParser, IndentedHelpFormatter


# Queue names are interpolated into generated table/index names (t_<queue>,
# r_<queue>_sai, ...). We cap UTF-8 byte length so those identifiers stay
# within PostgreSQL's 63-byte limit; character set is otherwise delegated to
# PostgreSQL quoted identifier rules.
MAX_QUEUE_NAME_LENGTH = 57


GITHUB_OWNER = "earendil-works"
GITHUB_REPO = "absurd"
GITHUB_RAW_BASE = f"https://raw.githubusercontent.com/{GITHUB_OWNER}/{GITHUB_REPO}"
GITHUB_API_BASE = f"https://api.github.com/repos/{GITHUB_OWNER}/{GITHUB_REPO}"

# BEGIN BUNDLED DATA (autogenerated by scripts/build-absurdctl)
ABSURD_SCHEMA_TARGET_VERSION = "0.3.0"
BUNDLED_SCHEMA_SQL = "-- Absurd installs a Postgres-native durable workflow system that can be dropped\n-- into an existing database.\n--\n-- It bootstraps the `absurd` schema and required extensions so that jobs, runs,\n-- checkpoints, and workflow events all live alongside application data without\n-- external services.\n--\n-- Each queue is materialized as its own set of tables that share a prefix:\n-- * `t_` for tasks (what is to be run)\n-- * `r_` for runs (attempts to run a task)\n-- * `c_` for checkpoints (saved states)\n-- * `e_` for emitted events\n-- * `w_` for wait registrations\n--\n-- `create_queue`, `drop_queue`, and `list_queues` provide the management\n-- surface for provisioning queues safely.\n--\n-- Task execution flows through `spawn_task`, which records the logical task and\n-- its first run, and `claim_task`, which hands work to workers with leasing\n-- semantics, state transitions, and cancellation checks.  Runtime routines\n-- such as `complete_run`, `schedule_run`, and `fail_run` advance or retry work,\n-- enforce attempt accounting, and keep the task and run tables synchronized.\n--\n-- Long-running or event-driven workflows rely on lightweight persistence\n-- primitives.  Checkpoint helpers (`set_task_checkpoint_state`,\n-- `get_task_checkpoint_state`, `get_task_checkpoint_states`) write arbitrary\n-- JSON payloads keyed by task and step, while `await_event` and `emit_event`\n-- coordinate sleepers and external signals so that tasks can suspend and resume\n-- without losing context.  Events are uniquely indexed and use first-write-wins\n-- semantics: the first emission per name is cached, later emits are ignored.\n\ncreate extension if not exists \"uuid-ossp\";\n\ncreate schema if not exists absurd;\n\n-- Returns either the actual current timestamp or a fake one if\n-- the session sets `absurd.fake_now`.  This lets tests control time.\ncreate function absurd.current_time ()\n  returns timestamptz\n  language plpgsql\n  volatile\nas $$\ndeclare\n  v_fake text;\nbegin\n  v_fake := current_setting('absurd.fake_now', true);\n  if v_fake is not null and length(trim(v_fake)) > 0 then\n    return v_fake::timestamptz;\n  end if;\n\n  return clock_timestamp();\nend;\n$$;\n\ncreate table if not exists absurd.queues (\n  queue_name text primary key,\n  created_at timestamptz not null default absurd.current_time()\n);\n\n-- Returns the Absurd schema release version baked into this SQL file.\n-- During development this is usually \"main\" and release automation replaces\n-- it with the actual tag version.\n\ncreate or replace function absurd.get_schema_version ()\n  returns text\n  language sql\nas $$\n  select '0.3.0'::text;\n$$;\n\n-- Queue names are used in generated table/index identifiers.\n-- We intentionally cap UTF-8 byte length so generated explicit index names\n-- (for instance r_<queue>_sai) stay within PostgreSQL's 63-byte identifier\n-- limit. Character set is otherwise delegated to PostgreSQL quoted-ident rules.\ncreate function absurd.validate_queue_name (p_queue_name text)\n  returns text\n  language plpgsql\nas $$\nbegin\n  if p_queue_name is null or length(trim(p_queue_name)) = 0 then\n    raise exception 'Queue name must be provided';\n  end if;\n\n  if octet_length(p_queue_name) > 57 then\n    raise exception 'Queue name \"%\" is too long (max 57 bytes).', p_queue_name;\n  end if;\n\n  return p_queue_name;\nend;\n$$;\n\ncreate function absurd.ensure_queue_tables (p_queue_name text)\n  returns void\n  language plpgsql\nas $$\nbegin\n  perform absurd.validate_queue_name(p_queue_name);\n\n  execute format(\n    'create table if not exists absurd.%I (\n        task_id uuid primary key,\n        task_name text not null,\n        params jsonb not null,\n        headers jsonb,\n        retry_strategy jsonb,\n        max_attempts integer,\n        cancellation jsonb,\n        enqueue_at timestamptz not null default absurd.current_time(),\n        first_started_at timestamptz,\n        state text not null check (state in (''pending'', ''running'', ''sleeping'', ''completed'', ''failed'', ''cancelled'')),\n        attempts integer not null default 0,\n        last_attempt_run uuid,\n        completed_payload jsonb,\n        cancelled_at timestamptz,\n        idempotency_key text unique\n     ) with (fillfactor=70)',\n    't_' || p_queue_name\n  );\n\n  execute format(\n    'create table if not exists absurd.%I (\n        run_id uuid primary key,\n        task_id uuid not null,\n        attempt integer not null,\n        state text not null check (state in (''pending'', ''running'', ''sleeping'', ''completed'', ''failed'', ''cancelled'')),\n        claimed_by text,\n        claim_expires_at timestamptz,\n        available_at timestamptz not null,\n        wake_event text,\n        event_payload jsonb,\n        started_at timestamptz,\n        completed_at timestamptz,\n        failed_at timestamptz,\n        result jsonb,\n        failure_reason jsonb,\n        created_at timestamptz not null default absurd.current_time()\n     ) with (fillfactor=70)',\n    'r_' || p_queue_name\n  );\n\n  execute format(\n    'create table if not exists absurd.%I (\n        task_id uuid not null,\n        checkpoint_name text not null,\n        state jsonb,\n        status text not null default ''committed'',\n        owner_run_id uuid,\n        updated_at timestamptz not null default absurd.current_time(),\n        primary key (task_id, checkpoint_name)\n     ) with (fillfactor=70)',\n    'c_' || p_queue_name\n  );\n\n  execute format(\n    'create table if not exists absurd.%I (\n        event_name text primary key,\n        payload jsonb,\n        emitted_at timestamptz not null default absurd.current_time()\n     )',\n    'e_' || p_queue_name\n  );\n\n  execute format(\n    'create table if not exists absurd.%I (\n        task_id uuid not null,\n        run_id uuid not null,\n        step_name text not null,\n        event_name text not null,\n        timeout_at timestamptz,\n        created_at timestamptz not null default absurd.current_time(),\n        primary key (run_id, step_name)\n     )',\n    'w_' || p_queue_name\n  );\n\n  execute format(\n    'create index if not exists %I on absurd.%I (state, available_at)',\n    ('r_' || p_queue_name) || '_sai',\n    'r_' || p_queue_name\n  );\n\n  execute format(\n    'create index if not exists %I on absurd.%I (task_id)',\n    ('r_' || p_queue_name) || '_ti',\n    'r_' || p_queue_name\n  );\n\n  execute format(\n    'create index if not exists %I on absurd.%I (claim_expires_at)\n      where state = ''running''\n        and claim_expires_at is not null',\n    ('r_' || p_queue_name) || '_cei',\n    'r_' || p_queue_name\n  );\n\n  execute format(\n    'create index if not exists %I on absurd.%I (event_name)',\n    ('w_' || p_queue_name) || '_eni',\n    'w_' || p_queue_name\n  );\n\n  execute format(\n    'create index if not exists %I on absurd.%I (task_id)',\n    ('w_' || p_queue_name) || '_ti',\n    'w_' || p_queue_name\n  );\n\n  execute format(\n    'create index if not exists %I on absurd.%I (emitted_at)',\n    ('e_' || p_queue_name) || '_eai',\n    'e_' || p_queue_name\n  );\nend;\n$$;\n\n-- Creates the queue with the given name.\n--\n-- If the table already exists, the function returns silently.\ncreate function absurd.create_queue (p_queue_name text)\n  returns void\n  language plpgsql\nas $$\nbegin\n  p_queue_name := absurd.validate_queue_name(p_queue_name);\n\n  begin\n    insert into absurd.queues (queue_name)\n    values (p_queue_name);\n  exception when unique_violation then\n    return;\n  end;\n\n  perform absurd.ensure_queue_tables(p_queue_name);\nend;\n$$;\n\n-- Drop a queue if it exists.\n-- We intentionally don't validate the provided name here so legacy queues\n-- created under older naming rules can still be removed.\ncreate function absurd.drop_queue (p_queue_name text)\n  returns void\n  language plpgsql\nas $$\ndeclare\n  v_existing_queue text;\nbegin\n  select queue_name into v_existing_queue\n  from absurd.queues\n  where queue_name = p_queue_name;\n\n  if v_existing_queue is null then\n    return;\n  end if;\n\n  execute format('drop table if exists absurd.%I cascade', 'w_' || p_queue_name);\n  execute format('drop table if exists absurd.%I cascade', 'e_' || p_queue_name);\n  execute format('drop table if exists absurd.%I cascade', 'c_' || p_queue_name);\n  execute format('drop table if exists absurd.%I cascade', 'r_' || p_queue_name);\n  execute format('drop table if exists absurd.%I cascade', 't_' || p_queue_name);\n\n  delete from absurd.queues where queue_name = p_queue_name;\nend;\n$$;\n\n-- Lists all queues that currently exist.\ncreate function absurd.list_queues ()\n  returns table (queue_name text)\n  language sql\nas $$\n  select queue_name from absurd.queues order by queue_name;\n$$;\n\n-- Returns the current state and terminal payload (if any) for a task.\n--\n-- Non-terminal states (pending/running/sleeping) return result/failure_reason\n-- as NULL. Completed tasks expose completed_payload as result. Failed tasks\n-- expose the last run failure_reason.\ncreate function absurd.get_task_result (\n  p_queue_name text,\n  p_task_id uuid\n)\n  returns table (\n    task_id uuid,\n    state text,\n    result jsonb,\n    failure_reason jsonb\n  )\n  language plpgsql\nas $$\nbegin\n  p_queue_name := absurd.validate_queue_name(p_queue_name);\n\n  return query execute format(\n    'select t.task_id,\n            t.state,\n            case when t.state = ''completed'' then t.completed_payload else null end as result,\n            case when t.state = ''failed'' then r.failure_reason else null end as failure_reason\n       from absurd.%I t\n       left join absurd.%I r on r.run_id = t.last_attempt_run\n      where t.task_id = $1',\n    't_' || p_queue_name,\n    'r_' || p_queue_name\n  ) using p_task_id;\nend;\n$$;\n\n-- Spawns a given task in a queue.\n--\n-- If an idempotency_key is provided in p_options, the function will check if a task\n-- with that key already exists. If so, it returns the existing task_id with run_id\n-- and attempt set to NULL to signal \"already exists\". This is race-safe via\n-- INSERT ... ON CONFLICT DO NOTHING.\ncreate function absurd.spawn_task (\n  p_queue_name text,\n  p_task_name text,\n  p_params jsonb,\n  p_options jsonb default '{}'::jsonb\n)\n  returns table (\n    task_id uuid,\n    run_id uuid,\n    attempt integer,\n    created boolean\n  )\n  language plpgsql\nas $$\ndeclare\n  v_task_id uuid := absurd.portable_uuidv7();\n  v_run_id uuid := absurd.portable_uuidv7();\n  v_attempt integer := 1;\n  v_headers jsonb;\n  v_retry_strategy jsonb;\n  v_max_attempts integer;\n  v_cancellation jsonb;\n  v_idempotency_key text;\n  v_existing_task_id uuid;\n  v_row_count integer;\n  v_now timestamptz := absurd.current_time();\n  v_params jsonb := coalesce(p_params, 'null'::jsonb);\nbegin\n  if p_task_name is null or length(trim(p_task_name)) = 0 then\n    raise exception 'task_name must be provided';\n  end if;\n\n  if p_options is not null then\n    v_headers := p_options->'headers';\n    v_retry_strategy := p_options->'retry_strategy';\n    if p_options ? 'max_attempts' then\n      v_max_attempts := (p_options->>'max_attempts')::int;\n      if v_max_attempts is not null and v_max_attempts < 1 then\n        raise exception 'max_attempts must be >= 1';\n      end if;\n    end if;\n    v_cancellation := p_options->'cancellation';\n    v_idempotency_key := p_options->>'idempotency_key';\n  end if;\n\n  -- If idempotency_key is provided, use INSERT ... ON CONFLICT DO NOTHING\n  if v_idempotency_key is not null then\n    execute format(\n      'insert into absurd.%I (task_id, task_name, params, headers, retry_strategy, max_attempts, cancellation, enqueue_at, first_started_at, state, attempts, last_attempt_run, completed_payload, cancelled_at, idempotency_key)\n       values ($1, $2, $3, $4, $5, $6, $7, $8, null, ''pending'', $9, $10, null, null, $11)\n       on conflict (idempotency_key) do nothing',\n      't_' || p_queue_name\n    )\n    using v_task_id, p_task_name, v_params, v_headers, v_retry_strategy, v_max_attempts, v_cancellation, v_now, v_attempt, v_run_id, v_idempotency_key;\n\n    get diagnostics v_row_count = row_count;\n\n    if v_row_count = 0 then\n      -- Task already exists, look up existing task info\n      execute format(\n        'select task_id, last_attempt_run, attempts from absurd.%I where idempotency_key = $1',\n        't_' || p_queue_name\n      )\n      into v_existing_task_id, v_run_id, v_attempt\n      using v_idempotency_key;\n\n      return query select v_existing_task_id, v_run_id, v_attempt, false;\n      return;\n    end if;\n  else\n    -- No idempotency key, insert normally\n    execute format(\n      'insert into absurd.%I (task_id, task_name, params, headers, retry_strategy, max_attempts, cancellation, enqueue_at, first_started_at, state, attempts, last_attempt_run, completed_payload, cancelled_at, idempotency_key)\n       values ($1, $2, $3, $4, $5, $6, $7, $8, null, ''pending'', $9, $10, null, null, null)',\n      't_' || p_queue_name\n    )\n    using v_task_id, p_task_name, v_params, v_headers, v_retry_strategy, v_max_attempts, v_cancellation, v_now, v_attempt, v_run_id;\n  end if;\n\n  execute format(\n    'insert into absurd.%I (run_id, task_id, attempt, state, available_at, wake_event, event_payload, result, failure_reason)\n     values ($1, $2, $3, ''pending'', $4, null, null, null, null)',\n    'r_' || p_queue_name\n  )\n  using v_run_id, v_task_id, v_attempt, v_now;\n\n  return query select v_task_id, v_run_id, v_attempt, true;\nend;\n$$;\n\n-- Workers call this to reserve a task from a given queue\n-- for a given reservation period in seconds.\ncreate function absurd.claim_task (\n  p_queue_name text,\n  p_worker_id text,\n  p_claim_timeout integer default 30,\n  p_qty integer default 1\n)\n  returns table (\n    run_id uuid,\n    task_id uuid,\n    attempt integer,\n    task_name text,\n    params jsonb,\n    retry_strategy jsonb,\n    max_attempts integer,\n    headers jsonb,\n    wake_event text,\n    event_payload jsonb\n  )\n  language plpgsql\nas $$\ndeclare\n  v_now timestamptz := absurd.current_time();\n  v_claim_timeout integer := greatest(coalesce(p_claim_timeout, 30), 0);\n  v_worker_id text := coalesce(nullif(p_worker_id, ''), 'worker');\n  v_qty integer := greatest(coalesce(p_qty, 1), 1);\n  v_claim_until timestamptz := null;\n  v_sql text;\n  v_expired_run record;\n  v_cancel_candidate record;\n  v_expired_sweep_limit integer;\nbegin\n  if v_claim_timeout > 0 then\n    v_claim_until := v_now + make_interval(secs => v_claim_timeout);\n  end if;\n\n  -- Keep claim polling work bounded: process at most v_qty expired leases\n  -- per claim call.\n  v_expired_sweep_limit := greatest(v_qty, 1);\n\n  -- Apply cancellation rules before claiming.\n  --\n  -- Use cancel_task() so lock order stays consistent (runs first, task second)\n  -- with complete_run()/fail_run().\n  for v_cancel_candidate in\n    execute format(\n      'select task_id\n         from absurd.%I\n        where state in (''pending'', ''sleeping'', ''running'')\n          and (\n            (\n              (cancellation->>''max_delay'')::bigint is not null\n              and first_started_at is null\n              and extract(epoch from ($1 - enqueue_at)) >= (cancellation->>''max_delay'')::bigint\n            )\n            or\n            (\n              (cancellation->>''max_duration'')::bigint is not null\n              and first_started_at is not null\n              and extract(epoch from ($1 - first_started_at)) >= (cancellation->>''max_duration'')::bigint\n            )\n          )\n        order by task_id',\n      't_' || p_queue_name\n    )\n  using v_now\n  loop\n    perform absurd.cancel_task(p_queue_name, v_cancel_candidate.task_id);\n  end loop;\n\n  for v_expired_run in\n    execute format(\n      'select run_id,\n              claimed_by,\n              claim_expires_at,\n              attempt\n         from absurd.%I\n        where state = ''running''\n          and claim_expires_at is not null\n          and claim_expires_at <= $1\n        order by claim_expires_at, run_id\n        limit $2\n        for update skip locked',\n      'r_' || p_queue_name\n    )\n  using v_now, v_expired_sweep_limit\n  loop\n    perform absurd.fail_run(\n      p_queue_name,\n      v_expired_run.run_id,\n      jsonb_strip_nulls(jsonb_build_object(\n        'name', '$ClaimTimeout',\n        'message', 'worker did not finish task within claim interval',\n        'workerId', v_expired_run.claimed_by,\n        'claimExpiredAt', v_expired_run.claim_expires_at,\n        'attempt', v_expired_run.attempt\n      )),\n      null\n    );\n  end loop;\n\n  v_sql := format(\n    'with candidate as (\n        select r.run_id\n          from absurd.%1$I r\n          join absurd.%2$I t on t.task_id = r.task_id\n         where r.state in (''pending'', ''sleeping'')\n           and t.state in (''pending'', ''sleeping'', ''running'')\n           and r.available_at <= $1\n         order by r.available_at, r.run_id\n         limit $2\n         for update skip locked\n     ),\n     updated as (\n        update absurd.%1$I r\n           set state = ''running'',\n               claimed_by = $3,\n               claim_expires_at = $4,\n               started_at = $1,\n               available_at = $1\n         where run_id in (select run_id from candidate)\n         returning r.run_id, r.task_id, r.attempt\n     ),\n     task_upd as (\n        update absurd.%2$I t\n           set state = ''running'',\n               attempts = greatest(t.attempts, u.attempt),\n               first_started_at = coalesce(t.first_started_at, $1),\n               last_attempt_run = u.run_id\n          from updated u\n         where t.task_id = u.task_id\n         returning t.task_id\n     ),\n     wait_cleanup as (\n        delete from absurd.%3$I w\n         using updated u\n        where w.run_id = u.run_id\n          and w.timeout_at is not null\n          and w.timeout_at <= $1\n        returning w.run_id\n     )\n     select\n       u.run_id,\n       u.task_id,\n       u.attempt,\n       t.task_name,\n       t.params,\n       t.retry_strategy,\n       t.max_attempts,\n      t.headers,\n      r.wake_event,\n      r.event_payload\n     from updated u\n     join absurd.%1$I r on r.run_id = u.run_id\n     join absurd.%2$I t on t.task_id = u.task_id\n     order by r.available_at, u.run_id',\n    'r_' || p_queue_name,\n    't_' || p_queue_name,\n    'w_' || p_queue_name\n  );\n\n  return query execute v_sql using v_now, v_qty, v_worker_id, v_claim_until;\nend;\n$$;\n\n-- Markes a run as completed\ncreate function absurd.complete_run (\n  p_queue_name text,\n  p_run_id uuid,\n  p_state jsonb default null\n)\n  returns void\n  language plpgsql\nas $$\ndeclare\n  v_task_id uuid;\n  v_state text;\n  v_now timestamptz := absurd.current_time();\nbegin\n  execute format(\n    'select task_id, state\n       from absurd.%I\n      where run_id = $1\n      for update',\n    'r_' || p_queue_name\n  )\n  into v_task_id, v_state\n  using p_run_id;\n\n  if v_task_id is null then\n    raise exception 'Run \"%\" not found in queue \"%\"', p_run_id, p_queue_name;\n  end if;\n\n  if v_state <> 'running' then\n    raise exception 'Run \"%\" is not currently running in queue \"%\"', p_run_id, p_queue_name;\n  end if;\n\n  execute format(\n    'update absurd.%I\n        set state = ''completed'',\n            completed_at = $2,\n            result = $3\n      where run_id = $1',\n    'r_' || p_queue_name\n  ) using p_run_id, v_now, p_state;\n\n  execute format(\n    'update absurd.%I\n        set state = ''completed'',\n            completed_payload = $2,\n            last_attempt_run = $3\n      where task_id = $1',\n    't_' || p_queue_name\n  ) using v_task_id, p_state, p_run_id;\n\n  execute format(\n    'delete from absurd.%I where run_id = $1',\n    'w_' || p_queue_name\n  ) using p_run_id;\nend;\n$$;\n\ncreate function absurd.schedule_run (\n  p_queue_name text,\n  p_run_id uuid,\n  p_wake_at timestamptz\n)\n  returns void\n  language plpgsql\nas $$\ndeclare\n  v_task_id uuid;\nbegin\n  execute format(\n    'select task_id\n       from absurd.%I\n      where run_id = $1\n        and state = ''running''\n      for update',\n    'r_' || p_queue_name\n  )\n  into v_task_id\n  using p_run_id;\n\n  if v_task_id is null then\n    raise exception 'Run \"%\" is not currently running in queue \"%\"', p_run_id, p_queue_name;\n  end if;\n\n  execute format(\n    'update absurd.%I\n        set state = ''sleeping'',\n            claimed_by = null,\n            claim_expires_at = null,\n            available_at = $2,\n            wake_event = null\n      where run_id = $1',\n    'r_' || p_queue_name\n  ) using p_run_id, p_wake_at;\n\n  execute format(\n    'update absurd.%I\n        set state = ''sleeping''\n      where task_id = $1',\n    't_' || p_queue_name\n  ) using v_task_id;\nend;\n$$;\n\ncreate function absurd.fail_run (\n  p_queue_name text,\n  p_run_id uuid,\n  p_reason jsonb,\n  p_retry_at timestamptz default null\n)\n  returns void\n  language plpgsql\nas $$\ndeclare\n  v_task_id uuid;\n  v_attempt integer;\n  v_retry_strategy jsonb;\n  v_max_attempts integer;\n  v_now timestamptz := absurd.current_time();\n  v_next_attempt integer;\n  v_delay_seconds double precision := 0;\n  v_next_available timestamptz;\n  v_retry_kind text;\n  v_base double precision;\n  v_factor double precision;\n  v_max_seconds double precision;\n  v_first_started timestamptz;\n  v_cancellation jsonb;\n  v_max_duration bigint;\n  v_task_cancel boolean := false;\n  v_new_run_id uuid;\n  v_task_state_after text;\n  v_recorded_attempt integer;\n  v_last_attempt_run uuid := p_run_id;\n  v_cancelled_at timestamptz := null;\nbegin\n  execute format(\n    'select r.task_id, r.attempt\n       from absurd.%I r\n      where r.run_id = $1\n        and r.state in (''running'', ''sleeping'')\n      for update',\n    'r_' || p_queue_name\n  )\n  into v_task_id, v_attempt\n  using p_run_id;\n\n  if v_task_id is null then\n    raise exception 'Run \"%\" cannot be failed in queue \"%\"', p_run_id, p_queue_name;\n  end if;\n\n  execute format(\n    'select retry_strategy, max_attempts, first_started_at, cancellation\n       from absurd.%I\n      where task_id = $1\n      for update',\n    't_' || p_queue_name\n  )\n  into v_retry_strategy, v_max_attempts, v_first_started, v_cancellation\n  using v_task_id;\n\n  execute format(\n    'update absurd.%I\n        set state = ''failed'',\n            wake_event = null,\n            failed_at = $2,\n            failure_reason = $3\n      where run_id = $1',\n    'r_' || p_queue_name\n  ) using p_run_id, v_now, p_reason;\n\n  v_next_attempt := v_attempt + 1;\n  v_task_state_after := 'failed';\n  v_recorded_attempt := v_attempt;\n\n  if v_max_attempts is null or v_next_attempt <= v_max_attempts then\n    if p_retry_at is not null then\n      v_next_available := p_retry_at;\n    else\n      v_retry_kind := coalesce(v_retry_strategy->>'kind', 'none');\n      if v_retry_kind = 'fixed' then\n        v_base := coalesce((v_retry_strategy->>'base_seconds')::double precision, 60);\n        v_delay_seconds := v_base;\n      elsif v_retry_kind = 'exponential' then\n        v_base := coalesce((v_retry_strategy->>'base_seconds')::double precision, 30);\n        v_factor := coalesce((v_retry_strategy->>'factor')::double precision, 2);\n        v_delay_seconds := v_base * power(v_factor, greatest(v_attempt - 1, 0));\n        v_max_seconds := (v_retry_strategy->>'max_seconds')::double precision;\n        if v_max_seconds is not null then\n          v_delay_seconds := least(v_delay_seconds, v_max_seconds);\n        end if;\n      else\n        v_delay_seconds := 0;\n      end if;\n      v_next_available := v_now + (v_delay_seconds * interval '1 second');\n    end if;\n\n    if v_next_available < v_now then\n      v_next_available := v_now;\n    end if;\n\n    if v_cancellation is not null then\n      v_max_duration := (v_cancellation->>'max_duration')::bigint;\n      if v_max_duration is not null and v_first_started is not null then\n        if extract(epoch from (v_next_available - v_first_started)) >= v_max_duration then\n          v_task_cancel := true;\n        end if;\n      end if;\n    end if;\n\n    if not v_task_cancel then\n      v_task_state_after := case when v_next_available > v_now then 'sleeping' else 'pending' end;\n      v_new_run_id := absurd.portable_uuidv7();\n      v_recorded_attempt := v_next_attempt;\n      v_last_attempt_run := v_new_run_id;\n      execute format(\n        'insert into absurd.%I (run_id, task_id, attempt, state, available_at, wake_event, event_payload, result, failure_reason)\n         values ($1, $2, $3, $4, $5, null, null, null, null)',\n        'r_' || p_queue_name\n      )\n      using v_new_run_id, v_task_id, v_next_attempt, v_task_state_after, v_next_available;\n    end if;\n  end if;\n\n  if v_task_cancel then\n    v_task_state_after := 'cancelled';\n    v_cancelled_at := v_now;\n    v_recorded_attempt := greatest(v_recorded_attempt, v_attempt);\n    v_last_attempt_run := p_run_id;\n  end if;\n\n  execute format(\n    'update absurd.%I\n        set state = $2,\n            attempts = greatest(attempts, $3),\n            last_attempt_run = $4,\n            cancelled_at = coalesce(cancelled_at, $5)\n      where task_id = $1',\n    't_' || p_queue_name\n  ) using v_task_id, v_task_state_after, v_recorded_attempt, v_last_attempt_run, v_cancelled_at;\n\n  execute format(\n    'delete from absurd.%I where run_id = $1',\n    'w_' || p_queue_name\n  ) using p_run_id;\nend;\n$$;\n\n-- Retries a failed task either by extending attempts on the same task or by\n-- spawning a brand new task from the original inputs.\n--\n-- Options:\n-- - spawn_new (boolean, default false): create a new task instead of retrying in-place.\n-- - max_attempts (integer, optional): for in-place retry, defaults to\n--   coalesce(current max_attempts, current attempts) + 1 and must be greater\n--   than current attempts; for spawn_new it overrides copied max_attempts on\n--   the new task.\ncreate function absurd.retry_task (\n  p_queue_name text,\n  p_task_id uuid,\n  p_options jsonb default '{}'::jsonb\n)\n  returns table (\n    task_id uuid,\n    run_id uuid,\n    attempt integer,\n    created boolean\n  )\n  language plpgsql\nas $$\ndeclare\n  v_now timestamptz := absurd.current_time();\n  v_spawn_new boolean := false;\n  v_requested_max_attempts integer;\n\n  v_task_name text;\n  v_params jsonb;\n  v_headers jsonb;\n  v_retry_strategy jsonb;\n  v_task_max_attempts integer;\n  v_cancellation jsonb;\n  v_task_attempts integer;\n  v_task_state text;\n\n  v_new_run_id uuid;\n  v_new_attempt integer;\n  v_spawn_options jsonb;\nbegin\n  if p_options is not null then\n    if p_options ? 'spawn_new' then\n      v_spawn_new := coalesce((p_options->>'spawn_new')::boolean, false);\n    end if;\n    if p_options ? 'max_attempts' then\n      v_requested_max_attempts := (p_options->>'max_attempts')::int;\n      if v_requested_max_attempts is not null and v_requested_max_attempts < 1 then\n        raise exception 'max_attempts must be >= 1';\n      end if;\n    end if;\n  end if;\n\n  execute format(\n    'select task_name,\n            params,\n            headers,\n            retry_strategy,\n            max_attempts,\n            cancellation,\n            attempts,\n            state\n       from absurd.%I\n      where task_id = $1\n      for update',\n    't_' || p_queue_name\n  )\n  into v_task_name,\n       v_params,\n       v_headers,\n       v_retry_strategy,\n       v_task_max_attempts,\n       v_cancellation,\n       v_task_attempts,\n       v_task_state\n  using p_task_id;\n\n  if v_task_state is null then\n    raise exception 'Task \"%\" not found in queue \"%\"', p_task_id, p_queue_name;\n  end if;\n\n  if v_task_state <> 'failed' then\n    raise exception 'Task \"%\" is not currently failed in queue \"%\"', p_task_id, p_queue_name;\n  end if;\n\n  if v_spawn_new then\n    v_spawn_options := jsonb_strip_nulls(jsonb_build_object(\n      'headers', v_headers,\n      'retry_strategy', v_retry_strategy,\n      'max_attempts', coalesce(v_requested_max_attempts, v_task_max_attempts),\n      'cancellation', v_cancellation\n    ));\n\n    return query\n      select s.task_id, s.run_id, s.attempt, s.created\n        from absurd.spawn_task(p_queue_name, v_task_name, v_params, v_spawn_options) s;\n    return;\n  end if;\n\n  if v_requested_max_attempts is null then\n    v_requested_max_attempts := coalesce(v_task_max_attempts, v_task_attempts) + 1;\n  end if;\n\n  if v_requested_max_attempts <= v_task_attempts then\n    raise exception 'max_attempts (%) must be greater than current attempts (%)',\n      v_requested_max_attempts,\n      v_task_attempts;\n  end if;\n\n  v_new_run_id := absurd.portable_uuidv7();\n  v_new_attempt := v_task_attempts + 1;\n\n  execute format(\n    'insert into absurd.%I (run_id, task_id, attempt, state, available_at, wake_event, event_payload, result, failure_reason)\n     values ($1, $2, $3, ''pending'', $4, null, null, null, null)',\n    'r_' || p_queue_name\n  )\n  using v_new_run_id, p_task_id, v_new_attempt, v_now;\n\n  execute format(\n    'update absurd.%I\n        set state = ''pending'',\n            attempts = greatest(attempts, $2),\n            max_attempts = $3,\n            last_attempt_run = $4,\n            cancelled_at = null\n      where task_id = $1',\n    't_' || p_queue_name\n  )\n  using p_task_id, v_new_attempt, v_requested_max_attempts, v_new_run_id;\n\n  return query select p_task_id, v_new_run_id, v_new_attempt, false;\nend;\n$$;\n\ncreate function absurd.set_task_checkpoint_state (\n  p_queue_name text,\n  p_task_id uuid,\n  p_step_name text,\n  p_state jsonb,\n  p_owner_run uuid,\n  p_extend_claim_by integer default null\n)\n  returns void\n  language plpgsql\nas $$\ndeclare\n  v_now timestamptz := absurd.current_time();\n  v_new_attempt integer;\n  v_existing_attempt integer;\n  v_existing_owner uuid;\n  v_task_state text;\n  v_run_state text;\nbegin\n  if p_step_name is null or length(trim(p_step_name)) = 0 then\n    raise exception 'step_name must be provided';\n  end if;\n\n  execute format(\n    'select r.attempt, r.state, t.state\n       from absurd.%I r\n       join absurd.%I t on t.task_id = r.task_id\n      where r.run_id = $1',\n    'r_' || p_queue_name,\n    't_' || p_queue_name\n  )\n  into v_new_attempt, v_run_state, v_task_state\n  using p_owner_run;\n\n  if v_new_attempt is null then\n    raise exception 'Run \"%\" not found for checkpoint', p_owner_run;\n  end if;\n\n  if v_task_state = 'cancelled' then\n    raise exception sqlstate 'AB001' using message = 'Task has been cancelled';\n  end if;\n\n  if v_run_state = 'failed' then\n    raise exception sqlstate 'AB002' using message = format('Run \"%s\" has already failed in queue \"%s\"', p_owner_run, p_queue_name);\n  end if;\n\n  -- Extend the claim if requested\n  if p_extend_claim_by is not null and p_extend_claim_by > 0 then\n    execute format(\n      'update absurd.%I\n          set claim_expires_at = $2 + make_interval(secs => $3)\n        where run_id = $1\n          and state = ''running''\n          and claim_expires_at is not null',\n      'r_' || p_queue_name\n    )\n    using p_owner_run, v_now, p_extend_claim_by;\n  end if;\n\n  execute format(\n    'select c.owner_run_id,\n            r.attempt\n       from absurd.%I c\n       left join absurd.%I r on r.run_id = c.owner_run_id\n      where c.task_id = $1\n        and c.checkpoint_name = $2',\n    'c_' || p_queue_name,\n    'r_' || p_queue_name\n  )\n  into v_existing_owner, v_existing_attempt\n  using p_task_id, p_step_name;\n\n  if v_existing_owner is null or v_existing_attempt is null or v_new_attempt >= v_existing_attempt then\n    execute format(\n      'insert into absurd.%I (task_id, checkpoint_name, state, status, owner_run_id, updated_at)\n       values ($1, $2, $3, ''committed'', $4, $5)\n       on conflict (task_id, checkpoint_name)\n       do update set state = excluded.state,\n                     status = excluded.status,\n                     owner_run_id = excluded.owner_run_id,\n                     updated_at = excluded.updated_at',\n      'c_' || p_queue_name\n    ) using p_task_id, p_step_name, p_state, p_owner_run, v_now;\n  end if;\nend;\n$$;\n\ncreate function absurd.extend_claim (\n  p_queue_name text,\n  p_run_id uuid,\n  p_extend_by integer\n)\n  returns void\n  language plpgsql\nas $$\ndeclare\n  v_now timestamptz := absurd.current_time();\n  v_task_state text;\n  v_run_state text;\n  v_claim_expires_at timestamptz;\nbegin\n  if p_extend_by is null or p_extend_by <= 0 then\n    raise exception 'extend_by must be > 0';\n  end if;\n\n  execute format(\n    'select r.state,\n            r.claim_expires_at,\n            t.state\n       from absurd.%I r\n       join absurd.%I t on t.task_id = r.task_id\n      where r.run_id = $1\n      for update',\n    'r_' || p_queue_name,\n    't_' || p_queue_name\n  )\n  into v_run_state, v_claim_expires_at, v_task_state\n  using p_run_id;\n\n  if v_run_state is null then\n    raise exception 'Run \"%\" not found in queue \"%\"', p_run_id, p_queue_name;\n  end if;\n\n  if v_task_state = 'cancelled' then\n    raise exception sqlstate 'AB001' using message = 'Task has been cancelled';\n  end if;\n\n  if v_run_state <> 'running' then\n    if v_run_state = 'failed' then\n      raise exception sqlstate 'AB002' using message = format('Run \"%s\" has already failed in queue \"%s\"', p_run_id, p_queue_name);\n    end if;\n    raise exception 'Run \"%\" is not currently running in queue \"%\"', p_run_id, p_queue_name;\n  end if;\n\n  if v_claim_expires_at is null then\n    raise exception 'Run \"%\" does not have an active claim in queue \"%\"', p_run_id, p_queue_name;\n  end if;\n\n  execute format(\n    'update absurd.%I\n        set claim_expires_at = $2 + make_interval(secs => $3)\n      where run_id = $1',\n    'r_' || p_queue_name\n  )\n  using p_run_id, v_now, p_extend_by;\nend;\n$$;\n\n-- Returns one checkpoint by name. By default only committed checkpoint rows\n-- are visible; pass p_include_pending = true to include pending rows.\ncreate function absurd.get_task_checkpoint_state (\n  p_queue_name text,\n  p_task_id uuid,\n  p_step_name text,\n  p_include_pending boolean default false\n)\n  returns table (\n    checkpoint_name text,\n    state jsonb,\n    status text,\n    owner_run_id uuid,\n    updated_at timestamptz\n  )\n  language plpgsql\nas $$\nbegin\n  return query execute format(\n    'select checkpoint_name, state, status, owner_run_id, updated_at\n       from absurd.%I\n      where task_id = $1\n        and checkpoint_name = $2\n        and ($3 or status = ''committed'')',\n    'c_' || p_queue_name\n  ) using p_task_id, p_step_name, coalesce(p_include_pending, false);\nend;\n$$;\n\n-- Returns committed checkpoints visible to the given run. The run must belong\n-- to the provided task, and checkpoints from later attempts are hidden.\ncreate function absurd.get_task_checkpoint_states (\n  p_queue_name text,\n  p_task_id uuid,\n  p_run_id uuid\n)\n  returns table (\n    checkpoint_name text,\n    state jsonb,\n    status text,\n    owner_run_id uuid,\n    updated_at timestamptz\n  )\n  language plpgsql\nas $$\ndeclare\n  v_run_task_id uuid;\n  v_run_attempt integer;\nbegin\n  execute format(\n    'select task_id, attempt\n       from absurd.%I\n      where run_id = $1',\n    'r_' || p_queue_name\n  )\n  into v_run_task_id, v_run_attempt\n  using p_run_id;\n\n  if v_run_task_id is null then\n    raise exception 'Run \"%\" not found in queue \"%\"', p_run_id, p_queue_name;\n  end if;\n\n  if v_run_task_id <> p_task_id then\n    raise exception 'Run \"%\" does not belong to task \"%\" in queue \"%\"', p_run_id, p_task_id, p_queue_name;\n  end if;\n\n  return query execute format(\n    'select c.checkpoint_name,\n            c.state,\n            c.status,\n            c.owner_run_id,\n            c.updated_at\n       from absurd.%1$I c\n       left join absurd.%2$I owner_run on owner_run.run_id = c.owner_run_id\n      where c.task_id = $1\n        and c.status = ''committed''\n        and (owner_run.attempt is null or owner_run.attempt <= $2)\n      order by c.updated_at asc',\n    'c_' || p_queue_name,\n    'r_' || p_queue_name\n  ) using p_task_id, v_run_attempt;\nend;\n$$;\n\ncreate function absurd.await_event (\n  p_queue_name text,\n  p_task_id uuid,\n  p_run_id uuid,\n  p_step_name text,\n  p_event_name text,\n  p_timeout integer default null\n)\n  returns table (\n    should_suspend boolean,\n    payload jsonb\n  )\n  language plpgsql\nas $$\ndeclare\n  v_run_state text;\n  v_existing_payload jsonb;\n  v_event_payload jsonb;\n  v_checkpoint_payload jsonb;\n  v_resolved_payload jsonb;\n  v_timeout_at timestamptz;\n  v_available_at timestamptz;\n  v_now timestamptz := absurd.current_time();\n  v_task_state text;\n  v_wake_event text;\nbegin\n  if p_event_name is null or length(trim(p_event_name)) = 0 then\n    raise exception 'event_name must be provided';\n  end if;\n\n  if p_timeout is not null then\n    if p_timeout < 0 then\n      raise exception 'timeout must be non-negative';\n    end if;\n    v_timeout_at := v_now + (p_timeout::double precision * interval '1 second');\n  end if;\n\n  v_available_at := coalesce(v_timeout_at, 'infinity'::timestamptz);\n\n  execute format(\n    'select state\n       from absurd.%I\n      where task_id = $1\n        and checkpoint_name = $2',\n    'c_' || p_queue_name\n  )\n  into v_checkpoint_payload\n  using p_task_id, p_step_name;\n\n  if v_checkpoint_payload is not null then\n    return query select false, v_checkpoint_payload;\n    return;\n  end if;\n\n  -- Ensure a row exists for this event so we can take a row-level lock.\n  --\n  -- We use payload IS NULL as the sentinel for \"not emitted yet\".  emit_event\n  -- always writes a non-NULL payload (at minimum JSON null).\n  --\n  -- Lock ordering is important to avoid deadlocks: await_event locks the event\n  -- row first (FOR SHARE) and then the run row (FOR UPDATE).  emit_event\n  -- naturally locks the event row via its UPSERT before touching waits/runs.\n  execute format(\n    'insert into absurd.%I (event_name, payload, emitted_at)\n     values ($1, null, ''epoch''::timestamptz)\n     on conflict (event_name) do nothing',\n    'e_' || p_queue_name\n  ) using p_event_name;\n\n  execute format(\n    'select 1\n       from absurd.%I\n      where event_name = $1\n      for share',\n    'e_' || p_queue_name\n  ) using p_event_name;\n\n  execute format(\n    'select r.state, r.event_payload, r.wake_event, t.state\n       from absurd.%I r\n       join absurd.%I t on t.task_id = r.task_id\n      where r.run_id = $1\n      for update',\n    'r_' || p_queue_name,\n    't_' || p_queue_name\n  )\n  into v_run_state, v_existing_payload, v_wake_event, v_task_state\n  using p_run_id;\n\n  if v_run_state is null then\n    raise exception 'Run \"%\" not found while awaiting event', p_run_id;\n  end if;\n\n  if v_task_state = 'cancelled' then\n    raise exception sqlstate 'AB001' using message = 'Task has been cancelled';\n  end if;\n\n  execute format(\n    'select payload\n       from absurd.%I\n      where event_name = $1',\n    'e_' || p_queue_name\n  )\n  into v_event_payload\n  using p_event_name;\n\n  if v_existing_payload is not null then\n    execute format(\n      'update absurd.%I\n          set event_payload = null\n        where run_id = $1',\n      'r_' || p_queue_name\n    ) using p_run_id;\n\n    if v_event_payload is not null and v_event_payload = v_existing_payload then\n      v_resolved_payload := v_existing_payload;\n    end if;\n  end if;\n\n  if v_run_state <> 'running' then\n    raise exception 'Run \"%\" must be running to await events', p_run_id;\n  end if;\n\n  if v_resolved_payload is null and v_event_payload is not null then\n    v_resolved_payload := v_event_payload;\n  end if;\n\n  if v_resolved_payload is not null then\n    execute format(\n      'insert into absurd.%I (task_id, checkpoint_name, state, status, owner_run_id, updated_at)\n       values ($1, $2, $3, ''committed'', $4, $5)\n       on conflict (task_id, checkpoint_name)\n       do update set state = excluded.state,\n                     status = excluded.status,\n                     owner_run_id = excluded.owner_run_id,\n                     updated_at = excluded.updated_at',\n      'c_' || p_queue_name\n    ) using p_task_id, p_step_name, v_resolved_payload, p_run_id, v_now;\n    return query select false, v_resolved_payload;\n    return;\n  end if;\n\n  -- Detect if we resumed due to timeout: wake_event matches and payload is null\n  if v_resolved_payload is null and v_wake_event = p_event_name and v_existing_payload is null then\n    -- Resumed due to timeout; don't re-sleep and don't create a new wait\n    execute format(\n      'update absurd.%I set wake_event = null where run_id = $1',\n      'r_' || p_queue_name\n    ) using p_run_id;\n    return query select false, null::jsonb;\n    return;\n  end if;\n\n  execute format(\n    'insert into absurd.%I (task_id, run_id, step_name, event_name, timeout_at, created_at)\n     values ($1, $2, $3, $4, $5, $6)\n     on conflict (run_id, step_name)\n     do update set event_name = excluded.event_name,\n                   timeout_at = excluded.timeout_at,\n                   created_at = excluded.created_at',\n    'w_' || p_queue_name\n  ) using p_task_id, p_run_id, p_step_name, p_event_name, v_timeout_at, v_now;\n\n  execute format(\n    'update absurd.%I\n        set state = ''sleeping'',\n            claimed_by = null,\n            claim_expires_at = null,\n            available_at = $3,\n            wake_event = $2,\n            event_payload = null\n      where run_id = $1',\n    'r_' || p_queue_name\n  ) using p_run_id, p_event_name, v_available_at;\n\n  execute format(\n    'update absurd.%I\n        set state = ''sleeping''\n      where task_id = $1',\n    't_' || p_queue_name\n  ) using p_task_id;\n\n  return query select true, null::jsonb;\n  return;\nend;\n$$;\n\ncreate function absurd.emit_event (\n  p_queue_name text,\n  p_event_name text,\n  p_payload jsonb default null\n)\n  returns void\n  language plpgsql\nas $$\ndeclare\n  v_now timestamptz := absurd.current_time();\n  v_payload jsonb := coalesce(p_payload, 'null'::jsonb);\n  v_emit_applied integer;\nbegin\n  if p_event_name is null or length(trim(p_event_name)) = 0 then\n    raise exception 'event_name must be provided';\n  end if;\n\n  -- Events are immutable once emitted: first write wins.\n  --\n  -- await_event() may pre-create a row with payload=NULL as a \"not emitted\"\n  -- sentinel. We allow exactly one transition NULL -> JSON payload.\n  execute format(\n    'insert into absurd.%1$I as e (event_name, payload, emitted_at)\n     values ($1, $2, $3)\n     on conflict (event_name)\n     do update set payload = excluded.payload,\n                   emitted_at = excluded.emitted_at\n      where e.payload is null',\n    'e_' || p_queue_name\n  ) using p_event_name, v_payload, v_now;\n\n  get diagnostics v_emit_applied = row_count;\n\n  -- Event was already emitted earlier; do not overwrite cached payload or\n  -- re-run wakeup side effects.\n  if v_emit_applied = 0 then\n    return;\n  end if;\n\n  execute format(\n    'with expired_waits as (\n        delete from absurd.%1$I w\n         where w.event_name = $1\n           and w.timeout_at is not null\n           and w.timeout_at <= $2\n         returning w.run_id\n     ),\n     affected as (\n        select run_id, task_id, step_name\n          from absurd.%1$I\n         where event_name = $1\n           and (timeout_at is null or timeout_at > $2)\n     ),\n     updated_runs as (\n        update absurd.%2$I r\n           set state = ''pending'',\n               available_at = $2,\n               wake_event = null,\n               event_payload = $3,\n               claimed_by = null,\n               claim_expires_at = null\n         where r.run_id in (select run_id from affected)\n           and r.state = ''sleeping''\n         returning r.run_id, r.task_id\n     ),\n     checkpoint_upd as (\n        insert into absurd.%3$I (task_id, checkpoint_name, state, status, owner_run_id, updated_at)\n        select a.task_id, a.step_name, $3, ''committed'', a.run_id, $2\n          from affected a\n          join updated_runs ur on ur.run_id = a.run_id\n        on conflict (task_id, checkpoint_name)\n        do update set state = excluded.state,\n                      status = excluded.status,\n                      owner_run_id = excluded.owner_run_id,\n                      updated_at = excluded.updated_at\n     ),\n     updated_tasks as (\n        update absurd.%4$I t\n           set state = ''pending''\n         where t.task_id in (select task_id from updated_runs)\n         returning task_id\n     )\n     delete from absurd.%5$I w\n      where w.event_name = $1\n        and w.run_id in (select run_id from updated_runs)',\n    'w_' || p_queue_name,\n    'r_' || p_queue_name,\n    'c_' || p_queue_name,\n    't_' || p_queue_name,\n    'w_' || p_queue_name\n  ) using p_event_name, v_now, v_payload;\nend;\n$$;\n\n-- Manually cancels a task by its task_id.\n-- Sets the task state to 'cancelled' and prevents any future runs.\n-- Currently running code will detect cancellation at the next checkpoint or heartbeat.\ncreate function absurd.cancel_task (\n  p_queue_name text,\n  p_task_id uuid\n)\n  returns void\n  language plpgsql\nas $$\ndeclare\n  v_now timestamptz := absurd.current_time();\n  v_task_state text;\nbegin\n  -- Lock active runs before the task row so cancel_task() uses the same\n  -- lock acquisition order as complete_run()/fail_run().\n  execute format(\n    'select run_id\n       from absurd.%I\n      where task_id = $1\n        and state not in (''completed'', ''failed'', ''cancelled'')\n      order by run_id\n      for update',\n    'r_' || p_queue_name\n  ) using p_task_id;\n\n  execute format(\n    'select state\n       from absurd.%I\n      where task_id = $1\n      for update',\n    't_' || p_queue_name\n  )\n  into v_task_state\n  using p_task_id;\n\n  if v_task_state is null then\n    raise exception 'Task \"%\" not found in queue \"%\"', p_task_id, p_queue_name;\n  end if;\n\n  if v_task_state in ('completed', 'failed', 'cancelled') then\n    return;\n  end if;\n\n  execute format(\n    'update absurd.%I\n        set state = ''cancelled'',\n            cancelled_at = coalesce(cancelled_at, $2)\n      where task_id = $1',\n    't_' || p_queue_name\n  ) using p_task_id, v_now;\n\n  execute format(\n    'update absurd.%I\n        set state = ''cancelled'',\n            claimed_by = null,\n            claim_expires_at = null\n      where task_id = $1\n        and state not in (''completed'', ''failed'', ''cancelled'')',\n    'r_' || p_queue_name\n  ) using p_task_id;\n\n  execute format(\n    'delete from absurd.%I where task_id = $1',\n    'w_' || p_queue_name\n  ) using p_task_id;\nend;\n$$;\n\n-- Cleans up old completed, failed, or cancelled tasks and their related data.\n-- Deletes tasks whose terminal timestamp (completed_at, failed_at, or cancelled_at)\n-- is older than the specified TTL in seconds.\n--\n-- Returns the number of tasks deleted.\ncreate function absurd.cleanup_tasks (\n  p_queue_name text,\n  p_ttl_seconds integer,\n  p_limit integer default 1000\n)\n  returns integer\n  language plpgsql\nas $$\ndeclare\n  v_now timestamptz := absurd.current_time();\n  v_cutoff timestamptz;\n  v_deleted_count integer;\nbegin\n  if p_ttl_seconds is null or p_ttl_seconds < 0 then\n    raise exception 'TTL must be a non-negative number of seconds';\n  end if;\n\n  v_cutoff := v_now - (p_ttl_seconds * interval '1 second');\n\n  -- Delete in order: wait registrations, checkpoints, runs, then tasks\n  -- Use a CTE to find eligible tasks and delete their related data\n  execute format(\n    'with eligible_tasks as (\n        select t.task_id,\n               case\n                 when t.state = ''completed'' then r.completed_at\n                 when t.state = ''failed'' then r.failed_at\n                 when t.state = ''cancelled'' then t.cancelled_at\n                 else null\n               end as terminal_at\n          from absurd.%1$I t\n          left join absurd.%2$I r on r.run_id = t.last_attempt_run\n         where t.state in (''completed'', ''failed'', ''cancelled'')\n     ),\n     to_delete as (\n        select task_id\n          from eligible_tasks\n         where terminal_at is not null\n           and terminal_at < $1\n         order by terminal_at\n         limit $2\n     ),\n     del_waits as (\n        delete from absurd.%3$I w\n         where w.task_id in (select task_id from to_delete)\n     ),\n     del_checkpoints as (\n        delete from absurd.%4$I c\n         where c.task_id in (select task_id from to_delete)\n     ),\n     del_runs as (\n        delete from absurd.%2$I r\n         where r.task_id in (select task_id from to_delete)\n     ),\n     del_tasks as (\n        delete from absurd.%1$I t\n         where t.task_id in (select task_id from to_delete)\n         returning 1\n     )\n     select count(*) from del_tasks',\n    't_' || p_queue_name,\n    'r_' || p_queue_name,\n    'w_' || p_queue_name,\n    'c_' || p_queue_name\n  )\n  into v_deleted_count\n  using v_cutoff, p_limit;\n\n  return v_deleted_count;\nend;\n$$;\n\n-- Cleans up old emitted events.\n-- Deletes events whose emitted_at timestamp is older than the specified TTL in seconds.\n--\n-- Returns the number of events deleted.\ncreate function absurd.cleanup_events (\n  p_queue_name text,\n  p_ttl_seconds integer,\n  p_limit integer default 1000\n)\n  returns integer\n  language plpgsql\nas $$\ndeclare\n  v_now timestamptz := absurd.current_time();\n  v_cutoff timestamptz;\n  v_deleted_count integer;\nbegin\n  if p_ttl_seconds is null or p_ttl_seconds < 0 then\n    raise exception 'TTL must be a non-negative number of seconds';\n  end if;\n\n  v_cutoff := v_now - (p_ttl_seconds * interval '1 second');\n\n  execute format(\n    'with to_delete as (\n        select event_name\n          from absurd.%I\n         where emitted_at < $1\n         order by emitted_at\n         limit $2\n     ),\n     del_events as (\n        delete from absurd.%I e\n         where e.event_name in (select event_name from to_delete)\n         returning 1\n     )\n     select count(*) from del_events',\n    'e_' || p_queue_name,\n    'e_' || p_queue_name\n  )\n  into v_deleted_count\n  using v_cutoff, p_limit;\n\n  return v_deleted_count;\nend;\n$$;\n\n-- utility function to generate a uuidv7 even for older postgres versions.\ncreate function absurd.portable_uuidv7 ()\n  returns uuid\n  language plpgsql\n  volatile\nas $$\ndeclare\n  v_server_num integer := current_setting('server_version_num')::int;\n  ts_ms bigint;\n  b bytea;\n  rnd bytea;\n  i int;\nbegin\n  if v_server_num >= 180000 then\n    return uuidv7 ();\n  end if;\n  ts_ms := floor(extract(epoch from absurd.current_time()) * 1000)::bigint;\n  rnd := uuid_send(uuid_generate_v4 ());\n  b := repeat(E'\\\\000', 16)::bytea;\n  for i in 0..5 loop\n    b := set_byte(b, i, ((ts_ms >> ((5 - i) * 8)) & 255)::int);\n  end loop;\n  for i in 6..15 loop\n    b := set_byte(b, i, get_byte(rnd, i));\n  end loop;\n  b := set_byte(b, 6, ((get_byte(b, 6) & 15) | (7 << 4)));\n  b := set_byte(b, 8, ((get_byte(b, 8) & 63) | 128));\n  return encode(b, 'hex')::uuid;\nend;\n$$;\n"
BUNDLED_MIGRATIONS = {"0.0.3-0.0.4.sql": "-- Migration from version 0.0.3 and earlier to 0.0.4\n-- This migration updates the claim_task, complete_run, and fail_run functions\n-- to improve claim timeout handling and add state validation.\n\n-- Replace absurd.claim_task with improved claim timeout handling\ncreate or replace function absurd.claim_task (\n  p_queue_name text,\n  p_worker_id text,\n  p_claim_timeout integer default 30,\n  p_qty integer default 1\n)\n  returns table (\n    run_id uuid,\n    task_id uuid,\n    attempt integer,\n    task_name text,\n    params jsonb,\n    retry_strategy jsonb,\n    max_attempts integer,\n    headers jsonb,\n    wake_event text,\n    event_payload jsonb\n  )\n  language plpgsql\nas $$\ndeclare\n  v_now timestamptz := absurd.current_time();\n  v_claim_timeout integer := greatest(coalesce(p_claim_timeout, 30), 0);\n  v_worker_id text := coalesce(nullif(p_worker_id, ''), 'worker');\n  v_qty integer := greatest(coalesce(p_qty, 1), 1);\n  v_claim_until timestamptz := null;\n  v_sql text;\n  v_expired_run record;\nbegin\n  if v_claim_timeout > 0 then\n    v_claim_until := v_now + make_interval(secs => v_claim_timeout);\n  end if;\n\n  -- Apply cancellation rules before claiming.\n  execute format(\n    'with limits as (\n        select task_id,\n               (cancellation->>''max_delay'')::bigint as max_delay,\n               (cancellation->>''max_duration'')::bigint as max_duration,\n               enqueue_at,\n               first_started_at,\n               state\n          from absurd.%I\n        where state in (''pending'', ''sleeping'', ''running'')\n     ),\n     to_cancel as (\n        select task_id\n          from limits\n         where\n           (\n             max_delay is not null\n             and first_started_at is null\n             and extract(epoch from ($1 - enqueue_at)) >= max_delay\n           )\n           or\n           (\n             max_duration is not null\n             and first_started_at is not null\n             and extract(epoch from ($1 - first_started_at)) >= max_duration\n           )\n     )\n     update absurd.%I t\n        set state = ''cancelled'',\n            cancelled_at = coalesce(t.cancelled_at, $1)\n      where t.task_id in (select task_id from to_cancel)',\n    't_' || p_queue_name,\n    't_' || p_queue_name\n  ) using v_now;\n\n  -- Handle expired claims by calling fail_run for each expired run\n  -- This provides better failure tracking with detailed error information\n  for v_expired_run in\n    execute format(\n      'select run_id,\n              claimed_by,\n              claim_expires_at,\n              attempt\n         from absurd.%I\n        where state = ''running''\n          and claim_expires_at is not null\n          and claim_expires_at <= $1\n        for update skip locked',\n      'r_' || p_queue_name\n    )\n  using v_now\n  loop\n    perform absurd.fail_run(\n      p_queue_name,\n      v_expired_run.run_id,\n      jsonb_strip_nulls(jsonb_build_object(\n        'name', '$ClaimTimeout',\n        'message', 'worker did not finish task within claim interval',\n        'workerId', v_expired_run.claimed_by,\n        'claimExpiredAt', v_expired_run.claim_expires_at,\n        'attempt', v_expired_run.attempt\n      )),\n      null\n    );\n  end loop;\n\n  execute format(\n    'update absurd.%I r\n        set state = ''cancelled'',\n            claimed_by = null,\n            claim_expires_at = null,\n            available_at = $1,\n            wake_event = null\n      where task_id in (select task_id from absurd.%I where state = ''cancelled'')\n        and r.state <> ''cancelled''',\n    'r_' || p_queue_name,\n    't_' || p_queue_name\n  ) using v_now;\n\n  v_sql := format(\n    'with candidate as (\n        select r.run_id\n          from absurd.%1$I r\n          join absurd.%2$I t on t.task_id = r.task_id\n         where r.state in (''pending'', ''sleeping'')\n           and t.state in (''pending'', ''sleeping'', ''running'')\n           and r.available_at <= $1\n         order by r.available_at, r.run_id\n         limit $2\n         for update skip locked\n     ),\n     updated as (\n        update absurd.%1$I r\n           set state = ''running'',\n               claimed_by = $3,\n               claim_expires_at = $4,\n               started_at = $1,\n               available_at = $1\n         where run_id in (select run_id from candidate)\n         returning r.run_id, r.task_id, r.attempt\n     ),\n     task_upd as (\n        update absurd.%2$I t\n           set state = ''running'',\n               attempts = greatest(t.attempts, u.attempt),\n               first_started_at = coalesce(t.first_started_at, $1),\n               last_attempt_run = u.run_id\n          from updated u\n         where t.task_id = u.task_id\n         returning t.task_id\n     ),\n     wait_cleanup as (\n        delete from absurd.%3$I w\n         using updated u\n        where w.run_id = u.run_id\n          and w.timeout_at is not null\n          and w.timeout_at <= $1\n        returning w.run_id\n     )\n     select\n       u.run_id,\n       u.task_id,\n       u.attempt,\n       t.task_name,\n       t.params,\n       t.retry_strategy,\n       t.max_attempts,\n      t.headers,\n      r.wake_event,\n      r.event_payload\n     from updated u\n     join absurd.%1$I r on r.run_id = u.run_id\n     join absurd.%2$I t on t.task_id = u.task_id\n     order by r.available_at, u.run_id',\n    'r_' || p_queue_name,\n    't_' || p_queue_name,\n    'w_' || p_queue_name\n  );\n\n  return query execute v_sql using v_now, v_qty, v_worker_id, v_claim_until;\nend;\n$$;\n\n-- Replace absurd.complete_run with state validation\ncreate or replace function absurd.complete_run (\n  p_queue_name text,\n  p_run_id uuid,\n  p_state jsonb default null\n)\n  returns void\n  language plpgsql\nas $$\ndeclare\n  v_task_id uuid;\n  v_state text;\n  v_now timestamptz := absurd.current_time();\nbegin\n  execute format(\n    'select task_id, state\n       from absurd.%I\n      where run_id = $1\n      for update',\n    'r_' || p_queue_name\n  )\n  into v_task_id, v_state\n  using p_run_id;\n\n  if v_task_id is null then\n    raise exception 'Run \"%\" not found in queue \"%\"', p_run_id, p_queue_name;\n  end if;\n\n  -- Add state validation\n  if v_state <> 'running' then\n    raise exception 'Run \"%\" is not currently running in queue \"%\"', p_run_id, p_queue_name;\n  end if;\n\n  execute format(\n    'update absurd.%I\n        set state = ''completed'',\n            completed_at = $2,\n            result = $3\n      where run_id = $1',\n    'r_' || p_queue_name\n  ) using p_run_id, v_now, p_state;\n\n  execute format(\n    'update absurd.%I\n        set state = ''completed'',\n            completed_payload = $2,\n            last_attempt_run = $3\n      where task_id = $1',\n    't_' || p_queue_name\n  ) using v_task_id, p_state, p_run_id;\n\n  execute format(\n    'delete from absurd.%I where run_id = $1',\n    'w_' || p_queue_name\n  ) using p_run_id;\nend;\n$$;\n\n-- Replace absurd.fail_run with cleaner update logic\ncreate or replace function absurd.fail_run (\n  p_queue_name text,\n  p_run_id uuid,\n  p_reason jsonb,\n  p_retry_at timestamptz default null\n)\n  returns void\n  language plpgsql\nas $$\ndeclare\n  v_task_id uuid;\n  v_attempt integer;\n  v_retry_strategy jsonb;\n  v_max_attempts integer;\n  v_now timestamptz := absurd.current_time();\n  v_next_attempt integer;\n  v_delay_seconds double precision := 0;\n  v_next_available timestamptz;\n  v_retry_kind text;\n  v_base double precision;\n  v_factor double precision;\n  v_max_seconds double precision;\n  v_first_started timestamptz;\n  v_cancellation jsonb;\n  v_max_duration bigint;\n  v_task_state text;\n  v_task_cancel boolean := false;\n  v_new_run_id uuid;\n  v_task_state_after text;\n  v_recorded_attempt integer;\n  v_last_attempt_run uuid := p_run_id;\n  v_cancelled_at timestamptz := null;\nbegin\n  execute format(\n    'select r.task_id, r.attempt\n       from absurd.%I r\n      where r.run_id = $1\n        and r.state in (''running'', ''sleeping'')\n      for update',\n    'r_' || p_queue_name\n  )\n  into v_task_id, v_attempt\n  using p_run_id;\n\n  if v_task_id is null then\n    raise exception 'Run \"%\" cannot be failed in queue \"%\"', p_run_id, p_queue_name;\n  end if;\n\n  execute format(\n    'select retry_strategy, max_attempts, first_started_at, cancellation, state\n       from absurd.%I\n      where task_id = $1\n      for update',\n    't_' || p_queue_name\n  )\n  into v_retry_strategy, v_max_attempts, v_first_started, v_cancellation, v_task_state\n  using v_task_id;\n\n  execute format(\n    'update absurd.%I\n        set state = ''failed'',\n            wake_event = null,\n            failed_at = $2,\n            failure_reason = $3\n      where run_id = $1',\n    'r_' || p_queue_name\n  ) using p_run_id, v_now, p_reason;\n\n  v_next_attempt := v_attempt + 1;\n  v_task_state_after := 'failed';\n  v_recorded_attempt := v_attempt;\n\n  if v_max_attempts is null or v_next_attempt <= v_max_attempts then\n    if p_retry_at is not null then\n      v_next_available := p_retry_at;\n    else\n      v_retry_kind := coalesce(v_retry_strategy->>'kind', 'none');\n      if v_retry_kind = 'fixed' then\n        v_base := coalesce((v_retry_strategy->>'base_seconds')::double precision, 60);\n        v_delay_seconds := v_base;\n      elsif v_retry_kind = 'exponential' then\n        v_base := coalesce((v_retry_strategy->>'base_seconds')::double precision, 30);\n        v_factor := coalesce((v_retry_strategy->>'factor')::double precision, 2);\n        v_delay_seconds := v_base * power(v_factor, greatest(v_attempt - 1, 0));\n        v_max_seconds := (v_retry_strategy->>'max_seconds')::double precision;\n        if v_max_seconds is not null then\n          v_delay_seconds := least(v_delay_seconds, v_max_seconds);\n        end if;\n      else\n        v_delay_seconds := 0;\n      end if;\n      v_next_available := v_now + (v_delay_seconds * interval '1 second');\n    end if;\n\n    if v_next_available < v_now then\n      v_next_available := v_now;\n    end if;\n\n    if v_cancellation is not null then\n      v_max_duration := (v_cancellation->>'max_duration')::bigint;\n      if v_max_duration is not null and v_first_started is not null then\n        if extract(epoch from (v_next_available - v_first_started)) >= v_max_duration then\n          v_task_cancel := true;\n        end if;\n      end if;\n    end if;\n\n    if not v_task_cancel then\n      v_task_state_after := case when v_next_available > v_now then 'sleeping' else 'pending' end;\n      v_new_run_id := absurd.portable_uuidv7();\n      v_recorded_attempt := v_next_attempt;\n      v_last_attempt_run := v_new_run_id;\n      execute format(\n        'insert into absurd.%I (run_id, task_id, attempt, state, available_at, wake_event, event_payload, result, failure_reason)\n         values ($1, $2, $3, %L, $4, null, null, null, null)',\n        'r_' || p_queue_name,\n        v_task_state_after\n      )\n      using v_new_run_id, v_task_id, v_next_attempt, v_next_available;\n    end if;\n  end if;\n\n  if v_task_cancel then\n    v_task_state_after := 'cancelled';\n    v_cancelled_at := v_now;\n    v_recorded_attempt := greatest(v_recorded_attempt, v_attempt);\n    v_last_attempt_run := p_run_id;\n  end if;\n\n  execute format(\n    'update absurd.%I\n        set state = %L,\n            attempts = greatest(attempts, $3),\n            last_attempt_run = $4,\n            cancelled_at = coalesce(cancelled_at, $5)\n      where task_id = $1',\n    't_' || p_queue_name,\n    v_task_state_after\n  ) using v_task_id, v_task_state_after, v_recorded_attempt, v_last_attempt_run, v_cancelled_at;\n\n  execute format(\n    'delete from absurd.%I where run_id = $1',\n    'w_' || p_queue_name\n  ) using p_run_id;\nend;\n$$;\n", "0.0.4-0.0.5.sql": "-- Migration from version 0.0.4 to 0.0.5\n--\n-- This migration adds task cancellation support and fixes event timeout handling:\n-- - Adds the absurd.extend_claim function for explicit claim extension with cancellation check\n-- - Adds the absurd.cancel_task function to manually cancel running tasks\n-- - Modifies set_task_checkpoint_state to check for cancelled tasks\n-- - Modifies await_event to check for cancelled tasks and handle timeout resumption correctly\n\n-- Add the new extend_claim function\ncreate function absurd.extend_claim (\n  p_queue_name text,\n  p_run_id uuid,\n  p_extend_by integer\n)\n  returns void\n  language plpgsql\nas $$\ndeclare\n  v_now timestamptz := absurd.current_time();\n  v_extend_by integer;\n  v_claim_timeout integer;\n  v_rows_updated integer;\n  v_task_state text;\nbegin\n  execute format(\n    'select t.state\n       from absurd.%I r\n       join absurd.%I t on t.task_id = r.task_id\n      where r.run_id = $1',\n    'r_' || p_queue_name,\n    't_' || p_queue_name\n  )\n  into v_task_state\n  using p_run_id;\n\n  if v_task_state = 'cancelled' then\n    raise exception sqlstate 'AB001' using message = 'Task has been cancelled';\n  end if;\n\n  execute format(\n    'update absurd.%I\n        set claim_expires_at = $2 + make_interval(secs => $3)\n      where run_id = $1\n        and state = ''running''\n        and claim_expires_at is not null',\n    'r_' || p_queue_name\n  )\n  using p_run_id, v_now, p_extend_by;\nend;\n$$;\n\n-- Add the new cancel_task function\ncreate function absurd.cancel_task (\n  p_queue_name text,\n  p_task_id uuid\n)\n  returns void\n  language plpgsql\nas $$\ndeclare\n  v_now timestamptz := absurd.current_time();\n  v_task_state text;\nbegin\n  execute format(\n    'select state\n       from absurd.%I\n      where task_id = $1\n      for update',\n    't_' || p_queue_name\n  )\n  into v_task_state\n  using p_task_id;\n\n  if v_task_state is null then\n    raise exception 'Task \"%\" not found in queue \"%\"', p_task_id, p_queue_name;\n  end if;\n\n  if v_task_state in ('completed', 'failed', 'cancelled') then\n    return;\n  end if;\n\n  execute format(\n    'update absurd.%I\n        set state = ''cancelled'',\n            cancelled_at = coalesce(cancelled_at, $2)\n      where task_id = $1',\n    't_' || p_queue_name\n  ) using p_task_id, v_now;\n\n  execute format(\n    'update absurd.%I\n        set state = ''cancelled'',\n            claimed_by = null,\n            claim_expires_at = null\n      where task_id = $1\n        and state not in (''completed'', ''failed'', ''cancelled'')',\n    'r_' || p_queue_name\n  ) using p_task_id;\n\n  execute format(\n    'delete from absurd.%I where task_id = $1',\n    'w_' || p_queue_name\n  ) using p_task_id;\nend;\n$$;\n\n-- Modify set_task_checkpoint_state to add cancellation check\ncreate or replace function absurd.set_task_checkpoint_state (\n  p_queue_name text,\n  p_task_id uuid,\n  p_step_name text,\n  p_state jsonb,\n  p_owner_run uuid,\n  p_extend_claim_by integer default null\n)\n  returns void\n  language plpgsql\nas $$\ndeclare\n  v_now timestamptz := absurd.current_time();\n  v_new_attempt integer;\n  v_existing_attempt integer;\n  v_existing_owner uuid;\n  v_task_state text;\nbegin\n  if p_step_name is null or length(trim(p_step_name)) = 0 then\n    raise exception 'step_name must be provided';\n  end if;\n\n  execute format(\n    'select r.attempt, t.state\n       from absurd.%I r\n       join absurd.%I t on t.task_id = r.task_id\n      where r.run_id = $1',\n    'r_' || p_queue_name,\n    't_' || p_queue_name\n  )\n  into v_new_attempt, v_task_state\n  using p_owner_run;\n\n  if v_new_attempt is null then\n    raise exception 'Run \"%\" not found for checkpoint', p_owner_run;\n  end if;\n\n  if v_task_state = 'cancelled' then\n    raise exception sqlstate 'AB001' using message = 'Task has been cancelled';\n  end if;\n\n  -- Extend the claim if requested\n  if p_extend_claim_by is not null and p_extend_claim_by > 0 then\n    execute format(\n      'update absurd.%I\n          set claim_expires_at = $2 + make_interval(secs => $3)\n        where run_id = $1\n          and state = ''running''\n          and claim_expires_at is not null',\n      'r_' || p_queue_name\n    )\n    using p_owner_run, v_now, p_extend_claim_by;\n  end if;\n\n  execute format(\n    'select c.owner_run_id,\n            r.attempt\n       from absurd.%I c\n       left join absurd.%I r on r.run_id = c.owner_run_id\n      where c.task_id = $1\n        and c.checkpoint_name = $2',\n    'c_' || p_queue_name,\n    'r_' || p_queue_name\n  )\n  into v_existing_owner, v_existing_attempt\n  using p_task_id, p_step_name;\n\n  if v_existing_owner is null or v_existing_attempt is null or v_new_attempt >= v_existing_attempt then\n    execute format(\n      'insert into absurd.%I (task_id, checkpoint_name, state, status, owner_run_id, updated_at)\n       values ($1, $2, $3, ''committed'', $4, $5)\n       on conflict (task_id, checkpoint_name)\n       do update set state = excluded.state,\n                     status = excluded.status,\n                     owner_run_id = excluded.owner_run_id,\n                     updated_at = excluded.updated_at',\n      'c_' || p_queue_name\n    ) using p_task_id, p_step_name, p_state, p_owner_run, v_now;\n  end if;\nend;\n$$;\n\n-- Modify await_event to add cancellation check\ncreate or replace function absurd.await_event (\n  p_queue_name text,\n  p_task_id uuid,\n  p_run_id uuid,\n  p_step_name text,\n  p_event_name text,\n  p_timeout integer default null\n)\n  returns table (\n    should_suspend boolean,\n    payload jsonb\n  )\n  language plpgsql\nas $$\ndeclare\n  v_run_state text;\n  v_existing_payload jsonb;\n  v_event_payload jsonb;\n  v_checkpoint_payload jsonb;\n  v_resolved_payload jsonb;\n  v_timeout_at timestamptz;\n  v_available_at timestamptz;\n  v_now timestamptz := absurd.current_time();\n  v_task_state text;\n  v_wake_event text;\nbegin\n  if p_event_name is null or length(trim(p_event_name)) = 0 then\n    raise exception 'event_name must be provided';\n  end if;\n\n  if p_timeout is not null then\n    if p_timeout < 0 then\n      raise exception 'timeout must be non-negative';\n    end if;\n    v_timeout_at := v_now + (p_timeout::double precision * interval '1 second');\n  end if;\n\n  v_available_at := coalesce(v_timeout_at, 'infinity'::timestamptz);\n\n  execute format(\n    'select state\n       from absurd.%I\n      where task_id = $1\n        and checkpoint_name = $2',\n    'c_' || p_queue_name\n  )\n  into v_checkpoint_payload\n  using p_task_id, p_step_name;\n\n  if v_checkpoint_payload is not null then\n    return query select false, v_checkpoint_payload;\n    return;\n  end if;\n\n  execute format(\n    'select r.state, r.event_payload, r.wake_event, t.state\n       from absurd.%I r\n       join absurd.%I t on t.task_id = r.task_id\n      where r.run_id = $1\n      for update',\n    'r_' || p_queue_name,\n    't_' || p_queue_name\n  )\n  into v_run_state, v_existing_payload, v_wake_event, v_task_state\n  using p_run_id;\n\n  if v_run_state is null then\n    raise exception 'Run \"%\" not found while awaiting event', p_run_id;\n  end if;\n\n  if v_task_state = 'cancelled' then\n    raise exception sqlstate 'AB001' using message = 'Task has been cancelled';\n  end if;\n\n  execute format(\n    'select payload\n       from absurd.%I\n      where event_name = $1',\n    'e_' || p_queue_name\n  )\n  into v_event_payload\n  using p_event_name;\n\n  if v_existing_payload is not null then\n    execute format(\n      'update absurd.%I\n          set event_payload = null\n        where run_id = $1',\n      'r_' || p_queue_name\n    ) using p_run_id;\n\n    if v_event_payload is not null and v_event_payload = v_existing_payload then\n      v_resolved_payload := v_existing_payload;\n    end if;\n  end if;\n\n  if v_run_state <> 'running' then\n    raise exception 'Run \"%\" must be running to await events', p_run_id;\n  end if;\n\n  if v_resolved_payload is null and v_event_payload is not null then\n    v_resolved_payload := v_event_payload;\n  end if;\n\n  if v_resolved_payload is not null then\n    execute format(\n      'insert into absurd.%I (task_id, checkpoint_name, state, status, owner_run_id, updated_at)\n       values ($1, $2, $3, ''committed'', $4, $5)\n       on conflict (task_id, checkpoint_name)\n       do update set state = excluded.state,\n                     status = excluded.status,\n                     owner_run_id = excluded.owner_run_id,\n                     updated_at = excluded.updated_at',\n      'c_' || p_queue_name\n    ) using p_task_id, p_step_name, v_resolved_payload, p_run_id, v_now;\n    return query select false, v_resolved_payload;\n    return;\n  end if;\n\n  -- Detect if we resumed due to timeout: wake_event matches and payload is null\n  if v_resolved_payload is null and v_wake_event = p_event_name and v_existing_payload is null then\n    -- Resumed due to timeout; don't re-sleep and don't create a new wait\n    execute format(\n      'update absurd.%I set wake_event = null where run_id = $1',\n      'r_' || p_queue_name\n    ) using p_run_id;\n    return query select false, null::jsonb;\n    return;\n  end if;\n\n  execute format(\n    'insert into absurd.%I (task_id, run_id, step_name, event_name, timeout_at, created_at)\n     values ($1, $2, $3, $4, $5, $6)\n     on conflict (run_id, step_name)\n     do update set event_name = excluded.event_name,\n                   timeout_at = excluded.timeout_at,\n                   created_at = excluded.created_at',\n    'w_' || p_queue_name\n  ) using p_task_id, p_run_id, p_step_name, p_event_name, v_timeout_at, v_now;\n\n  execute format(\n    'update absurd.%I\n        set state = ''sleeping'',\n            claimed_by = null,\n            claim_expires_at = null,\n            available_at = $3,\n            wake_event = $2,\n            event_payload = null\n      where run_id = $1',\n    'r_' || p_queue_name\n  ) using p_run_id, p_event_name, v_available_at;\n\n  execute format(\n    'update absurd.%I\n        set state = ''sleeping''\n      where task_id = $1',\n    't_' || p_queue_name\n  ) using p_task_id;\n\n  return query select true, null::jsonb;\n  return;\nend;\n$$;\n", "0.0.5-0.0.6.sql": "-- Migration from version 0.0.5 to main\n--\n-- This migration adds idempotency key support for task spawning:\n-- - Adds idempotency_key column (text, unique) to task tables (t_{queue})\n-- - Modifies spawn_task to support idempotency via INSERT ... ON CONFLICT DO NOTHING\n-- - spawn_task now returns a 'created' boolean to indicate if a new task was created\n\n-- Add idempotency_key column to all existing task tables\ndo $$\ndeclare\n  v_queue record;\nbegin\n  for v_queue in select queue_name from absurd.queues\n  loop\n    execute format(\n      'alter table absurd.%I add column if not exists idempotency_key text unique',\n      't_' || v_queue.queue_name\n    );\n  end loop;\nend;\n$$;\n\n-- Update ensure_queue_tables to include idempotency_key column\ncreate or replace function absurd.ensure_queue_tables (p_queue_name text)\n  returns void\n  language plpgsql\nas $$\nbegin\n  execute format(\n    'create table if not exists absurd.%I (\n        task_id uuid primary key,\n        task_name text not null,\n        params jsonb not null,\n        headers jsonb,\n        retry_strategy jsonb,\n        max_attempts integer,\n        cancellation jsonb,\n        enqueue_at timestamptz not null default absurd.current_time(),\n        first_started_at timestamptz,\n        state text not null check (state in (''pending'', ''running'', ''sleeping'', ''completed'', ''failed'', ''cancelled'')),\n        attempts integer not null default 0,\n        last_attempt_run uuid,\n        completed_payload jsonb,\n        cancelled_at timestamptz,\n        idempotency_key text unique\n     ) with (fillfactor=70)',\n    't_' || p_queue_name\n  );\n\n  execute format(\n    'create table if not exists absurd.%I (\n        run_id uuid primary key,\n        task_id uuid not null,\n        attempt integer not null,\n        state text not null check (state in (''pending'', ''running'', ''sleeping'', ''completed'', ''failed'', ''cancelled'')),\n        claimed_by text,\n        claim_expires_at timestamptz,\n        available_at timestamptz not null,\n        wake_event text,\n        event_payload jsonb,\n        started_at timestamptz,\n        completed_at timestamptz,\n        failed_at timestamptz,\n        result jsonb,\n        failure_reason jsonb,\n        created_at timestamptz not null default absurd.current_time()\n     ) with (fillfactor=70)',\n    'r_' || p_queue_name\n  );\n\n  execute format(\n    'create table if not exists absurd.%I (\n        task_id uuid not null,\n        checkpoint_name text not null,\n        state jsonb,\n        status text not null default ''committed'',\n        owner_run_id uuid,\n        updated_at timestamptz not null default absurd.current_time(),\n        primary key (task_id, checkpoint_name)\n     ) with (fillfactor=70)',\n    'c_' || p_queue_name\n  );\n\n  execute format(\n    'create table if not exists absurd.%I (\n        event_name text primary key,\n        payload jsonb,\n        emitted_at timestamptz not null default absurd.current_time()\n     )',\n    'e_' || p_queue_name\n  );\n\n  execute format(\n    'create table if not exists absurd.%I (\n        task_id uuid not null,\n        run_id uuid not null,\n        step_name text not null,\n        event_name text not null,\n        timeout_at timestamptz,\n        created_at timestamptz not null default absurd.current_time(),\n        primary key (run_id, step_name)\n     )',\n    'w_' || p_queue_name\n  );\n\n  execute format(\n    'create index if not exists %I on absurd.%I (state, available_at)',\n    ('r_' || p_queue_name) || '_sai',\n    'r_' || p_queue_name\n  );\n\n  execute format(\n    'create index if not exists %I on absurd.%I (task_id)',\n    ('r_' || p_queue_name) || '_ti',\n    'r_' || p_queue_name\n  );\n\n  execute format(\n    'create index if not exists %I on absurd.%I (event_name)',\n    ('w_' || p_queue_name) || '_eni',\n    'w_' || p_queue_name\n  );\nend;\n$$;\n\n-- Replace spawn_task with idempotency support\ndrop function if exists absurd.spawn_task(text, text, jsonb, jsonb);\n\ncreate function absurd.spawn_task (\n  p_queue_name text,\n  p_task_name text,\n  p_params jsonb,\n  p_options jsonb default '{}'::jsonb\n)\n  returns table (\n    task_id uuid,\n    run_id uuid,\n    attempt integer,\n    created boolean\n  )\n  language plpgsql\nas $$\ndeclare\n  v_task_id uuid := absurd.portable_uuidv7();\n  v_run_id uuid := absurd.portable_uuidv7();\n  v_attempt integer := 1;\n  v_headers jsonb;\n  v_retry_strategy jsonb;\n  v_max_attempts integer;\n  v_cancellation jsonb;\n  v_idempotency_key text;\n  v_existing_task_id uuid;\n  v_row_count integer;\n  v_now timestamptz := absurd.current_time();\n  v_params jsonb := coalesce(p_params, 'null'::jsonb);\nbegin\n  if p_task_name is null or length(trim(p_task_name)) = 0 then\n    raise exception 'task_name must be provided';\n  end if;\n\n  if p_options is not null then\n    v_headers := p_options->'headers';\n    v_retry_strategy := p_options->'retry_strategy';\n    if p_options ? 'max_attempts' then\n      v_max_attempts := (p_options->>'max_attempts')::int;\n      if v_max_attempts is not null and v_max_attempts < 1 then\n        raise exception 'max_attempts must be >= 1';\n      end if;\n    end if;\n    v_cancellation := p_options->'cancellation';\n    v_idempotency_key := p_options->>'idempotency_key';\n  end if;\n\n  -- If idempotency_key is provided, use INSERT ... ON CONFLICT DO NOTHING\n  if v_idempotency_key is not null then\n    execute format(\n      'insert into absurd.%I (task_id, task_name, params, headers, retry_strategy, max_attempts, cancellation, enqueue_at, first_started_at, state, attempts, last_attempt_run, completed_payload, cancelled_at, idempotency_key)\n       values ($1, $2, $3, $4, $5, $6, $7, $8, null, ''pending'', $9, $10, null, null, $11)\n       on conflict (idempotency_key) do nothing',\n      't_' || p_queue_name\n    )\n    using v_task_id, p_task_name, v_params, v_headers, v_retry_strategy, v_max_attempts, v_cancellation, v_now, v_attempt, v_run_id, v_idempotency_key;\n\n    get diagnostics v_row_count = row_count;\n\n    if v_row_count = 0 then\n      -- Task already exists, look up existing task info\n      execute format(\n        'select task_id, last_attempt_run, attempts from absurd.%I where idempotency_key = $1',\n        't_' || p_queue_name\n      )\n      into v_existing_task_id, v_run_id, v_attempt\n      using v_idempotency_key;\n\n      return query select v_existing_task_id, v_run_id, v_attempt, false;\n      return;\n    end if;\n  else\n    -- No idempotency key, insert normally\n    execute format(\n      'insert into absurd.%I (task_id, task_name, params, headers, retry_strategy, max_attempts, cancellation, enqueue_at, first_started_at, state, attempts, last_attempt_run, completed_payload, cancelled_at, idempotency_key)\n       values ($1, $2, $3, $4, $5, $6, $7, $8, null, ''pending'', $9, $10, null, null, null)',\n      't_' || p_queue_name\n    )\n    using v_task_id, p_task_name, v_params, v_headers, v_retry_strategy, v_max_attempts, v_cancellation, v_now, v_attempt, v_run_id;\n  end if;\n\n  execute format(\n    'insert into absurd.%I (run_id, task_id, attempt, state, available_at, wake_event, event_payload, result, failure_reason)\n     values ($1, $2, $3, ''pending'', $4, null, null, null, null)',\n    'r_' || p_queue_name\n  )\n  using v_run_id, v_task_id, v_attempt, v_now;\n\n  return query select v_task_id, v_run_id, v_attempt, true;\nend;\n$$;\n", "0.0.6-0.0.7.sql": "-- Migration from 0.0.6 to main\n--\n-- This migration fixes a race condition in await_event where an event could be\n-- emitted between checking for the event and going to sleep, causing the task\n-- to miss the event and sleep forever.\n--\n-- The fix ensures that await_event takes a row-level lock on the event row\n-- before checking if the event has been emitted. This guarantees that if\n-- emit_event is in progress, await_event will wait for it to complete, and\n-- vice versa.\n\ncreate or replace function absurd.await_event (\n  p_queue_name text,\n  p_task_id uuid,\n  p_run_id uuid,\n  p_step_name text,\n  p_event_name text,\n  p_timeout integer default null\n)\n  returns table (\n    should_suspend boolean,\n    payload jsonb\n  )\n  language plpgsql\nas $$\ndeclare\n  v_run_state text;\n  v_existing_payload jsonb;\n  v_event_payload jsonb;\n  v_checkpoint_payload jsonb;\n  v_resolved_payload jsonb;\n  v_timeout_at timestamptz;\n  v_available_at timestamptz;\n  v_now timestamptz := absurd.current_time();\n  v_task_state text;\n  v_wake_event text;\nbegin\n  if p_event_name is null or length(trim(p_event_name)) = 0 then\n    raise exception 'event_name must be provided';\n  end if;\n\n  if p_timeout is not null then\n    if p_timeout < 0 then\n      raise exception 'timeout must be non-negative';\n    end if;\n    v_timeout_at := v_now + (p_timeout::double precision * interval '1 second');\n  end if;\n\n  v_available_at := coalesce(v_timeout_at, 'infinity'::timestamptz);\n\n  execute format(\n    'select state\n       from absurd.%I\n      where task_id = $1\n        and checkpoint_name = $2',\n    'c_' || p_queue_name\n  )\n  into v_checkpoint_payload\n  using p_task_id, p_step_name;\n\n  if v_checkpoint_payload is not null then\n    return query select false, v_checkpoint_payload;\n    return;\n  end if;\n\n  -- Ensure a row exists for this event so we can take a row-level lock.\n  --\n  -- We use payload IS NULL as the sentinel for \"not emitted yet\".  emit_event\n  -- always writes a non-NULL payload (at minimum JSON null).\n  --\n  -- Lock ordering is important to avoid deadlocks: await_event locks the event\n  -- row first (FOR SHARE) and then the run row (FOR UPDATE).  emit_event\n  -- naturally locks the event row via its UPSERT before touching waits/runs.\n  execute format(\n    'insert into absurd.%I (event_name, payload, emitted_at)\n     values ($1, null, ''epoch''::timestamptz)\n     on conflict (event_name) do nothing',\n    'e_' || p_queue_name\n  ) using p_event_name;\n\n  execute format(\n    'select 1\n       from absurd.%I\n      where event_name = $1\n      for share',\n    'e_' || p_queue_name\n  ) using p_event_name;\n\n  execute format(\n    'select r.state, r.event_payload, r.wake_event, t.state\n       from absurd.%I r\n       join absurd.%I t on t.task_id = r.task_id\n      where r.run_id = $1\n      for update',\n    'r_' || p_queue_name,\n    't_' || p_queue_name\n  )\n  into v_run_state, v_existing_payload, v_wake_event, v_task_state\n  using p_run_id;\n\n  if v_run_state is null then\n    raise exception 'Run \"%\" not found while awaiting event', p_run_id;\n  end if;\n\n  if v_task_state = 'cancelled' then\n    raise exception sqlstate 'AB001' using message = 'Task has been cancelled';\n  end if;\n\n  execute format(\n    'select payload\n       from absurd.%I\n      where event_name = $1',\n    'e_' || p_queue_name\n  )\n  into v_event_payload\n  using p_event_name;\n\n  if v_existing_payload is not null then\n    execute format(\n      'update absurd.%I\n          set event_payload = null\n        where run_id = $1',\n      'r_' || p_queue_name\n    ) using p_run_id;\n\n    if v_event_payload is not null and v_event_payload = v_existing_payload then\n      v_resolved_payload := v_existing_payload;\n    end if;\n  end if;\n\n  if v_run_state <> 'running' then\n    raise exception 'Run \"%\" must be running to await events', p_run_id;\n  end if;\n\n  if v_resolved_payload is null and v_event_payload is not null then\n    v_resolved_payload := v_event_payload;\n  end if;\n\n  if v_resolved_payload is not null then\n    execute format(\n      'insert into absurd.%I (task_id, checkpoint_name, state, status, owner_run_id, updated_at)\n       values ($1, $2, $3, ''committed'', $4, $5)\n       on conflict (task_id, checkpoint_name)\n       do update set state = excluded.state,\n                     status = excluded.status,\n                     owner_run_id = excluded.owner_run_id,\n                     updated_at = excluded.updated_at',\n      'c_' || p_queue_name\n    ) using p_task_id, p_step_name, v_resolved_payload, p_run_id, v_now;\n    return query select false, v_resolved_payload;\n    return;\n  end if;\n\n  -- Detect if we resumed due to timeout: wake_event matches and payload is null\n  if v_resolved_payload is null and v_wake_event = p_event_name and v_existing_payload is null then\n    -- Resumed due to timeout; don't re-sleep and don't create a new wait\n    execute format(\n      'update absurd.%I set wake_event = null where run_id = $1',\n      'r_' || p_queue_name\n    ) using p_run_id;\n    return query select false, null::jsonb;\n    return;\n  end if;\n\n  execute format(\n    'insert into absurd.%I (task_id, run_id, step_name, event_name, timeout_at, created_at)\n     values ($1, $2, $3, $4, $5, $6)\n     on conflict (run_id, step_name)\n     do update set event_name = excluded.event_name,\n                   timeout_at = excluded.timeout_at,\n                   created_at = excluded.created_at',\n    'w_' || p_queue_name\n  ) using p_task_id, p_run_id, p_step_name, p_event_name, v_timeout_at, v_now;\n\n  execute format(\n    'update absurd.%I\n        set state = ''sleeping'',\n            claimed_by = null,\n            claim_expires_at = null,\n            available_at = $3,\n            wake_event = $2,\n            event_payload = null\n      where run_id = $1',\n    'r_' || p_queue_name\n  ) using p_run_id, p_event_name, v_available_at;\n\n  execute format(\n    'update absurd.%I\n        set state = ''sleeping''\n      where task_id = $1',\n    't_' || p_queue_name\n  ) using p_task_id;\n\n  return query select true, null::jsonb;\n  return;\nend;\n$$;\n", "0.0.8-0.1.0.sql": "-- Migration from 0.0.8 to 0.1.0\n--\n-- This migration introduces queue name byte-length validation, adds missing\n-- indexes for existing queues, and updates runtime functions for cancellation,\n-- claim extension, checkpoint visibility, and event immutability semantics.\n\ncreate or replace function absurd.validate_queue_name (p_queue_name text)\n  returns text\n  language plpgsql\nas $$\nbegin\n  if p_queue_name is null or length(trim(p_queue_name)) = 0 then\n    raise exception 'Queue name must be provided';\n  end if;\n\n  if octet_length(p_queue_name) > 57 then\n    raise exception 'Queue name \"%\" is too long (max 57 bytes).', p_queue_name;\n  end if;\n\n  return p_queue_name;\nend;\n$$;\n\ncreate or replace function absurd.ensure_queue_tables (p_queue_name text)\n  returns void\n  language plpgsql\nas $$\nbegin\n  perform absurd.validate_queue_name(p_queue_name);\n\n  execute format(\n    'create table if not exists absurd.%I (\n        task_id uuid primary key,\n        task_name text not null,\n        params jsonb not null,\n        headers jsonb,\n        retry_strategy jsonb,\n        max_attempts integer,\n        cancellation jsonb,\n        enqueue_at timestamptz not null default absurd.current_time(),\n        first_started_at timestamptz,\n        state text not null check (state in (''pending'', ''running'', ''sleeping'', ''completed'', ''failed'', ''cancelled'')),\n        attempts integer not null default 0,\n        last_attempt_run uuid,\n        completed_payload jsonb,\n        cancelled_at timestamptz,\n        idempotency_key text unique\n     ) with (fillfactor=70)',\n    't_' || p_queue_name\n  );\n\n  execute format(\n    'create table if not exists absurd.%I (\n        run_id uuid primary key,\n        task_id uuid not null,\n        attempt integer not null,\n        state text not null check (state in (''pending'', ''running'', ''sleeping'', ''completed'', ''failed'', ''cancelled'')),\n        claimed_by text,\n        claim_expires_at timestamptz,\n        available_at timestamptz not null,\n        wake_event text,\n        event_payload jsonb,\n        started_at timestamptz,\n        completed_at timestamptz,\n        failed_at timestamptz,\n        result jsonb,\n        failure_reason jsonb,\n        created_at timestamptz not null default absurd.current_time()\n     ) with (fillfactor=70)',\n    'r_' || p_queue_name\n  );\n\n  execute format(\n    'create table if not exists absurd.%I (\n        task_id uuid not null,\n        checkpoint_name text not null,\n        state jsonb,\n        status text not null default ''committed'',\n        owner_run_id uuid,\n        updated_at timestamptz not null default absurd.current_time(),\n        primary key (task_id, checkpoint_name)\n     ) with (fillfactor=70)',\n    'c_' || p_queue_name\n  );\n\n  execute format(\n    'create table if not exists absurd.%I (\n        event_name text primary key,\n        payload jsonb,\n        emitted_at timestamptz not null default absurd.current_time()\n     )',\n    'e_' || p_queue_name\n  );\n\n  execute format(\n    'create table if not exists absurd.%I (\n        task_id uuid not null,\n        run_id uuid not null,\n        step_name text not null,\n        event_name text not null,\n        timeout_at timestamptz,\n        created_at timestamptz not null default absurd.current_time(),\n        primary key (run_id, step_name)\n     )',\n    'w_' || p_queue_name\n  );\n\n  execute format(\n    'create index if not exists %I on absurd.%I (state, available_at)',\n    ('r_' || p_queue_name) || '_sai',\n    'r_' || p_queue_name\n  );\n\n  execute format(\n    'create index if not exists %I on absurd.%I (task_id)',\n    ('r_' || p_queue_name) || '_ti',\n    'r_' || p_queue_name\n  );\n\n  execute format(\n    'create index if not exists %I on absurd.%I (claim_expires_at)\n      where state = ''running''\n        and claim_expires_at is not null',\n    ('r_' || p_queue_name) || '_cei',\n    'r_' || p_queue_name\n  );\n\n  execute format(\n    'create index if not exists %I on absurd.%I (event_name)',\n    ('w_' || p_queue_name) || '_eni',\n    'w_' || p_queue_name\n  );\n\n  execute format(\n    'create index if not exists %I on absurd.%I (task_id)',\n    ('w_' || p_queue_name) || '_ti',\n    'w_' || p_queue_name\n  );\n\n  execute format(\n    'create index if not exists %I on absurd.%I (emitted_at)',\n    ('e_' || p_queue_name) || '_eai',\n    'e_' || p_queue_name\n  );\nend;\n$$;\n\ncreate or replace function absurd.create_queue (p_queue_name text)\n  returns void\n  language plpgsql\nas $$\nbegin\n  p_queue_name := absurd.validate_queue_name(p_queue_name);\n\n  begin\n    insert into absurd.queues (queue_name)\n    values (p_queue_name);\n  exception when unique_violation then\n    return;\n  end;\n\n  perform absurd.ensure_queue_tables(p_queue_name);\nend;\n$$;\n\n-- Add new indexes to already existing queues.\ndo $$\ndeclare\n  v_queue record;\nbegin\n  for v_queue in select queue_name from absurd.queues\n  loop\n    execute format(\n      'create index if not exists %I on absurd.%I (claim_expires_at)\n        where state = ''running''\n          and claim_expires_at is not null',\n      ('r_' || v_queue.queue_name) || '_cei',\n      'r_' || v_queue.queue_name\n    );\n\n    execute format(\n      'create index if not exists %I on absurd.%I (task_id)',\n      ('w_' || v_queue.queue_name) || '_ti',\n      'w_' || v_queue.queue_name\n    );\n\n    execute format(\n      'create index if not exists %I on absurd.%I (emitted_at)',\n      ('e_' || v_queue.queue_name) || '_eai',\n      'e_' || v_queue.queue_name\n    );\n  end loop;\nend;\n$$;\n\ncreate or replace function absurd.claim_task (\n  p_queue_name text,\n  p_worker_id text,\n  p_claim_timeout integer default 30,\n  p_qty integer default 1\n)\n  returns table (\n    run_id uuid,\n    task_id uuid,\n    attempt integer,\n    task_name text,\n    params jsonb,\n    retry_strategy jsonb,\n    max_attempts integer,\n    headers jsonb,\n    wake_event text,\n    event_payload jsonb\n  )\n  language plpgsql\nas $$\ndeclare\n  v_now timestamptz := absurd.current_time();\n  v_claim_timeout integer := greatest(coalesce(p_claim_timeout, 30), 0);\n  v_worker_id text := coalesce(nullif(p_worker_id, ''), 'worker');\n  v_qty integer := greatest(coalesce(p_qty, 1), 1);\n  v_claim_until timestamptz := null;\n  v_sql text;\n  v_expired_run record;\n  v_cancel_candidate record;\n  v_expired_sweep_limit integer;\nbegin\n  if v_claim_timeout > 0 then\n    v_claim_until := v_now + make_interval(secs => v_claim_timeout);\n  end if;\n\n  -- Keep claim polling work bounded: process at most v_qty expired leases\n  -- per claim call.\n  v_expired_sweep_limit := greatest(v_qty, 1);\n\n  -- Apply cancellation rules before claiming.\n  --\n  -- Use cancel_task() so lock order stays consistent (runs first, task second)\n  -- with complete_run()/fail_run().\n  for v_cancel_candidate in\n    execute format(\n      'select task_id\n         from absurd.%I\n        where state in (''pending'', ''sleeping'', ''running'')\n          and (\n            (\n              (cancellation->>''max_delay'')::bigint is not null\n              and first_started_at is null\n              and extract(epoch from ($1 - enqueue_at)) >= (cancellation->>''max_delay'')::bigint\n            )\n            or\n            (\n              (cancellation->>''max_duration'')::bigint is not null\n              and first_started_at is not null\n              and extract(epoch from ($1 - first_started_at)) >= (cancellation->>''max_duration'')::bigint\n            )\n          )\n        order by task_id',\n      't_' || p_queue_name\n    )\n  using v_now\n  loop\n    perform absurd.cancel_task(p_queue_name, v_cancel_candidate.task_id);\n  end loop;\n\n  for v_expired_run in\n    execute format(\n      'select run_id,\n              claimed_by,\n              claim_expires_at,\n              attempt\n         from absurd.%I\n        where state = ''running''\n          and claim_expires_at is not null\n          and claim_expires_at <= $1\n        order by claim_expires_at, run_id\n        limit $2\n        for update skip locked',\n      'r_' || p_queue_name\n    )\n  using v_now, v_expired_sweep_limit\n  loop\n    perform absurd.fail_run(\n      p_queue_name,\n      v_expired_run.run_id,\n      jsonb_strip_nulls(jsonb_build_object(\n        'name', '$ClaimTimeout',\n        'message', 'worker did not finish task within claim interval',\n        'workerId', v_expired_run.claimed_by,\n        'claimExpiredAt', v_expired_run.claim_expires_at,\n        'attempt', v_expired_run.attempt\n      )),\n      null\n    );\n  end loop;\n\n  v_sql := format(\n    'with candidate as (\n        select r.run_id\n          from absurd.%1$I r\n          join absurd.%2$I t on t.task_id = r.task_id\n         where r.state in (''pending'', ''sleeping'')\n           and t.state in (''pending'', ''sleeping'', ''running'')\n           and r.available_at <= $1\n         order by r.available_at, r.run_id\n         limit $2\n         for update skip locked\n     ),\n     updated as (\n        update absurd.%1$I r\n           set state = ''running'',\n               claimed_by = $3,\n               claim_expires_at = $4,\n               started_at = $1,\n               available_at = $1\n         where run_id in (select run_id from candidate)\n         returning r.run_id, r.task_id, r.attempt\n     ),\n     task_upd as (\n        update absurd.%2$I t\n           set state = ''running'',\n               attempts = greatest(t.attempts, u.attempt),\n               first_started_at = coalesce(t.first_started_at, $1),\n               last_attempt_run = u.run_id\n          from updated u\n         where t.task_id = u.task_id\n         returning t.task_id\n     ),\n     wait_cleanup as (\n        delete from absurd.%3$I w\n         using updated u\n        where w.run_id = u.run_id\n          and w.timeout_at is not null\n          and w.timeout_at <= $1\n        returning w.run_id\n     )\n     select\n       u.run_id,\n       u.task_id,\n       u.attempt,\n       t.task_name,\n       t.params,\n       t.retry_strategy,\n       t.max_attempts,\n      t.headers,\n      r.wake_event,\n      r.event_payload\n     from updated u\n     join absurd.%1$I r on r.run_id = u.run_id\n     join absurd.%2$I t on t.task_id = u.task_id\n     order by r.available_at, u.run_id',\n    'r_' || p_queue_name,\n    't_' || p_queue_name,\n    'w_' || p_queue_name\n  );\n\n  return query execute v_sql using v_now, v_qty, v_worker_id, v_claim_until;\nend;\n$$;\n\ncreate or replace function absurd.extend_claim (\n  p_queue_name text,\n  p_run_id uuid,\n  p_extend_by integer\n)\n  returns void\n  language plpgsql\nas $$\ndeclare\n  v_now timestamptz := absurd.current_time();\n  v_task_state text;\n  v_run_state text;\n  v_claim_expires_at timestamptz;\nbegin\n  if p_extend_by is null or p_extend_by <= 0 then\n    raise exception 'extend_by must be > 0';\n  end if;\n\n  execute format(\n    'select r.state,\n            r.claim_expires_at,\n            t.state\n       from absurd.%I r\n       join absurd.%I t on t.task_id = r.task_id\n      where r.run_id = $1\n      for update',\n    'r_' || p_queue_name,\n    't_' || p_queue_name\n  )\n  into v_run_state, v_claim_expires_at, v_task_state\n  using p_run_id;\n\n  if v_run_state is null then\n    raise exception 'Run \"%\" not found in queue \"%\"', p_run_id, p_queue_name;\n  end if;\n\n  if v_task_state = 'cancelled' then\n    raise exception sqlstate 'AB001' using message = 'Task has been cancelled';\n  end if;\n\n  if v_run_state <> 'running' then\n    raise exception 'Run \"%\" is not currently running in queue \"%\"', p_run_id, p_queue_name;\n  end if;\n\n  if v_claim_expires_at is null then\n    raise exception 'Run \"%\" does not have an active claim in queue \"%\"', p_run_id, p_queue_name;\n  end if;\n\n  execute format(\n    'update absurd.%I\n        set claim_expires_at = $2 + make_interval(secs => $3)\n      where run_id = $1',\n    'r_' || p_queue_name\n  )\n  using p_run_id, v_now, p_extend_by;\nend;\n$$;\n\ncreate or replace function absurd.get_task_checkpoint_state (\n  p_queue_name text,\n  p_task_id uuid,\n  p_step_name text,\n  p_include_pending boolean default false\n)\n  returns table (\n    checkpoint_name text,\n    state jsonb,\n    status text,\n    owner_run_id uuid,\n    updated_at timestamptz\n  )\n  language plpgsql\nas $$\nbegin\n  return query execute format(\n    'select checkpoint_name, state, status, owner_run_id, updated_at\n       from absurd.%I\n      where task_id = $1\n        and checkpoint_name = $2\n        and ($3 or status = ''committed'')',\n    'c_' || p_queue_name\n  ) using p_task_id, p_step_name, coalesce(p_include_pending, false);\nend;\n$$;\n\ncreate or replace function absurd.get_task_checkpoint_states (\n  p_queue_name text,\n  p_task_id uuid,\n  p_run_id uuid\n)\n  returns table (\n    checkpoint_name text,\n    state jsonb,\n    status text,\n    owner_run_id uuid,\n    updated_at timestamptz\n  )\n  language plpgsql\nas $$\ndeclare\n  v_run_task_id uuid;\n  v_run_attempt integer;\nbegin\n  execute format(\n    'select task_id, attempt\n       from absurd.%I\n      where run_id = $1',\n    'r_' || p_queue_name\n  )\n  into v_run_task_id, v_run_attempt\n  using p_run_id;\n\n  if v_run_task_id is null then\n    raise exception 'Run \"%\" not found in queue \"%\"', p_run_id, p_queue_name;\n  end if;\n\n  if v_run_task_id <> p_task_id then\n    raise exception 'Run \"%\" does not belong to task \"%\" in queue \"%\"', p_run_id, p_task_id, p_queue_name;\n  end if;\n\n  return query execute format(\n    'select c.checkpoint_name,\n            c.state,\n            c.status,\n            c.owner_run_id,\n            c.updated_at\n       from absurd.%1$I c\n       left join absurd.%2$I owner_run on owner_run.run_id = c.owner_run_id\n      where c.task_id = $1\n        and c.status = ''committed''\n        and (owner_run.attempt is null or owner_run.attempt <= $2)\n      order by c.updated_at asc',\n    'c_' || p_queue_name,\n    'r_' || p_queue_name\n  ) using p_task_id, v_run_attempt;\nend;\n$$;\n\ncreate or replace function absurd.emit_event (\n  p_queue_name text,\n  p_event_name text,\n  p_payload jsonb default null\n)\n  returns void\n  language plpgsql\nas $$\ndeclare\n  v_now timestamptz := absurd.current_time();\n  v_payload jsonb := coalesce(p_payload, 'null'::jsonb);\n  v_emit_applied integer;\nbegin\n  if p_event_name is null or length(trim(p_event_name)) = 0 then\n    raise exception 'event_name must be provided';\n  end if;\n\n  -- Events are immutable once emitted: first write wins.\n  --\n  -- await_event() may pre-create a row with payload=NULL as a \"not emitted\"\n  -- sentinel. We allow exactly one transition NULL -> JSON payload.\n  execute format(\n    'insert into absurd.%1$I as e (event_name, payload, emitted_at)\n     values ($1, $2, $3)\n     on conflict (event_name)\n     do update set payload = excluded.payload,\n                   emitted_at = excluded.emitted_at\n      where e.payload is null',\n    'e_' || p_queue_name\n  ) using p_event_name, v_payload, v_now;\n\n  get diagnostics v_emit_applied = row_count;\n\n  -- Event was already emitted earlier; do not overwrite cached payload or\n  -- re-run wakeup side effects.\n  if v_emit_applied = 0 then\n    return;\n  end if;\n\n  execute format(\n    'with expired_waits as (\n        delete from absurd.%1$I w\n         where w.event_name = $1\n           and w.timeout_at is not null\n           and w.timeout_at <= $2\n         returning w.run_id\n     ),\n     affected as (\n        select run_id, task_id, step_name\n          from absurd.%1$I\n         where event_name = $1\n           and (timeout_at is null or timeout_at > $2)\n     ),\n     updated_runs as (\n        update absurd.%2$I r\n           set state = ''pending'',\n               available_at = $2,\n               wake_event = null,\n               event_payload = $3,\n               claimed_by = null,\n               claim_expires_at = null\n         where r.run_id in (select run_id from affected)\n           and r.state = ''sleeping''\n         returning r.run_id, r.task_id\n     ),\n     checkpoint_upd as (\n        insert into absurd.%3$I (task_id, checkpoint_name, state, status, owner_run_id, updated_at)\n        select a.task_id, a.step_name, $3, ''committed'', a.run_id, $2\n          from affected a\n          join updated_runs ur on ur.run_id = a.run_id\n        on conflict (task_id, checkpoint_name)\n        do update set state = excluded.state,\n                      status = excluded.status,\n                      owner_run_id = excluded.owner_run_id,\n                      updated_at = excluded.updated_at\n     ),\n     updated_tasks as (\n        update absurd.%4$I t\n           set state = ''pending''\n         where t.task_id in (select task_id from updated_runs)\n         returning task_id\n     )\n     delete from absurd.%5$I w\n      where w.event_name = $1\n        and w.run_id in (select run_id from updated_runs)',\n    'w_' || p_queue_name,\n    'r_' || p_queue_name,\n    'c_' || p_queue_name,\n    't_' || p_queue_name,\n    'w_' || p_queue_name\n  ) using p_event_name, v_now, v_payload;\nend;\n$$;\n\ncreate or replace function absurd.cancel_task (\n  p_queue_name text,\n  p_task_id uuid\n)\n  returns void\n  language plpgsql\nas $$\ndeclare\n  v_now timestamptz := absurd.current_time();\n  v_task_state text;\nbegin\n  -- Lock active runs before the task row so cancel_task() uses the same\n  -- lock acquisition order as complete_run()/fail_run().\n  execute format(\n    'select run_id\n       from absurd.%I\n      where task_id = $1\n        and state not in (''completed'', ''failed'', ''cancelled'')\n      order by run_id\n      for update',\n    'r_' || p_queue_name\n  ) using p_task_id;\n\n  execute format(\n    'select state\n       from absurd.%I\n      where task_id = $1\n      for update',\n    't_' || p_queue_name\n  )\n  into v_task_state\n  using p_task_id;\n\n  if v_task_state is null then\n    raise exception 'Task \"%\" not found in queue \"%\"', p_task_id, p_queue_name;\n  end if;\n\n  if v_task_state in ('completed', 'failed', 'cancelled') then\n    return;\n  end if;\n\n  execute format(\n    'update absurd.%I\n        set state = ''cancelled'',\n            cancelled_at = coalesce(cancelled_at, $2)\n      where task_id = $1',\n    't_' || p_queue_name\n  ) using p_task_id, v_now;\n\n  execute format(\n    'update absurd.%I\n        set state = ''cancelled'',\n            claimed_by = null,\n            claim_expires_at = null\n      where task_id = $1\n        and state not in (''completed'', ''failed'', ''cancelled'')',\n    'r_' || p_queue_name\n  ) using p_task_id;\n\n  execute format(\n    'delete from absurd.%I where task_id = $1',\n    'w_' || p_queue_name\n  ) using p_task_id;\nend;\n$$;\n", "0.1.0-0.1.1.sql": "-- Migration from 0.1.0 to main\n--\n-- This migration adds AB002 error handling for already-failed runs in\n-- set_task_checkpoint_state and extend_claim, so workers get a clear signal\n-- instead of a generic error when operating on a run that has already failed.\n\ncreate or replace function absurd.set_task_checkpoint_state (\n  p_queue_name text,\n  p_task_id uuid,\n  p_step_name text,\n  p_state jsonb,\n  p_owner_run uuid,\n  p_extend_claim_by integer default null\n)\n  returns void\n  language plpgsql\nas $$\ndeclare\n  v_now timestamptz := absurd.current_time();\n  v_new_attempt integer;\n  v_existing_attempt integer;\n  v_existing_owner uuid;\n  v_task_state text;\n  v_run_state text;\nbegin\n  if p_step_name is null or length(trim(p_step_name)) = 0 then\n    raise exception 'step_name must be provided';\n  end if;\n\n  execute format(\n    'select r.attempt, r.state, t.state\n       from absurd.%I r\n       join absurd.%I t on t.task_id = r.task_id\n      where r.run_id = $1',\n    'r_' || p_queue_name,\n    't_' || p_queue_name\n  )\n  into v_new_attempt, v_run_state, v_task_state\n  using p_owner_run;\n\n  if v_new_attempt is null then\n    raise exception 'Run \"%\" not found for checkpoint', p_owner_run;\n  end if;\n\n  if v_task_state = 'cancelled' then\n    raise exception sqlstate 'AB001' using message = 'Task has been cancelled';\n  end if;\n\n  if v_run_state = 'failed' then\n    raise exception sqlstate 'AB002' using message = format('Run \"%s\" has already failed in queue \"%s\"', p_owner_run, p_queue_name);\n  end if;\n\n  -- Extend the claim if requested\n  if p_extend_claim_by is not null and p_extend_claim_by > 0 then\n    execute format(\n      'update absurd.%I\n          set claim_expires_at = $2 + make_interval(secs => $3)\n        where run_id = $1\n          and state = ''running''\n          and claim_expires_at is not null',\n      'r_' || p_queue_name\n    )\n    using p_owner_run, v_now, p_extend_claim_by;\n  end if;\n\n  execute format(\n    'select c.owner_run_id,\n            r.attempt\n       from absurd.%I c\n       left join absurd.%I r on r.run_id = c.owner_run_id\n      where c.task_id = $1\n        and c.checkpoint_name = $2',\n    'c_' || p_queue_name,\n    'r_' || p_queue_name\n  )\n  into v_existing_owner, v_existing_attempt\n  using p_task_id, p_step_name;\n\n  if v_existing_owner is null or v_existing_attempt is null or v_new_attempt >= v_existing_attempt then\n    execute format(\n      'insert into absurd.%I (task_id, checkpoint_name, state, status, owner_run_id, updated_at)\n       values ($1, $2, $3, ''committed'', $4, $5)\n       on conflict (task_id, checkpoint_name)\n       do update set state = excluded.state,\n                     status = excluded.status,\n                     owner_run_id = excluded.owner_run_id,\n                     updated_at = excluded.updated_at',\n      'c_' || p_queue_name\n    ) using p_task_id, p_step_name, p_state, p_owner_run, v_now;\n  end if;\nend;\n$$;\n\ncreate or replace function absurd.extend_claim (\n  p_queue_name text,\n  p_run_id uuid,\n  p_extend_by integer\n)\n  returns void\n  language plpgsql\nas $$\ndeclare\n  v_now timestamptz := absurd.current_time();\n  v_task_state text;\n  v_run_state text;\n  v_claim_expires_at timestamptz;\nbegin\n  if p_extend_by is null or p_extend_by <= 0 then\n    raise exception 'extend_by must be > 0';\n  end if;\n\n  execute format(\n    'select r.state,\n            r.claim_expires_at,\n            t.state\n       from absurd.%I r\n       join absurd.%I t on t.task_id = r.task_id\n      where r.run_id = $1\n      for update',\n    'r_' || p_queue_name,\n    't_' || p_queue_name\n  )\n  into v_run_state, v_claim_expires_at, v_task_state\n  using p_run_id;\n\n  if v_run_state is null then\n    raise exception 'Run \"%\" not found in queue \"%\"', p_run_id, p_queue_name;\n  end if;\n\n  if v_task_state = 'cancelled' then\n    raise exception sqlstate 'AB001' using message = 'Task has been cancelled';\n  end if;\n\n  if v_run_state <> 'running' then\n    if v_run_state = 'failed' then\n      raise exception sqlstate 'AB002' using message = format('Run \"%s\" has already failed in queue \"%s\"', p_run_id, p_queue_name);\n    end if;\n    raise exception 'Run \"%\" is not currently running in queue \"%\"', p_run_id, p_queue_name;\n  end if;\n\n  if v_claim_expires_at is null then\n    raise exception 'Run \"%\" does not have an active claim in queue \"%\"', p_run_id, p_queue_name;\n  end if;\n\n  execute format(\n    'update absurd.%I\n        set claim_expires_at = $2 + make_interval(secs => $3)\n      where run_id = $1',\n    'r_' || p_queue_name\n  )\n  using p_run_id, v_now, p_extend_by;\nend;\n$$;\n", "0.1.1-0.2.0.sql": "-- Migration from 0.1.1 to main\n--\n-- Adds absurd.retry_task to allow retrying permanently failed tasks either\n-- in-place (with optional max-attempts adjustment) or by spawning a new task\n-- from the original inputs.\n\ncreate or replace function absurd.get_schema_version ()\n  returns text\n  language sql\nas $$\n  select '0.2.0'::text;\n$$;\n\ncreate or replace function absurd.retry_task (\n  p_queue_name text,\n  p_task_id uuid,\n  p_options jsonb default '{}'::jsonb\n)\n  returns table (\n    task_id uuid,\n    run_id uuid,\n    attempt integer,\n    created boolean\n  )\n  language plpgsql\nas $$\ndeclare\n  v_now timestamptz := absurd.current_time();\n  v_spawn_new boolean := false;\n  v_requested_max_attempts integer;\n\n  v_task_name text;\n  v_params jsonb;\n  v_headers jsonb;\n  v_retry_strategy jsonb;\n  v_task_max_attempts integer;\n  v_cancellation jsonb;\n  v_task_attempts integer;\n  v_task_state text;\n\n  v_new_run_id uuid;\n  v_new_attempt integer;\n  v_spawn_options jsonb;\nbegin\n  if p_options is not null then\n    if p_options ? 'spawn_new' then\n      v_spawn_new := coalesce((p_options->>'spawn_new')::boolean, false);\n    end if;\n    if p_options ? 'max_attempts' then\n      v_requested_max_attempts := (p_options->>'max_attempts')::int;\n      if v_requested_max_attempts is not null and v_requested_max_attempts < 1 then\n        raise exception 'max_attempts must be >= 1';\n      end if;\n    end if;\n  end if;\n\n  execute format(\n    'select task_name,\n            params,\n            headers,\n            retry_strategy,\n            max_attempts,\n            cancellation,\n            attempts,\n            state\n       from absurd.%I\n      where task_id = $1\n      for update',\n    't_' || p_queue_name\n  )\n  into v_task_name,\n       v_params,\n       v_headers,\n       v_retry_strategy,\n       v_task_max_attempts,\n       v_cancellation,\n       v_task_attempts,\n       v_task_state\n  using p_task_id;\n\n  if v_task_state is null then\n    raise exception 'Task \"%\" not found in queue \"%\"', p_task_id, p_queue_name;\n  end if;\n\n  if v_task_state <> 'failed' then\n    raise exception 'Task \"%\" is not currently failed in queue \"%\"', p_task_id, p_queue_name;\n  end if;\n\n  if v_spawn_new then\n    v_spawn_options := jsonb_strip_nulls(jsonb_build_object(\n      'headers', v_headers,\n      'retry_strategy', v_retry_strategy,\n      'max_attempts', coalesce(v_requested_max_attempts, v_task_max_attempts),\n      'cancellation', v_cancellation\n    ));\n\n    return query\n      select s.task_id, s.run_id, s.attempt, s.created\n        from absurd.spawn_task(p_queue_name, v_task_name, v_params, v_spawn_options) s;\n    return;\n  end if;\n\n  if v_requested_max_attempts is null then\n    v_requested_max_attempts := coalesce(v_task_max_attempts, v_task_attempts) + 1;\n  end if;\n\n  if v_requested_max_attempts <= v_task_attempts then\n    raise exception 'max_attempts (%) must be greater than current attempts (%)',\n      v_requested_max_attempts,\n      v_task_attempts;\n  end if;\n\n  v_new_run_id := absurd.portable_uuidv7();\n  v_new_attempt := v_task_attempts + 1;\n\n  execute format(\n    'insert into absurd.%I (run_id, task_id, attempt, state, available_at, wake_event, event_payload, result, failure_reason)\n     values ($1, $2, $3, ''pending'', $4, null, null, null, null)',\n    'r_' || p_queue_name\n  )\n  using v_new_run_id, p_task_id, v_new_attempt, v_now;\n\n  execute format(\n    'update absurd.%I\n        set state = ''pending'',\n            attempts = greatest(attempts, $2),\n            max_attempts = $3,\n            last_attempt_run = $4,\n            cancelled_at = null\n      where task_id = $1',\n    't_' || p_queue_name\n  )\n  using p_task_id, v_new_attempt, v_requested_max_attempts, v_new_run_id;\n\n  return query select p_task_id, v_new_run_id, v_new_attempt, false;\nend;\n$$;\n", "0.2.0-0.3.0.sql": "-- Migration from 0.2.0 to main\n--\n-- Changes:\n-- - bump absurd.get_schema_version() to \"main\"\n-- - add absurd.get_task_result(queue, task_id)\n-- - update absurd.fail_run() dynamic SQL parameterization\n\ncreate or replace function absurd.get_schema_version ()\n  returns text\n  language sql\nas $$\n  select '0.3.0'::text;\n$$;\n\n-- Returns the current state and terminal payload (if any) for a task.\n--\n-- Non-terminal states (pending/running/sleeping) return result/failure_reason\n-- as NULL. Completed tasks expose completed_payload as result. Failed tasks\n-- expose the last run failure_reason.\ncreate or replace function absurd.get_task_result (\n  p_queue_name text,\n  p_task_id uuid\n)\n  returns table (\n    task_id uuid,\n    state text,\n    result jsonb,\n    failure_reason jsonb\n  )\n  language plpgsql\nas $$\nbegin\n  p_queue_name := absurd.validate_queue_name(p_queue_name);\n\n  return query execute format(\n    'select t.task_id,\n            t.state,\n            case when t.state = ''completed'' then t.completed_payload else null end as result,\n            case when t.state = ''failed'' then r.failure_reason else null end as failure_reason\n       from absurd.%I t\n       left join absurd.%I r on r.run_id = t.last_attempt_run\n      where t.task_id = $1',\n    't_' || p_queue_name,\n    'r_' || p_queue_name\n  ) using p_task_id;\nend;\n$$;\n\ncreate or replace function absurd.fail_run (\n  p_queue_name text,\n  p_run_id uuid,\n  p_reason jsonb,\n  p_retry_at timestamptz default null\n)\n  returns void\n  language plpgsql\nas $$\ndeclare\n  v_task_id uuid;\n  v_attempt integer;\n  v_retry_strategy jsonb;\n  v_max_attempts integer;\n  v_now timestamptz := absurd.current_time();\n  v_next_attempt integer;\n  v_delay_seconds double precision := 0;\n  v_next_available timestamptz;\n  v_retry_kind text;\n  v_base double precision;\n  v_factor double precision;\n  v_max_seconds double precision;\n  v_first_started timestamptz;\n  v_cancellation jsonb;\n  v_max_duration bigint;\n  v_task_cancel boolean := false;\n  v_new_run_id uuid;\n  v_task_state_after text;\n  v_recorded_attempt integer;\n  v_last_attempt_run uuid := p_run_id;\n  v_cancelled_at timestamptz := null;\nbegin\n  execute format(\n    'select r.task_id, r.attempt\n       from absurd.%I r\n      where r.run_id = $1\n        and r.state in (''running'', ''sleeping'')\n      for update',\n    'r_' || p_queue_name\n  )\n  into v_task_id, v_attempt\n  using p_run_id;\n\n  if v_task_id is null then\n    raise exception 'Run \"%\" cannot be failed in queue \"%\"', p_run_id, p_queue_name;\n  end if;\n\n  execute format(\n    'select retry_strategy, max_attempts, first_started_at, cancellation\n       from absurd.%I\n      where task_id = $1\n      for update',\n    't_' || p_queue_name\n  )\n  into v_retry_strategy, v_max_attempts, v_first_started, v_cancellation\n  using v_task_id;\n\n  execute format(\n    'update absurd.%I\n        set state = ''failed'',\n            wake_event = null,\n            failed_at = $2,\n            failure_reason = $3\n      where run_id = $1',\n    'r_' || p_queue_name\n  ) using p_run_id, v_now, p_reason;\n\n  v_next_attempt := v_attempt + 1;\n  v_task_state_after := 'failed';\n  v_recorded_attempt := v_attempt;\n\n  if v_max_attempts is null or v_next_attempt <= v_max_attempts then\n    if p_retry_at is not null then\n      v_next_available := p_retry_at;\n    else\n      v_retry_kind := coalesce(v_retry_strategy->>'kind', 'none');\n      if v_retry_kind = 'fixed' then\n        v_base := coalesce((v_retry_strategy->>'base_seconds')::double precision, 60);\n        v_delay_seconds := v_base;\n      elsif v_retry_kind = 'exponential' then\n        v_base := coalesce((v_retry_strategy->>'base_seconds')::double precision, 30);\n        v_factor := coalesce((v_retry_strategy->>'factor')::double precision, 2);\n        v_delay_seconds := v_base * power(v_factor, greatest(v_attempt - 1, 0));\n        v_max_seconds := (v_retry_strategy->>'max_seconds')::double precision;\n        if v_max_seconds is not null then\n          v_delay_seconds := least(v_delay_seconds, v_max_seconds);\n        end if;\n      else\n        v_delay_seconds := 0;\n      end if;\n      v_next_available := v_now + (v_delay_seconds * interval '1 second');\n    end if;\n\n    if v_next_available < v_now then\n      v_next_available := v_now;\n    end if;\n\n    if v_cancellation is not null then\n      v_max_duration := (v_cancellation->>'max_duration')::bigint;\n      if v_max_duration is not null and v_first_started is not null then\n        if extract(epoch from (v_next_available - v_first_started)) >= v_max_duration then\n          v_task_cancel := true;\n        end if;\n      end if;\n    end if;\n\n    if not v_task_cancel then\n      v_task_state_after := case when v_next_available > v_now then 'sleeping' else 'pending' end;\n      v_new_run_id := absurd.portable_uuidv7();\n      v_recorded_attempt := v_next_attempt;\n      v_last_attempt_run := v_new_run_id;\n      execute format(\n        'insert into absurd.%I (run_id, task_id, attempt, state, available_at, wake_event, event_payload, result, failure_reason)\n         values ($1, $2, $3, $4, $5, null, null, null, null)',\n        'r_' || p_queue_name\n      )\n      using v_new_run_id, v_task_id, v_next_attempt, v_task_state_after, v_next_available;\n    end if;\n  end if;\n\n  if v_task_cancel then\n    v_task_state_after := 'cancelled';\n    v_cancelled_at := v_now;\n    v_recorded_attempt := greatest(v_recorded_attempt, v_attempt);\n    v_last_attempt_run := p_run_id;\n  end if;\n\n  execute format(\n    'update absurd.%I\n        set state = $2,\n            attempts = greatest(attempts, $3),\n            last_attempt_run = $4,\n            cancelled_at = coalesce(cancelled_at, $5)\n      where task_id = $1',\n    't_' || p_queue_name\n  ) using v_task_id, v_task_state_after, v_recorded_attempt, v_last_attempt_run, v_cancelled_at;\n\n  execute format(\n    'delete from absurd.%I where run_id = $1',\n    'w_' || p_queue_name\n  ) using p_run_id;\nend;\n$$;\n"}
BUNDLED_SKILLS = {"skills/absurd/SKILL.md": "---\nname: absurd\ndescription: Debug and operate Absurd durable workflows with absurdctl. Use when working with Absurd queues, tasks, runs, events, retries, schema setup, or when a user wants to inspect, spawn, retry, cancel, or wake workflows.\nlicense: Apache-2.0\n---\n\n# Absurd\n\nUse this skill when the project uses **Absurd**, the Postgres-native durable workflow engine, or when the user mentions **`absurdctl`**, queues, durable tasks, runs, retries, sleeping tasks, or events.\n\n## Tiny mental model\n\n- A **queue** is a namespace of Absurd tables (`t_`, `r_`, `c_`, `e_`, `w_`).\n- A **task** is the durable workflow instance.\n- A **run** is one execution attempt of a task.\n- A **step** is a checkpoint. Completed step results are stored as JSON.\n- **Sleeping** tasks are usually waiting for time or an event.\n- **Events** wake waiting tasks. Event payloads are cached; first emit wins.\n\nImportant distinction:\n\n- `task_id` = the whole workflow across all attempts\n- `run_id` = one specific execution attempt\n\n## First: make sure `absurdctl` works\n\nIf `absurdctl` is not on `PATH`, check whether you are inside a repo checkout and, if so, use:\n\n```bash\nexport PATH=\"$PWD:$PATH\"\n```\n\nAbsurd connection precedence is:\n\n```text\n--database > ABSURD_DATABASE_URL > PGDATABASE > postgresql://localhost/absurd\n```\n\nFor non-URI connections, `PGHOST`, `PGPORT`, `PGUSER`, and `PGPASSWORD` are also honored.\n\n## Default debugging workflow\n\nPrefer **`absurdctl` state inspection before source inspection**. Usually you do **not** need to read application code first.\n\nIf the user explicitly asks you to use **`absurdctl`** to inspect or fix a workflow, do that first instead of starting with `rg` / source browsing.\n\nWhen the user wants to debug a task, start with these commands in order:\n\n### 1) Discover queues\n\n```bash\nabsurdctl list-queues\n```\n\n### 2) Inspect recent activity in the likely queue\n\n```bash\nabsurdctl list-tasks --queue=default --limit=20\n```\n\nNotes:\n- `list-tasks` defaults to 50 rows if `--limit` is omitted.\n- Useful statuses: `pending`, `running`, `sleeping`, `completed`, `failed`, `cancelled`.\n\n### 3) Focus on failures or sleepers\n\n```bash\nabsurdctl list-tasks --queue=default --status=failed --limit=20\nabsurdctl list-tasks --queue=default --status=sleeping --limit=20\n```\n\n### 4) Inspect one workflow or one attempt in detail\n\n```bash\nabsurdctl dump-task --task-id=<task-id>\nabsurdctl dump-task --run-id=<run-id>\n```\n\n`dump-task` is the most important inspection command. It shows things like:\n- task name, params, and headers\n- retry settings and attempts\n- checkpointed step state\n- waits / events / sleep state\n- final result or failure\n\n## How to reason about common states\n\n### If a task is `failed`\n\n1. `dump-task --task-id=<task-id>`\n2. Read the failure and the last successful checkpoints.\n3. If needed, inspect the most recent attempt with `--run-id`.\n4. Search the code for the task implementation by task name.\n5. Only then decide whether to retry.\n\n### If a task is `sleeping`\n\n1. `dump-task --task-id=<task-id>`\n2. Look for the wait reason:\n   - sleeping until a timestamp\n   - waiting for an event name\n3. If it is waiting for an event and the user wants it resumed, emit that event.\n\n### If a task is `running`\n\n1. `dump-task --task-id=<task-id>`\n2. Look at existing checkpoints to see how far it got.\n3. If the user suspects a stuck worker, inspect the worker process / application logs too.\n\n### About workers\n\nDo not assume you need to start or modify a worker.\n\n- If a spawned task moves from `pending` to `sleeping`, `running`, or `completed`, a worker is already active.\n- If tasks remain `pending`, then investigate whether a worker for that queue is actually running.\n- Only inspect Python / TypeScript runtime details when the task state suggests a worker problem or the user asks for code changes.\n\n### If you need the implementation\n\nAfter you know the task name, search the codebase for its registration.\n\nTypeScript / JavaScript:\n\n```bash\nrg -n \"registerTask\\(|name:\\s*['\\\"]<task-name>['\\\"]\" .\n```\n\nPython:\n\n```bash\nrg -n \"register_task\\(|@.*register_task|['\\\"]<task-name>['\\\"]\" .\n```\n\nIf the task is waiting for an event, also search for the event name.\n\n## Common actions\n\n### Spawn work\n\nUse `-P key=value` for strings and `-P key:=json` for typed JSON values.\n\n```bash\nabsurdctl spawn-task my-task -q default -P foo=bar\nabsurdctl spawn-task my-task -q default -P count:=42 -P enabled:=true\nabsurdctl spawn-task my-task -q default -P user.name=Alice -P user.age:=30\n```\n\nUse `--params` when the user already has a JSON object:\n\n```bash\nabsurdctl spawn-task my-task -q default --params '{\"foo\":\"bar\",\"count\":42}'\n```\n\n### Retry failed work\n\n```bash\nabsurdctl retry-task <task-id>\nabsurdctl retry-task <task-id> --max-attempts 5\nabsurdctl retry-task -q default <task-id> --spawn-new\n```\n\nGuidance:\n- plain `retry-task` retries the existing task\n- `--spawn-new` creates a brand-new task with the original inputs\n- prefer understanding the failure before retrying\n\n### Cancel work\n\n```bash\nabsurdctl cancel-task <task-id>\nabsurdctl cancel-task -q default <task-id>\n```\n\n### Wake waiting tasks by emitting an event\n\n```bash\nabsurdctl emit-event order.completed -q default -P orderId=123\nabsurdctl emit-event approval.granted:42 -q default -P approved:=true\n```\n\nIf the event payload should be structured JSON:\n\n```bash\nabsurdctl emit-event shipment.packed:42 -q default --payload '{\"trackingNumber\":\"XYZ\"}'\n```\n\n### Schema setup / migrations\n\nUse these on a blank or controlled database, or when the user explicitly asks:\n\n```bash\nabsurdctl init\nabsurdctl schema-version\nabsurdctl migrate\nabsurdctl create-queue default\n```\n\n## Safe operating rules\n\nBe careful with state-changing commands. Unless the user clearly wants them, avoid running these blindly on a shared or production database:\n\n- `init`\n- `migrate`\n- `create-queue`\n- `drop-queue`\n- `cleanup`\n- `cancel-task`\n- `retry-task`\n- `emit-event`\n- `spawn-task`\n\nIf the environment is ambiguous, ask which database / queue is safe to operate on.\n\n## Good copy-paste playbooks\n\n### Debug the latest failures in `default`\n\n```bash\nabsurdctl list-queues\nabsurdctl list-tasks --queue=default --status=failed --limit=20\nabsurdctl dump-task --task-id=<task-id>\n```\n\n### Find sleepers and wake one\n\n```bash\nabsurdctl list-tasks --queue=default --status=sleeping --limit=20\nabsurdctl dump-task --task-id=<task-id>\nabsurdctl emit-event <event-name> -q default -P key=value\n```\n\n### Reproduce by spawning a task, then inspect it\n\n```bash\nabsurdctl spawn-task my-task -q default -P foo=bar\nabsurdctl list-tasks --queue=default --task-name=my-task --limit=5\nabsurdctl dump-task --task-id=<task-id>\n```\n\nFast path when the user says \u201cspawn a task and debug it\u201d:\n\n```bash\nabsurdctl spawn-task my-task -q default -P foo=bar\nabsurdctl list-tasks --queue=default --task-name=my-task --limit=5\nabsurdctl dump-task --task-id=<task-id>\n# then either:\nabsurdctl emit-event <event-name> -q default -P key=value\n# or:\nabsurdctl retry-task <task-id>\n```\n\n## Extra reference\n\n- Use `absurdctl <command> --help` for full options.\n- `dump-task --task-id` is usually the best starting point once you know the task.\n- Checkpointed step results are durable JSON state; code outside steps may execute multiple times across retries.\n"}
# END BUNDLED DATA

MIGRATION_FILENAME_RE = re.compile(
    r"^(?P<from>[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.]+)?)-(?P<to>main|[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.]+)?)\.sql$"
)
VERSION_RE = re.compile(
    r"^(?P<major>[0-9]+)\.(?P<minor>[0-9]+)\.(?P<patch>[0-9]+)(?:-(?P<suffix>[A-Za-z0-9.]+))?$"
)
SCHEMA_VERSION_FUNCTION_RE = re.compile(
    r"create\s+or\s+replace\s+function\s+absurd\.get_schema_version\s*\(",
    re.IGNORECASE,
)


def quote_ident(identifier):
    """Quote a SQL identifier using PostgreSQL double-quote escaping."""
    return '"' + identifier.replace('"', '""') + '"'


def queue_relation(prefix, queue_name):
    """Return a fully qualified absurd.<prefix>_<queue> relation reference."""
    return f"absurd.{quote_ident(f'{prefix}_{queue_name}')}"


def validate_queue_name(value):
    """Validate queue names used in dynamic table/index names."""
    if value is None or len(value.strip()) == 0:
        print("Error: Queue name must be provided.", file=sys.stderr)
        sys.exit(1)

    if len(value.encode("utf-8")) > MAX_QUEUE_NAME_LENGTH:
        print(
            f"Error: Queue name '{value}' is too long (max {MAX_QUEUE_NAME_LENGTH} bytes).",
            file=sys.stderr,
        )
        sys.exit(1)

    return value


class RemoteMigrationDiscoveryError(Exception):
    pass


def get_term_width():
    """Get terminal width for formatting."""
    try:
        return shutil.get_terminal_size().columns
    except Exception:
        return 80


class CleanHelpFormatter(IndentedHelpFormatter):
    """Custom formatter with better spacing and formatting."""

    def __init__(self):
        IndentedHelpFormatter.__init__(
            self,
            indent_increment=2,
            max_help_position=30,
            width=get_term_width(),
            short_first=1,
        )

    def format_option(self, option):
        result = []
        opts = self.option_strings[option]
        opt_width = self.help_position - self.current_indent - 2

        if len(opts) > opt_width:
            opts = "%*s%s\n" % (self.current_indent, "", opts)
            indent_first = self.help_position
        else:
            opts = "%*s%-*s  " % (self.current_indent, "", opt_width, opts)
            indent_first = 0

        result.append(opts)

        if option.help:
            help_text = self.expand_default(option)
            help_lines = []

            help_lines = textwrap.wrap(help_text, self.width - self.help_position)

            result.append("%*s%s\n" % (indent_first, "", help_lines[0]))
            for line in help_lines[1:]:
                result.append("%*s%s\n" % (self.help_position, "", line))
        elif opts[-1] != "\n":
            result.append("\n")

        return "".join(result)


def build_database_config(host, port, user, database):
    """Build a dict with database configuration."""
    return {
        "host": host,
        "port": port,
        "user": user,
        "database": database,
    }


def resolve_database_argument(explicit_database=None):
    """Resolve database/URI using explicit args first, then env vars, then fallback."""
    return (
        explicit_database
        or os.getenv("ABSURD_DATABASE_URL")
        or os.getenv("PGDATABASE")
        or "postgresql://localhost/absurd"
    )


def config_from_options(options):
    """Shortcut to construct a database config from parsed options."""
    database = resolve_database_argument(options.database)
    parsed = urlparse(database)
    if parsed.scheme and "postgres" in parsed.scheme:
        return {"uri": database}

    return build_database_config(
        options.host or os.getenv("PGHOST", "localhost"),
        options.port or os.getenv("PGPORT", "5432"),
        options.user or os.getenv("PGUSER", os.getenv("USER", "")),
        database,
    )


def build_psql_command(config, extra_args=None):
    """Construct the base psql command with optional additional arguments."""
    cmd = ["psql", "-X"]

    if "uri" in config:
        cmd.extend(["-d", config["uri"]])
    else:
        cmd.extend(
            [
                "-h",
                config["host"],
                "-p",
                config["port"],
                "-U",
                config["user"],
                "-d",
                config["database"],
            ]
        )

    if extra_args:
        cmd.extend(extra_args)

    return cmd


def sanitize_uri(uri):
    """Remove password from URI for safe display in verbose output."""
    parsed = urlparse(uri)
    if parsed.password:
        return uri.replace(f"{parsed.password}@", f"****@", 1)
    return uri


def get_common_db_details(config):
    """Return standard database connection details for verbose output."""
    if "uri" in config:
        sanitized_uri = sanitize_uri(config["uri"])
        return [("URI", sanitized_uri)]
    return [
        ("Host", config["host"]),
        ("Port", config["port"]),
        ("User", config["user"]),
        ("Database", config["database"]),
    ]


def print_verbose_configuration(enabled, details):
    """Print a formatted configuration block when verbose output is enabled."""
    if not enabled:
        return
    print("Configuration:")
    for label, value in details:
        print(f"  {label}: {value}")


def run_psql(
    config,
    query=None,
    *,
    tuples_only=False,
    no_align=False,
    extra_args=None,
    input_data=None,
    error_message=None,
    strip_output=True,
    variables=None,
    fail_on_error=True,
):
    """
    Execute a PostgreSQL command. Returns stripped stdout for queries and the
    CompletedProcess for other invocations.

    Use `variables` dict to pass values safely. In the query, reference them as :'name'
    for string values. Example:
        run_psql(config, "SELECT * FROM t WHERE id = :'id'", variables={"id": user_input})

    Note: psql does not expand :variables for SQL passed via -c, so SQL text is
    sent through stdin with -f - whenever query/input_data is provided.
    """
    cmd_args = []
    stdin_data = input_data

    if tuples_only:
        cmd_args.append("-t")
    if no_align:
        cmd_args.append("-A")
    if fail_on_error:
        cmd_args.extend(["-v", "ON_ERROR_STOP=1"])
    if variables:
        for name, value in variables.items():
            cmd_args.extend(["-v", f"{name}={value}"])
    if extra_args:
        cmd_args.extend(extra_args)
    if query is not None:
        if input_data is not None:
            raise ValueError("run_psql received both query and input_data")
        stdin_data = query

    if stdin_data is not None:
        cmd_args.extend(["-f", "-"])

    message = error_message
    if message is None:
        message = (
            "Error executing query"
            if query is not None
            else "Error executing psql command"
        )

    env = os.environ.copy()
    env["PGPASSWORD"] = os.getenv("PGPASSWORD", "")
    try:
        result = subprocess.run(
            build_psql_command(config, cmd_args),
            input=stdin_data,
            capture_output=True,
            text=True,
            check=True,
            env=env,
        )
    except subprocess.CalledProcessError as e:
        if fail_on_error:
            print(f"{message}: {e.stderr}", file=sys.stderr)
            sys.exit(1)
        raise

    if query is not None:
        return result.stdout.strip() if strip_output else result.stdout
    return result


def run_psql_csv(config, query, variables=None):
    """Execute a query and return rows parsed via CSV output."""
    output = run_psql(
        config,
        query,
        tuples_only=True,
        extra_args=["--csv"],
        strip_output=False,
        variables=variables,
    )
    if not output:
        return []

    reader = csv.reader(io.StringIO(output))
    return [row for row in reader if row]


def queue_exists(config, queue_name):
    """Check if a queue exists."""
    result = run_psql(
        config,
        "SELECT COUNT(*) FROM absurd.queues WHERE queue_name = :'queue_name';",
        tuples_only=True,
        no_align=True,
        variables={"queue_name": queue_name},
    )
    return int(result) > 0


def build_json_params(param_list, base_params=None):
    """
    Build JSON parameters from -p flags and optional base --params.

    Supports:
      -p foo=bar           -> {"foo": "bar"}
      -p count:=42         -> {"count": 42}
      -p enabled:=true     -> {"enabled": true}
      -p tags:='["a","b"]' -> {"tags": ["a","b"]}
      -p user.name=Alice   -> {"user": {"name": "Alice"}}
      --params '{"x":1}'   -> merged with -p flags
    """
    result = {}

    if base_params:
        try:
            result = json.loads(base_params)
            if not isinstance(result, dict):
                raise ValueError("--params must be a JSON object")
        except json.JSONDecodeError as e:
            print(f"Error parsing --params: {e}", file=sys.stderr)
            sys.exit(1)

    for param in param_list:
        if ":=" in param:
            key, value_str = param.split(":=", 1)
            try:
                value = json.loads(value_str)
            except json.JSONDecodeError as e:
                print(f"Error parsing JSON value in '{param}': {e}", file=sys.stderr)
                sys.exit(1)
        elif "=" in param:
            key, value = param.split("=", 1)
        else:
            print(
                f"Invalid parameter format: '{param}' (expected key=value or key:=json)",
                file=sys.stderr,
            )
            sys.exit(1)

        set_nested_value(result, key, value)

    return json.dumps(result)


def set_nested_value(obj, path, value):
    """Set a value in a nested dictionary using dot notation."""
    keys = path.split(".")
    current = obj

    for key in keys[:-1]:
        if key not in current:
            current[key] = {}
        elif not isinstance(current[key], dict):
            print(
                f"Error: Cannot set nested value, '{key}' is not an object",
                file=sys.stderr,
            )
            sys.exit(1)
        current = current[key]

    current[keys[-1]] = value


def add_db_options(parser):
    """Add common database connection options to a parser."""
    parser.add_option("-h", "--host", dest="host", metavar="HOST", help="Database host")
    parser.add_option("-p", "--port", dest="port", metavar="PORT", help="Database port")
    parser.add_option("-U", "--user", dest="user", metavar="USER", help="Database user")
    parser.add_option(
        "-d",
        "--database",
        dest="database",
        metavar="DB",
        help="Database name or PostgreSQL connection URI",
    )
    parser.add_option(
        "-v",
        "--verbose",
        dest="verbose",
        action="store_true",
        default=False,
        help="Verbose output",
    )


def build_command_parser(usage):
    """Create an OptionParser with common Absurd defaults."""
    parser = OptionParser(
        usage=usage,
        add_help_option=False,
        formatter=CleanHelpFormatter(),
    )
    add_db_options(parser)
    parser.add_option("--help", action="store_true", help="Show this help message")
    return parser


def parse_args_with_help(parser, args, examples=None):
    """Parse args and handle --help in a consistent way."""
    options, remaining = parser.parse_args(args)
    if getattr(options, "help", False):
        parser.print_help()
        if examples:
            print("\nExamples:")
            for example in examples:
                print(f"  {example}")
        sys.exit(0)
    return options, remaining


def ensure_queue_exists(config, queue_name):
    """Exit with error if queue is missing."""
    if not queue_exists(config, queue_name):
        print(f"Queue '{queue_name}' does not exist", file=sys.stderr)
        sys.exit(1)


def github_absurd_sql_url(ref):
    return f"{GITHUB_RAW_BASE}/{quote(ref)}/sql/absurd.sql"


def github_migration_index_url(ref):
    return f"{GITHUB_API_BASE}/contents/sql/migrations?ref={quote(ref)}"


def github_migration_sql_url(ref, migration_name):
    return f"{GITHUB_RAW_BASE}/{quote(ref)}/sql/migrations/{quote(migration_name)}"


def github_repo_file_url(ref, relative_path):
    return f"{GITHUB_RAW_BASE}/{quote(ref)}/{quote(relative_path, safe='/')}"


def fetch_text(url):
    with urlopen(url) as response:
        return response.read().decode("utf-8")


def parse_migration_filename(filename):
    match = MIGRATION_FILENAME_RE.match(filename)
    if not match:
        return None
    return match.group("from"), match.group("to")


def version_sort_key(version):
    if version == "main":
        return (10**9, 10**9, 10**9, 1, "")

    match = VERSION_RE.match(version)
    if match is None:
        # Keep unknown version strings stable and after regular semver tags.
        return (10**8, 0, 0, 0, version)

    suffix = match.group("suffix") or ""
    # Stable release sorts after pre-release of same x.y.z.
    stable_flag = 1 if suffix == "" else 0
    return (
        int(match.group("major")),
        int(match.group("minor")),
        int(match.group("patch")),
        stable_flag,
        suffix,
    )


def compare_versions(a, b):
    ka = version_sort_key(a)
    kb = version_sort_key(b)
    if ka < kb:
        return -1
    if ka > kb:
        return 1
    return 0


def migration_declares_schema_version(sql_content):
    return SCHEMA_VERSION_FUNCTION_RE.search(sql_content) is not None


def get_recorded_schema_version(config):
    exists = run_psql(
        config,
        "SELECT to_regprocedure('absurd.get_schema_version()') IS NOT NULL;",
        tuples_only=True,
        no_align=True,
        error_message="Error checking schema version metadata",
    )
    if exists.lower() not in ("t", "true", "1"):
        return None

    version = run_psql(
        config,
        "SELECT absurd.get_schema_version();",
        tuples_only=True,
        no_align=True,
        error_message="Error loading schema version",
    )
    return version or None


def load_absurd_sql(script_dir, target_ref, verbose):
    local_sql_path = os.path.join(script_dir, "sql", "absurd.sql")

    if os.path.exists(local_sql_path) and target_ref == ABSURD_SCHEMA_TARGET_VERSION:
        if verbose:
            print(f"Using local SQL file: {local_sql_path}")
        with open(local_sql_path, "r", encoding="utf-8") as f:
            return f.read(), f"local:{local_sql_path}"

    if BUNDLED_SCHEMA_SQL and target_ref == ABSURD_SCHEMA_TARGET_VERSION:
        if verbose:
            print("Using bundled absurd.sql")
        return BUNDLED_SCHEMA_SQL, "bundled"

    sql_url = github_absurd_sql_url(target_ref)
    if verbose:
        print(f"Fetching SQL file from: {sql_url}")
    try:
        return fetch_text(sql_url), f"github:{sql_url}"
    except URLError as e:
        print(f"Error downloading SQL file from GitHub: {e}", file=sys.stderr)
        sys.exit(1)


def discover_local_migrations(script_dir):
    migrations = {}
    migrations_dir = os.path.join(script_dir, "sql", "migrations")
    if not os.path.isdir(migrations_dir):
        return migrations

    for filename in sorted(os.listdir(migrations_dir)):
        parsed = parse_migration_filename(filename)
        if parsed is None:
            continue
        source, target = parsed
        path = os.path.join(migrations_dir, filename)
        with open(path, "r", encoding="utf-8") as f:
            migrations[(source, target)] = {
                "name": filename,
                "source": "local",
                "sql": f.read(),
            }
    return migrations


def discover_bundled_migrations():
    migrations = {}
    for filename, sql_content in BUNDLED_MIGRATIONS.items():
        parsed = parse_migration_filename(filename)
        if parsed is None:
            continue
        source, target = parsed
        migrations[(source, target)] = {
            "name": filename,
            "source": "bundled",
            "sql": sql_content,
        }
    return migrations


def discover_remote_migrations(target_ref):
    migrations = {}
    index_url = github_migration_index_url(target_ref)

    try:
        payload = fetch_text(index_url)
    except URLError as e:
        raise RemoteMigrationDiscoveryError(
            f"failed to download migration index from {index_url}: {e}"
        ) from e

    try:
        entries = json.loads(payload)
    except json.JSONDecodeError as e:
        raise RemoteMigrationDiscoveryError(
            f"failed to parse migration index from {index_url}: {e}"
        ) from e

    if not isinstance(entries, list):
        raise RemoteMigrationDiscoveryError(
            f"unexpected migration index payload from {index_url}: expected JSON list"
        )

    for entry in entries:
        name = entry.get("name", "")
        parsed = parse_migration_filename(name)
        if parsed is None:
            continue
        source, target = parsed
        migrations[(source, target)] = {
            "name": name,
            "source": "github",
            "url": github_migration_sql_url(target_ref, name),
        }
    return migrations


def discover_migrations(script_dir, target_ref, include_remote=False):
    # Keep source selection simple: use local/bundled assets first and only
    # switch to remote discovery when explicitly requested.
    if include_remote:
        return discover_remote_migrations(target_ref)

    local = discover_local_migrations(script_dir)
    if local:
        return local

    bundled = discover_bundled_migrations()
    if bundled:
        return bundled

    return {}


def resolve_migration_path(current_version, target_version, migrations):
    direction = compare_versions(current_version, target_version)
    if direction == 0:
        return []
    if direction > 0:
        # Downgrades are not supported.
        return None

    # Migrations are applied in release order by destination version.
    # Missing release-to-release files are treated as null migrations.
    grouped_by_target = {}
    for (src, dst), migration in migrations.items():
        if compare_versions(dst, current_version) <= 0:
            continue
        if compare_versions(dst, target_version) > 0:
            continue
        grouped_by_target.setdefault(dst, []).append((src, migration))

    ordered_targets = sorted(grouped_by_target.keys(), key=version_sort_key)

    cursor = current_version
    path = []
    for dst in ordered_targets:
        candidates = grouped_by_target[dst]

        exact = [item for item in candidates if compare_versions(item[0], cursor) == 0]
        if exact:
            chosen = exact[0][1]
        else:
            le_cursor = [item for item in candidates if compare_versions(item[0], cursor) < 0]
            if le_cursor:
                # Prefer the newest compatible source version.
                chosen = max(le_cursor, key=lambda item: version_sort_key(item[0]))[1]
            else:
                # No direct predecessor migration is known; assume null migrations
                # from cursor up to the first available source.
                chosen = min(candidates, key=lambda item: version_sort_key(item[0]))[1]

        path.append(chosen)
        cursor = dst

    return path


def get_migration_sql(migration):
    if "sql" in migration:
        return migration["sql"]
    try:
        return fetch_text(migration["url"])
    except URLError as e:
        print(
            f"Error downloading migration {migration['name']} from GitHub: {e}",
            file=sys.stderr,
        )
        sys.exit(1)


def load_skill_file(script_dir, relative_path, verbose):
    local_path = os.path.join(script_dir, *relative_path.split("/"))

    if os.path.exists(local_path):
        if verbose:
            print(f"Using local skill file: {local_path}")
        with open(local_path, "r", encoding="utf-8") as f:
            return f.read(), f"local:{local_path}"

    bundled_content = BUNDLED_SKILLS.get(relative_path)
    if bundled_content is not None:
        if verbose:
            print(f"Using bundled skill file: {relative_path}")
        return bundled_content, "bundled"

    url = github_repo_file_url(ABSURD_SCHEMA_TARGET_VERSION, relative_path)
    if verbose:
        print(f"Fetching skill file from: {url}")
    try:
        return fetch_text(url), f"github:{url}"
    except URLError as e:
        print(f"Error downloading skill file from GitHub: {e}", file=sys.stderr)
        sys.exit(1)


def render_combined_migration_sql(current_version, target_version, resolved_steps):
    parts = [
        "-- Absurd migration SQL bundle",
        f"-- Range: {current_version} -> {target_version}",
        "",
    ]

    for step in resolved_steps:
        migration = step["migration"]
        sql_content = step["sql"]
        parts.append(
            f"-- begin migration: {migration['name']} [{migration['source']}]"
        )
        if sql_content:
            parts.append(sql_content)
        parts.append(f"-- end migration: {migration['name']}")
        parts.append("")

    return "\n".join(parts)


def cmd_cleanup(args):
    """Clean up old completed, failed, or cancelled tasks and events."""
    usage = "usage: %prog cleanup [options] QUEUE_NAME TTL_DAYS"
    parser = build_command_parser(usage)
    parser.add_option(
        "-l",
        "--limit",
        dest="limit",
        type="int",
        default=1000,
        help="Batch size for deletions (default: 1000)",
    )
    parser.add_option(
        "-n",
        "--dry-run",
        dest="dry_run",
        action="store_true",
        default=False,
        help="Show what would be deleted without actually deleting",
    )
    examples = [
        "absurdctl cleanup myqueue 30",
        "absurdctl cleanup --dry-run -v myqueue 30",
    ]

    options, args = parse_args_with_help(parser, args, examples)

    if len(args) != 2:
        print(
            "Error: Missing required arguments QUEUE_NAME and TTL_DAYS", file=sys.stderr
        )
        parser.print_help()
        sys.exit(1)

    queue_name = validate_queue_name(args[0])
    try:
        ttl_days = int(args[1])
    except ValueError:
        print(
            f"Error: TTL_DAYS must be a positive integer, got: {args[1]}",
            file=sys.stderr,
        )
        sys.exit(1)

    config = config_from_options(options)
    ensure_queue_exists(config, queue_name)

    ttl_seconds = ttl_days * 24 * 60 * 60

    print_verbose_configuration(
        options.verbose,
        [
            ("Queue", queue_name),
            ("TTL", f"{ttl_days} days ({ttl_seconds} seconds)"),
        ]
        + get_common_db_details(config)
        + [
            ("Limit", options.limit),
            ("Dry run", options.dry_run),
        ],
    )

    if options.dry_run:
        print("DRY RUN MODE - No data will be deleted")

        t_rel = queue_relation("t", queue_name)
        r_rel = queue_relation("r", queue_name)
        e_rel = queue_relation("e", queue_name)

        task_count = run_psql(
            config,
            f"""SELECT COUNT(*) FROM {t_rel} t
                LEFT JOIN {r_rel} r ON r.run_id = t.last_attempt_run
                WHERE t.state IN ('completed', 'failed', 'cancelled')
                AND ((t.state = 'completed' AND r.completed_at < NOW() - make_interval(secs => :'ttl_seconds'::int))
                OR (t.state = 'failed' AND r.failed_at < NOW() - make_interval(secs => :'ttl_seconds'::int))
                OR (t.state = 'cancelled' AND t.cancelled_at < NOW() - make_interval(secs => :'ttl_seconds'::int)));""",
            tuples_only=True,
            no_align=True,
            variables={"ttl_seconds": ttl_seconds},
        )

        event_count = run_psql(
            config,
            f"SELECT COUNT(*) FROM {e_rel} WHERE emitted_at < NOW() - make_interval(secs => :'ttl_seconds'::int);",
            tuples_only=True,
            no_align=True,
            variables={"ttl_seconds": ttl_seconds},
        )

        print(f"Would delete {task_count} tasks and {event_count} events")
        return

    print(f"Starting cleanup for queue '{queue_name}' (TTL: {ttl_days} days)")

    total_tasks_deleted = 0
    total_events_deleted = 0
    iteration = 0

    while True:
        iteration += 1

        if options.verbose:
            print(f"Iteration {iteration}...")

        tasks_deleted = int(
            run_psql(
                config,
                "SELECT absurd.cleanup_tasks(:'queue_name', :'ttl_seconds'::int, :'limit'::int);",
                tuples_only=True,
                no_align=True,
                variables={
                    "queue_name": queue_name,
                    "ttl_seconds": ttl_seconds,
                    "limit": options.limit,
                },
            )
        )
        total_tasks_deleted += tasks_deleted

        if options.verbose and tasks_deleted > 0:
            print(f"  Deleted {tasks_deleted} tasks")

        events_deleted = int(
            run_psql(
                config,
                "SELECT absurd.cleanup_events(:'queue_name', :'ttl_seconds'::int, :'limit'::int);",
                tuples_only=True,
                no_align=True,
                variables={
                    "queue_name": queue_name,
                    "ttl_seconds": ttl_seconds,
                    "limit": options.limit,
                },
            )
        )
        total_events_deleted += events_deleted

        if options.verbose and events_deleted > 0:
            print(f"  Deleted {events_deleted} events")

        if tasks_deleted == 0 and events_deleted == 0:
            break

        if tasks_deleted > 0 or events_deleted > 0:
            print(f"Deleted {tasks_deleted} tasks, {events_deleted} events")

    print(
        f"Cleanup complete: {total_tasks_deleted} tasks, {total_events_deleted} events deleted in {iteration} iterations"
    )


def cmd_create_queue(args):
    """Create a new Absurd queue."""
    usage = "usage: %prog create-queue [options] QUEUE_NAME"
    parser = build_command_parser(usage)

    options, args = parse_args_with_help(
        parser, args, ["absurdctl create-queue myqueue"]
    )

    if len(args) != 1:
        print("Error: Missing required argument QUEUE_NAME", file=sys.stderr)
        parser.print_help()
        sys.exit(1)

    queue_name = validate_queue_name(args[0])
    config = config_from_options(options)

    print_verbose_configuration(
        options.verbose,
        [("Queue", queue_name)] + get_common_db_details(config),
    )
    if options.verbose:
        print(f"Creating queue '{queue_name}'...")

    run_psql(config, "SELECT absurd.create_queue(:'queue_name');", variables={"queue_name": queue_name})
    print(f"Queue '{queue_name}' created successfully")


def cmd_drop_queue(args):
    """Drop an existing Absurd queue."""
    usage = "usage: %prog drop-queue [options] QUEUE_NAME"
    parser = build_command_parser(usage)
    parser.add_option(
        "-y",
        "--yes",
        dest="yes",
        action="store_true",
        default=False,
        help="Skip confirmation prompt",
    )
    examples = [
        "absurdctl drop-queue myqueue",
        "absurdctl drop-queue --yes myqueue",
    ]

    options, args = parse_args_with_help(parser, args, examples)

    if len(args) != 1:
        print("Error: Missing required argument QUEUE_NAME", file=sys.stderr)
        parser.print_help()
        sys.exit(1)

    queue_name = validate_queue_name(args[0])
    config = config_from_options(options)

    ensure_queue_exists(config, queue_name)

    print_verbose_configuration(
        options.verbose,
        [("Queue", queue_name)] + get_common_db_details(config),
    )

    if not options.yes:
        print(
            f"WARNING: This will permanently delete queue '{queue_name}' and all its data."
        )
        response = input("Are you sure you want to continue? (yes/no): ")
        if response != "yes":
            print("Operation cancelled")
            return

    if options.verbose:
        print(f"Dropping queue '{queue_name}'...")

    run_psql(config, "SELECT absurd.drop_queue(:'queue_name');", variables={"queue_name": queue_name})
    print(f"Queue '{queue_name}' dropped successfully")


def cmd_list_queues(args):
    """List all existing Absurd queues."""
    usage = "usage: %prog list-queues [options]"
    parser = build_command_parser(usage)

    options, args = parse_args_with_help(parser, args)

    config = config_from_options(options)

    result = run_psql(
        config, "SELECT * FROM absurd.list_queues();", tuples_only=True, no_align=True
    ).strip()
    if result:
        print(result)


def cmd_init(args):
    """Initialize the Absurd schema by applying absurd.sql."""
    usage = "usage: %prog init [options]"
    parser = build_command_parser(usage)
    parser.add_option(
        "--ref",
        dest="ref",
        metavar="REF",
        help=f"Git ref/tag for absurd.sql (default: {ABSURD_SCHEMA_TARGET_VERSION})",
    )

    options, args = parse_args_with_help(
        parser,
        args,
        [
            "absurdctl init",
            "absurdctl init --ref 0.1.1",
        ],
    )

    if args:
        print("Error: init does not accept positional arguments", file=sys.stderr)
        parser.print_help()
        sys.exit(1)

    config = config_from_options(options)
    target_ref = options.ref or ABSURD_SCHEMA_TARGET_VERSION

    print_verbose_configuration(
        options.verbose,
        [("Schema ref", target_ref)] + get_common_db_details(config),
    )

    script_dir = os.path.dirname(os.path.abspath(__file__))
    sql_content, sql_source = load_absurd_sql(script_dir, target_ref, options.verbose)
    result = run_psql(
        config,
        input_data=sql_content,
        error_message="Error applying SQL file",
    )

    if options.verbose and result.stdout:
        print(result.stdout)
    print(f"Absurd schema initialized successfully ({target_ref} via {sql_source})")


def cmd_schema_version(args):
    """Show the currently recorded Absurd schema version."""
    usage = "usage: %prog schema-version [options]"
    parser = build_command_parser(usage)

    options, args = parse_args_with_help(
        parser,
        args,
        ["absurdctl schema-version"],
    )

    if args:
        print("Error: schema-version does not accept positional arguments", file=sys.stderr)
        parser.print_help()
        sys.exit(1)

    config = config_from_options(options)
    version = get_recorded_schema_version(config)
    if version is None:
        print("unknown")
        return
    print(version)


def cmd_migrate(args):
    """Apply Absurd schema migrations from the recorded version to a target."""
    usage = "usage: %prog migrate [options]"
    parser = build_command_parser(usage)
    parser.add_option(
        "--from",
        dest="from_version",
        metavar="VERSION",
        help="Source schema version (overrides value recorded in DB)",
    )
    parser.add_option(
        "--to",
        dest="to_version",
        metavar="VERSION",
        help=f"Target schema version (default: {ABSURD_SCHEMA_TARGET_VERSION})",
    )
    parser.add_option(
        "--dry-run",
        dest="dry_run",
        action="store_true",
        default=False,
        help="Show the migration plan without applying SQL",
    )
    parser.add_option(
        "--dump-sql",
        dest="dump_sql",
        action="store_true",
        default=False,
        help="Print combined migration SQL for the selected range and exit",
    )

    options, args = parse_args_with_help(
        parser,
        args,
        [
            "absurdctl migrate",
            "absurdctl migrate --to 0.1.1",
            "absurdctl migrate --from 0.0.8 --to main",
            "absurdctl migrate --from 0.1.0 --to main --dump-sql > absurd-migrations.sql",
        ],
    )

    if args:
        print("Error: migrate does not accept positional arguments", file=sys.stderr)
        parser.print_help()
        sys.exit(1)

    script_dir = os.path.dirname(os.path.abspath(__file__))
    target_version = options.to_version or ABSURD_SCHEMA_TARGET_VERSION

    if options.dump_sql and options.from_version is None:
        print(
            "Error: --dump-sql requires --from VERSION so SQL can be generated from an explicit range.",
            file=sys.stderr,
        )
        sys.exit(1)

    config = None
    from_version_supplied = options.from_version is not None

    if options.dump_sql:
        current_version = options.from_version
    else:
        config = config_from_options(options)
        current_version = options.from_version or get_recorded_schema_version(config)

    if current_version is None:
        print(
            "Error: Could not determine current Absurd schema version. "
            "Pass --from VERSION, or install a schema that defines absurd.get_schema_version().",
            file=sys.stderr,
        )
        sys.exit(1)

    migrations = discover_migrations(script_dir, target_version, include_remote=False)
    path = resolve_migration_path(current_version, target_version, migrations)

    needs_remote_retry = path is None or (
        compare_versions(current_version, target_version) < 0 and not path
    )

    if needs_remote_retry:
        # Local/bundled migrations might not be complete (for example when
        # running a standalone absurdctl without migration files). Fall back to
        # GitHub to discover additional migrations.
        migrations = discover_migrations(
            script_dir,
            target_version,
            include_remote=True,
        )
        path = resolve_migration_path(current_version, target_version, migrations)

    if path is None:
        print(
            f"Error: Downgrade migrations are not supported ({current_version} -> {target_version}).",
            file=sys.stderr,
        )
        sys.exit(1)

    if not path and compare_versions(current_version, target_version) == 0:
        if not options.dump_sql:
            print(f"Absurd schema is already at {target_version}")
        return

    if not path and compare_versions(current_version, target_version) < 0:
        if not options.dump_sql:
            print(
                f"No migration files found between {current_version} and {target_version}; "
                "treating intermediate releases as null migrations."
            )
            print(f"Absurd schema migrated successfully to {target_version}")
        return

    if (options.verbose or options.dry_run) and not options.dump_sql:
        print(f"Migration plan ({current_version} -> {target_version}):")
        for migration in path:
            print(f"  - {migration['name']} [{migration['source']}]")

    if options.dry_run and not options.dump_sql:
        return

    resolved_steps = []
    for migration in path:
        sql_content = get_migration_sql(migration)
        resolved_steps.append(
            {
                "migration": migration,
                "sql": sql_content,
                "declares_schema_version": migration_declares_schema_version(
                    sql_content
                ),
            }
        )

    legacy_steps = [
        step["migration"]["name"]
        for step in resolved_steps
        if not step["declares_schema_version"]
    ]
    if legacy_steps and not from_version_supplied:
        print(
            "Error: Migration plan contains legacy migrations without "
            "absurd.get_schema_version() metadata.",
            file=sys.stderr,
        )
        print(
            "Please rerun with --from VERSION to explicitly confirm the "
            "starting schema version.",
            file=sys.stderr,
        )
        print("Legacy migrations in plan:", file=sys.stderr)
        for migration_name in legacy_steps:
            print(f"  - {migration_name}", file=sys.stderr)
        sys.exit(1)

    if options.dump_sql:
        print(
            render_combined_migration_sql(
                current_version,
                target_version,
                resolved_steps,
            ),
            end="",
        )
        return

    for step in resolved_steps:
        migration = step["migration"]
        sql_content = step["sql"]
        if options.verbose:
            print(f"Applying migration {migration['name']}...")
        run_psql(
            config,
            input_data=sql_content,
            error_message=f"Error applying migration {migration['name']}",
        )

        parsed = parse_migration_filename(migration["name"])
        if parsed is not None and step["declares_schema_version"]:
            _, migrated_to = parsed
            actual_version = get_recorded_schema_version(config)
            if actual_version != migrated_to:
                print(
                    f"Error: Migration {migration['name']} did not set schema version to {migrated_to} "
                    f"(got {actual_version or 'unknown'}).",
                    file=sys.stderr,
                )
                sys.exit(1)
        elif parsed is not None and options.verbose:
            print(
                f"Migration {migration['name']} does not define absurd.get_schema_version(); "
                "skipping version verification for this step."
            )

    print(f"Absurd schema migrated successfully to {target_version}")


def cmd_spawn_task(args):
    """Spawn a new task."""
    usage = "usage: %prog spawn-task [options] TASK_NAME"
    parser = build_command_parser(usage)

    parser.add_option(
        "-q",
        "--queue",
        dest="queue",
        metavar="NAME",
        help="Queue name (default: default)",
    )

    parser.add_option(
        "-P",
        "--param",
        dest="params",
        action="append",
        default=[],
        metavar="K=V",
        help="Task parameter. Use -P key=value for strings or -P key:=json for JSON values. "
        "Supports dotted paths for nested objects (e.g., -P user.name=Alice)",
    )
    parser.add_option(
        "--params",
        dest="base_params",
        metavar="JSON",
        help="Base JSON object for parameters (will be merged with -P flags)",
    )
    parser.add_option(
        "-H",
        "--header",
        dest="headers",
        action="append",
        default=[],
        metavar="K=V",
        help="Task header as key=value pair",
    )

    parser.add_option(
        "--max-attempts",
        dest="max_attempts",
        type="int",
        metavar="N",
        help="Maximum number of retry attempts",
    )
    parser.add_option(
        "--retry-kind",
        dest="retry_kind",
        metavar="KIND",
        help="Retry strategy: fixed, exponential, or none",
    )
    parser.add_option(
        "--retry-base",
        dest="retry_base",
        type="int",
        metavar="SECS",
        help="Base retry delay in seconds",
    )
    parser.add_option(
        "--retry-factor",
        dest="retry_factor",
        type="float",
        metavar="FACTOR",
        help="Exponential backoff multiplier (for exponential retry)",
    )
    parser.add_option(
        "--retry-max",
        dest="retry_max",
        type="int",
        metavar="SECS",
        help="Maximum retry delay cap in seconds",
    )

    parser.add_option(
        "--max-duration",
        dest="max_duration",
        type="int",
        metavar="SECS",
        help="Maximum task execution duration in seconds",
    )
    parser.add_option(
        "--max-delay",
        dest="max_delay",
        type="int",
        metavar="SECS",
        help="Maximum delay before task must start in seconds",
    )

    examples = [
        "absurdctl spawn-task my-task -P foo=bar -P count:=42",
        "absurdctl spawn-task my-task -P user.name=Alice -P user.age:=30",
        'absurdctl spawn-task my-task --params \'{"foo":"bar"}\' -P extra=value',
        "absurdctl spawn-task my-task -P email=test@example.com -q myqueue --max-attempts 5",
    ]

    options, args = parse_args_with_help(parser, args, examples)

    if len(args) != 1:
        print("Error: Missing required argument TASK_NAME", file=sys.stderr)
        parser.print_help()
        sys.exit(1)

    task_name = args[0]
    config = config_from_options(options)

    params_json = build_json_params(options.params, options.base_params)

    headers_payload = None
    if options.headers:
        headers_dict = {}
        for header in options.headers:
            if "=" not in header:
                print(
                    f"Invalid header format: '{header}' (expected key=value)",
                    file=sys.stderr,
                )
                sys.exit(1)
            key, value = header.split("=", 1)
            headers_dict[key] = value
        headers_payload = json.dumps(headers_dict)

    retry_json = "NULL"
    if (
        options.retry_kind
        or options.retry_base
        or options.retry_factor
        or options.retry_max
    ):
        retry = {"kind": options.retry_kind or "exponential"}
        if options.retry_base:
            retry["baseSeconds"] = options.retry_base
        if options.retry_factor:
            retry["factor"] = options.retry_factor
        if options.retry_max:
            retry["maxSeconds"] = options.retry_max
        retry_json = f"'{json.dumps(retry)}'::jsonb"

    cancellation_json = "NULL"
    if options.max_duration or options.max_delay:
        cancellation = {}
        if options.max_duration:
            cancellation["maxDuration"] = options.max_duration
        if options.max_delay:
            cancellation["maxDelay"] = options.max_delay
        cancellation_json = f"'{json.dumps(cancellation)}'::jsonb"

    queue = validate_queue_name(options.queue or "default")

    ensure_queue_exists(config, queue)

    details = [
        ("Task", task_name),
        ("Queue", queue),
        ("Params", params_json),
    ]
    if headers_payload is not None:
        details.append(("Headers", headers_payload))
    if options.max_attempts:
        details.append(("Max attempts", options.max_attempts))
    if retry_json != "NULL":
        details.append(("Retry strategy", retry_json))
    if cancellation_json != "NULL":
        details.append(("Cancellation", cancellation_json))
    details.extend(get_common_db_details(config))
    print_verbose_configuration(options.verbose, details)

    headers_expr = "NULL"
    variables = {
        "queue": queue,
        "task_name": task_name,
        "params_json": params_json,
    }
    if headers_payload is not None:
        headers_expr = ":'headers_json'::jsonb"
        variables["headers_json"] = headers_payload

    query = f"""
        SELECT task_id, run_id, attempt
        FROM absurd.spawn_task(
            :'queue',
            :'task_name',
            :'params_json'::jsonb,
            {headers_expr}
        );
    """

    rows = run_psql_csv(config, query, variables=variables)

    if rows:
        task_id, run_id, attempt = rows[0]
        print(f"Task spawned successfully:")
        print(f"  Task ID: {task_id}")
        print(f"  Run ID: {run_id}")
        print(f"  Attempt: {attempt}")
    else:
        print("Task spawned, but no result returned", file=sys.stderr)


def format_error_like(data):
    """Try to extract and format error-like objects with name, message, and stack."""
    if not isinstance(data, dict):
        return None

    if "name" in data and "message" in data and "stack" in data:
        return {
            "name": data["name"],
            "message": data["message"],
            "stack": data["stack"],
        }
    return None


def format_json_value(value, indent=0):
    """Format a JSON value with proper indentation."""
    if value is None:
        return "null"

    # Check if it's an error-like object
    error_info = format_error_like(value)
    if error_info:
        lines = []
        lines.append(f"{error_info['name']}")
        lines.append(f"\nMessage:")
        lines.append(f"  {error_info['message']}")
        if error_info["stack"]:
            lines.append(f"\nStack trace:")
            for line in error_info["stack"].split("\n"):
                lines.append(f"  {line}")
        return "\n".join(lines)

    # Otherwise, render as JSON
    return json.dumps(value, indent=2)


def format_timestamp(ts_str):
    """Format a timestamp string in a human-readable way."""
    if not ts_str:
        return "—"
    try:
        from datetime import datetime

        dt = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
        return dt.strftime("%b %d, %Y %I:%M:%S %p")
    except:
        return ts_str


def format_age(timestamp_str):
    """Format a timestamp as a relative age (e.g., '2h ago', '3d ago')."""
    if not timestamp_str:
        return "—"
    try:
        from datetime import datetime, timezone

        dt = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
        now = datetime.now(timezone.utc)
        delta = now - dt

        seconds = delta.total_seconds()
        if seconds < 60:
            return f"{int(seconds)}s ago"
        elif seconds < 3600:
            return f"{int(seconds / 60)}m ago"
        elif seconds < 86400:
            return f"{int(seconds / 3600)}h ago"
        elif seconds < 604800:
            return f"{int(seconds / 86400)}d ago"
        else:
            return f"{int(seconds / 604800)}w ago"
    except:
        return timestamp_str


def cmd_list_tasks(args):
    """List tasks across queues with filtering options."""
    usage = "usage: %prog list-tasks [options]"
    parser = build_command_parser(usage)
    parser.add_option(
        "-q",
        "--queue",
        dest="queue",
        metavar="NAME",
        help="Filter by queue name",
    )
    parser.add_option(
        "-n",
        "--task-name",
        dest="task_name",
        metavar="NAME",
        help="Filter by task name (exact match)",
    )
    parser.add_option(
        "-s",
        "--status",
        dest="status",
        metavar="STATUS",
        help="Filter by status (pending, running, sleeping, completed, failed, cancelled)",
    )
    parser.add_option(
        "-l",
        "--limit",
        dest="limit",
        type="int",
        default=50,
        metavar="N",
        help="Maximum number of tasks to display (default: 50)",
    )

    examples = [
        "absurdctl list-tasks",
        "absurdctl list-tasks --queue=default",
        "absurdctl list-tasks --status=failed",
        "absurdctl list-tasks --task-name=my-task --queue=default",
        "absurdctl list-tasks --limit=100",
    ]

    options, args = parse_args_with_help(parser, args, examples)

    config = config_from_options(options)

    # Validate status if provided
    valid_statuses = [
        "pending",
        "running",
        "sleeping",
        "completed",
        "failed",
        "cancelled",
    ]
    if options.status and options.status not in valid_statuses:
        print(
            f"Error: Invalid status '{options.status}'. Must be one of: {', '.join(valid_statuses)}",
            file=sys.stderr,
        )
        sys.exit(1)

    # Get list of queues
    queues_result = run_psql(
        config,
        "SELECT queue_name FROM absurd.queues;",
        tuples_only=True,
        no_align=True,
    )

    if not queues_result:
        print("No queues found in the database", file=sys.stderr)
        sys.exit(0)

    queues = [q for q in queues_result.strip().split("\n") if q]

    # Filter to specific queue if requested
    if options.queue:
        validate_queue_name(options.queue)
        if options.queue not in queues:
            print(f"Queue '{options.queue}' does not exist", file=sys.stderr)
            sys.exit(1)
        queues = [options.queue]

    # Build query for each queue and collect results
    all_tasks = []

    for queue in queues:
        # Build WHERE clause based on filters
        where_clauses = []
        variables = {}
        if options.task_name:
            where_clauses.append("t.task_name = :'task_name'")
            variables["task_name"] = options.task_name
        if options.status:
            where_clauses.append("t.state = :'status'")
            variables["status"] = options.status

        variables["queue_name"] = queue

        where_clause = ""
        if where_clauses:
            where_clause = "WHERE " + " AND ".join(where_clauses)

        t_rel = queue_relation("t", queue)
        r_rel = queue_relation("r", queue)

        query = f"""
            SELECT
                t.task_id,
                r.run_id,
                t.task_name,
                :'queue_name' as queue_name,
                t.state,
                r.attempt,
                t.max_attempts,
                COALESCE(r.completed_at, r.failed_at, r.started_at, r.created_at) AS updated_at
            FROM {t_rel} t
            LEFT JOIN {r_rel} r ON r.run_id = t.last_attempt_run
            {where_clause}
            ORDER BY updated_at DESC
            LIMIT {options.limit}
        """

        rows = run_psql_csv(config, query, variables=variables if variables else None)

        for parts in rows:
            if len(parts) >= 8:
                all_tasks.append(
                    {
                        "task_id": parts[0],
                        "run_id": parts[1] if parts[1] else "—",
                        "task_name": parts[2],
                        "queue": parts[3],
                        "status": parts[4],
                        "attempt": parts[5],
                        "max_attempts": parts[6] if parts[6] else "∞",
                        "updated_at": parts[7],
                    }
                )

    # Sort all tasks by updated_at descending
    all_tasks.sort(key=lambda x: x["updated_at"], reverse=True)

    # Apply global limit
    all_tasks = all_tasks[: options.limit]

    if not all_tasks:
        print("No tasks found")
        return

    # Print tasks with full identifiers on two lines for easier copying.
    for task in all_tasks:
        attempts = f"{task['attempt']}/{task['max_attempts']}"
        age = format_age(task["updated_at"])
        print(f"task_id={task['task_id']}")
        print(f"  run_id={task['run_id']}")
        print(f"  attempts={attempts}")
        print(f"  age={age}")
        print(f"  queue={task['queue']}")
        print(f"  task_name={task['task_name']}")
        print(f"  status={task['status']}")
        print()

    print()
    print(f"Total: {len(all_tasks)} task(s)")


def cmd_cancel_task(args):
    """Cancel a running or pending task."""
    usage = "usage: %prog cancel-task [options] TASK_ID"
    parser = build_command_parser(usage)
    parser.add_option(
        "-q",
        "--queue",
        dest="queue",
        metavar="NAME",
        help="Queue name (default: default)",
    )

    examples = [
        "absurdctl cancel-task 019a32d3-8425-7ae2-a5af-2f17a6707666",
        "absurdctl cancel-task -q myqueue 019a32d3-8425-7ae2-a5af-2f17a6707666",
    ]

    options, args = parse_args_with_help(parser, args, examples)

    if len(args) != 1:
        print("Error: Missing required argument TASK_ID", file=sys.stderr)
        parser.print_help()
        sys.exit(1)

    task_id = args[0]
    queue = validate_queue_name(options.queue or "default")
    config = config_from_options(options)

    try:
        run_psql(
            config,
            "SELECT absurd.cancel_task(:'queue', :'task_id');",
            variables={"queue": queue, "task_id": task_id},
        )
        print(f"Task {task_id} cancelled in queue '{queue}'")
    except subprocess.CalledProcessError as e:
        print(f"Error cancelling task: {e}", file=sys.stderr)
        sys.exit(1)


def cmd_retry_task(args):
    """Retry a failed task by extending attempts or spawning a new task."""
    usage = "usage: %prog retry-task [options] TASK_ID"
    parser = build_command_parser(usage)
    parser.add_option(
        "-q",
        "--queue",
        dest="queue",
        metavar="NAME",
        help="Queue name (default: default)",
    )
    parser.add_option(
        "--max-attempts",
        dest="max_attempts",
        type="int",
        metavar="N",
        help="Set max attempts for retry (in-place or when using --spawn-new)",
    )
    parser.add_option(
        "--spawn-new",
        dest="spawn_new",
        action="store_true",
        default=False,
        help="Spawn a brand new task with the original inputs",
    )

    examples = [
        "absurdctl retry-task 019a32d3-8425-7ae2-a5af-2f17a6707666",
        "absurdctl retry-task 019a32d3-8425-7ae2-a5af-2f17a6707666 --max-attempts 5",
        "absurdctl retry-task -q myqueue 019a32d3-8425-7ae2-a5af-2f17a6707666 --spawn-new",
        "absurdctl retry-task 019a32d3-8425-7ae2-a5af-2f17a6707666 --spawn-new --max-attempts 8",
    ]

    options, args = parse_args_with_help(parser, args, examples)

    if len(args) != 1:
        print("Error: Missing required argument TASK_ID", file=sys.stderr)
        parser.print_help()
        sys.exit(1)

    task_id = args[0]
    queue = validate_queue_name(options.queue or "default")
    config = config_from_options(options)

    retry_options = {}
    if options.spawn_new:
        retry_options["spawn_new"] = True
    if options.max_attempts is not None:
        retry_options["max_attempts"] = options.max_attempts

    rows = run_psql_csv(
        config,
        """
        SELECT task_id, run_id, attempt, created
        FROM absurd.retry_task(:'queue', :'task_id', :'options_json'::jsonb);
        """,
        variables={
            "queue": queue,
            "task_id": task_id,
            "options_json": json.dumps(retry_options),
        },
    )

    if not rows:
        print("Retry requested, but no result returned", file=sys.stderr)
        sys.exit(1)

    out_task_id, out_run_id, out_attempt, out_created = rows[0]
    created = str(out_created).lower() in ("t", "true", "1")

    if created:
        print("Task respawned successfully:")
    else:
        print("Task retried successfully:")
    print(f"  Task ID: {out_task_id}")
    print(f"  Run ID: {out_run_id}")
    print(f"  Attempt: {out_attempt}")


def cmd_dump_task(args):
    """Dump task or run details including checkpoints."""
    usage = "usage: %prog dump-task [options]"
    parser = build_command_parser(usage)
    parser.add_option(
        "--task-id",
        dest="task_id",
        metavar="UUID",
        help="Task ID to dump",
    )
    parser.add_option(
        "--run-id",
        dest="run_id",
        metavar="UUID",
        help="Run ID to dump",
    )

    examples = [
        "absurdctl dump-task --task-id=019a32d3-8425-7ae2-a5af-2f17a6707666",
        "absurdctl dump-task --run-id=019a32d3-8425-7ae2-a5af-2f17a6707666",
    ]

    options, args = parse_args_with_help(parser, args, examples)

    if not options.task_id and not options.run_id:
        print("Error: Either --task-id or --run-id must be specified", file=sys.stderr)
        parser.print_help()
        sys.exit(1)

    if options.task_id and options.run_id:
        print("Error: Cannot specify both --task-id and --run-id", file=sys.stderr)
        parser.print_help()
        sys.exit(1)

    config = config_from_options(options)

    # Get list of queues
    queues_result = run_psql(
        config,
        "SELECT queue_name FROM absurd.queues;",
        tuples_only=True,
        no_align=True,
    )

    if not queues_result:
        print("No queues found in the database", file=sys.stderr)
        sys.exit(1)

    queues = [q for q in queues_result.strip().split("\n") if q]

    # Find which queue contains the task/run
    target_queue = None
    task_id = None

    if options.run_id:
        # Search for run_id across all queues
        for queue in queues:
            r_rel = queue_relation("r", queue)
            result = run_psql(
                config,
                f"SELECT task_id FROM {r_rel} WHERE run_id = :'run_id';",
                tuples_only=True,
                no_align=True,
                variables={"run_id": options.run_id},
            )
            if result:
                target_queue = queue
                task_id = result.strip()
                break

        if not target_queue:
            print(f"Run ID '{options.run_id}' not found in any queue", file=sys.stderr)
            sys.exit(1)
    else:
        # Search for task_id across all queues
        task_id = options.task_id
        for queue in queues:
            t_rel = queue_relation("t", queue)
            result = run_psql(
                config,
                f"SELECT COUNT(*) FROM {t_rel} WHERE task_id = :'task_id';",
                tuples_only=True,
                no_align=True,
                variables={"task_id": task_id},
            )
            if result and int(result.strip()) > 0:
                target_queue = queue
                break

        if not target_queue:
            print(f"Task ID '{task_id}' not found in any queue", file=sys.stderr)
            sys.exit(1)

    t_target_rel = queue_relation("t", target_queue)
    r_target_rel = queue_relation("r", target_queue)

    # Fetch task and runs
    if options.run_id:
        # Fetch specific run
        runs_query = f"""
            SELECT
                t.task_id,
                r.run_id,
                t.task_name,
                t.state,
                r.state as run_state,
                r.attempt,
                t.max_attempts,
                t.params,
                t.retry_strategy,
                t.headers,
                COALESCE(r.failure_reason, r.result) AS final_state,
                r.created_at,
                r.started_at,
                r.completed_at,
                r.failed_at,
                t.cancelled_at,
                COALESCE(r.completed_at, r.failed_at, r.started_at, r.created_at) AS updated_at,
                r.claimed_by
            FROM {t_target_rel} t
            JOIN {r_target_rel} r ON r.task_id = t.task_id
            WHERE r.run_id = :'run_id'
        """
        runs_rows = run_psql_csv(config, runs_query, variables={"run_id": options.run_id})
    else:
        # Fetch all runs for task
        runs_query = f"""
            SELECT
                t.task_id,
                r.run_id,
                t.task_name,
                t.state,
                r.state as run_state,
                r.attempt,
                t.max_attempts,
                t.params,
                t.retry_strategy,
                t.headers,
                COALESCE(r.failure_reason, r.result) AS final_state,
                r.created_at,
                r.started_at,
                r.completed_at,
                r.failed_at,
                t.cancelled_at,
                COALESCE(r.completed_at, r.failed_at, r.started_at, r.created_at) AS updated_at,
                r.claimed_by
            FROM {t_target_rel} t
            JOIN {r_target_rel} r ON r.task_id = t.task_id
            WHERE t.task_id = :'task_id'
            ORDER BY r.attempt
        """
        runs_rows = run_psql_csv(config, runs_query, variables={"task_id": task_id})

    if not runs_rows:
        print("No data found", file=sys.stderr)
        sys.exit(1)

    # Parse runs
    runs = []
    for parts in runs_rows:
        if len(parts) >= 18:
            runs.append(
                {
                    "task_id": parts[0],
                    "run_id": parts[1],
                    "task_name": parts[2],
                    "task_state": parts[3],
                    "run_state": parts[4],
                    "attempt": parts[5],
                    "max_attempts": parts[6] if parts[6] else None,
                    "params": json.loads(parts[7]) if parts[7] else None,
                    "retry_strategy": json.loads(parts[8]) if parts[8] else None,
                    "headers": json.loads(parts[9]) if parts[9] else None,
                    "final_state": json.loads(parts[10]) if parts[10] else None,
                    "created_at": parts[11],
                    "started_at": parts[12],
                    "completed_at": parts[13],
                    "failed_at": parts[14],
                    "cancelled_at": parts[15],
                    "updated_at": parts[16],
                    "worker_id": parts[17] if parts[17] else None,
                }
            )

    c_target_rel = queue_relation("c", target_queue)
    w_target_rel = queue_relation("w", target_queue)
    e_target_rel = queue_relation("e", target_queue)

    # For each run, fetch checkpoints and waits
    for run in runs:
        # Fetch checkpoints
        checkpoints_query = f"""
            SELECT
                checkpoint_name,
                state,
                status,
                owner_run_id,
                updated_at
            FROM {c_target_rel}
            WHERE task_id = :'task_id' AND owner_run_id = :'run_id'
            ORDER BY updated_at DESC
        """

        checkpoint_rows = run_psql_csv(
            config, checkpoints_query, variables={"task_id": run["task_id"], "run_id": run["run_id"]}
        )

        checkpoints = []
        for parts in checkpoint_rows:
            if len(parts) >= 5:
                checkpoints.append(
                    {
                        "name": parts[0],
                        "state": json.loads(parts[1]) if parts[1] else None,
                        "status": parts[2],
                        "owner_run_id": parts[3],
                        "updated_at": parts[4],
                    }
                )

        run["checkpoints"] = checkpoints

        # Fetch wait states
        waits_query = f"""
            SELECT
                CASE
                    WHEN r.wake_event IS NOT NULL THEN 'event'
                    WHEN r.available_at IS NOT NULL THEN 'sleep'
                    ELSE NULL
                END AS wait_type,
                r.available_at,
                r.wake_event,
                w.step_name,
                r.event_payload,
                e.payload as event_payload_emitted,
                e.emitted_at,
                COALESCE(w.created_at, r.started_at) as updated_at
            FROM {r_target_rel} r
            LEFT JOIN {w_target_rel} w ON w.run_id = r.run_id
            LEFT JOIN {e_target_rel} e ON e.event_name = r.wake_event AND e.payload IS NOT NULL
            WHERE r.run_id = :'run_id' AND r.state = 'sleeping'
        """

        waits_rows = run_psql_csv(config, waits_query, variables={"run_id": run["run_id"]})

        waits = []
        for parts in waits_rows:
            if len(parts) >= 8 and parts[0]:
                waits.append(
                    {
                        "wait_type": parts[0],
                        "wake_at": parts[1],
                        "wake_event": parts[2] if parts[2] else None,
                        "step_name": parts[3] if parts[3] else None,
                        "payload": json.loads(parts[4]) if parts[4] else None,
                        "event_payload": json.loads(parts[5]) if parts[5] else None,
                        "emitted_at": parts[6] if parts[6] else None,
                        "updated_at": parts[7],
                    }
                )

        run["waits"] = waits

    # Now output the results
    print("=" * 80)
    print(f"TASK DUMP")
    print("=" * 80)
    print()

    for idx, run in enumerate(runs):
        if idx > 0:
            print()
            print("-" * 80)
            print()

        print(f"BASIC INFORMATION")
        print(f"  Current status:    {run['run_state']}")
        print(f"  Task Name:         {run['task_name']}")
        print(f"  Queue:             {target_queue}")
        print(f"  Task ID:           {run['task_id']}")
        print(f"  Run ID:            {run['run_id']}")
        if run["worker_id"]:
            print(f"  Worker ID:         {run['worker_id']}")
        print()

        print(f"TIMING")
        print(f"  Created:           {format_timestamp(run['created_at'])}")
        print(f"  Updated:           {format_timestamp(run['updated_at'])}")
        if run["started_at"]:
            print(f"  Started:           {format_timestamp(run['started_at'])}")
        if run["completed_at"]:
            print(f"  Completed:         {format_timestamp(run['completed_at'])}")
        if run["failed_at"]:
            print(f"  Failed:            {format_timestamp(run['failed_at'])}")
        if run["cancelled_at"]:
            print(f"  Cancelled:         {format_timestamp(run['cancelled_at'])}")
        print()

        # Wait states
        if run["waits"]:
            print(f"WAIT STATES")
            for wait in run["waits"]:
                wait_type = (
                    wait["wait_type"].capitalize() + " wait"
                    if wait["wait_type"]
                    else "Wait"
                )
                print(f"  {wait_type}")
                if wait["step_name"]:
                    print(f"    Step:            {wait['step_name']}")
                if wait["wake_at"]:
                    print(f"    Wake at:         {format_timestamp(wait['wake_at'])}")
                if wait["wake_event"]:
                    print(f"    Wake event:      {wait['wake_event']}")
                if wait["emitted_at"]:
                    print(
                        f"    Last emit:       {format_timestamp(wait['emitted_at'])}"
                    )
                print(f"    Updated:         {format_timestamp(wait['updated_at'])}")
                if wait["payload"]:
                    print(f"    Wait payload:")
                    for line in format_json_value(wait["payload"]).split("\n"):
                        print(f"      {line}")
                if wait["event_payload"]:
                    print(f"    Event payload:")
                    for line in format_json_value(wait["event_payload"]).split("\n"):
                        print(f"      {line}")
            print()

        print(f"RETRY INFORMATION")
        max_attempts_str = run["max_attempts"] if run["max_attempts"] else "unlimited"
        print(f"  Attempt:           {run['attempt']} of {max_attempts_str}")
        if run["retry_strategy"]:
            print(f"  Retry Strategy:")
            for line in format_json_value(run["retry_strategy"]).split("\n"):
                print(f"    {line}")
        print()

        # Parameters
        if run["params"]:
            print(f"PARAMETERS")
            for line in format_json_value(run["params"]).split("\n"):
                print(f"  {line}")
            print()

        # Headers
        if run["headers"]:
            print(f"HEADERS")
            for line in format_json_value(run["headers"]).split("\n"):
                print(f"  {line}")
            print()

        # Final state
        if run["final_state"]:
            state_label = "FAILURE" if run["run_state"] == "failed" else "FINAL STATE"
            print(state_label)
            for line in format_json_value(run["final_state"]).split("\n"):
                print(f"  {line}")
            print()

        # Checkpoints
        if run["checkpoints"]:
            print(f"CHECKPOINTS")
            for checkpoint in run["checkpoints"]:
                print(f"  {checkpoint['name']} ({checkpoint['status']})")
                print(
                    f"    Updated:         {format_timestamp(checkpoint['updated_at'])}"
                )
                if checkpoint["state"]:
                    print(f"    State:")
                    for line in format_json_value(checkpoint["state"]).split("\n"):
                        print(f"      {line}")
                print()


def cmd_emit_event(args):
    """Emit an event to wake waiting tasks."""
    usage = "usage: %prog emit-event [options] EVENT_NAME"
    parser = build_command_parser(usage)

    parser.add_option(
        "-q",
        "--queue",
        dest="queue",
        metavar="NAME",
        help="Queue name (default: default)",
    )

    parser.add_option(
        "-P",
        "--param",
        dest="params",
        action="append",
        default=[],
        metavar="K=V",
        help="Event payload parameter. Use -P key=value for strings or -P key:=json for JSON values. "
        "Supports dotted paths for nested objects (e.g., -P user.name=Alice)",
    )
    parser.add_option(
        "--payload",
        dest="base_payload",
        metavar="JSON",
        help="Base JSON object for payload (will be merged with -P flags)",
    )

    examples = [
        "absurdctl emit-event order.completed",
        "absurdctl emit-event order.completed -P orderId=123 -P status=shipped",
        "absurdctl emit-event user.created -P user.id:=42 -P user.name=Alice",
        'absurdctl emit-event order.completed --payload \'{"orderId":"123"}\'',
        "absurdctl emit-event -q myqueue shipment.packed -P trackingNumber=ABC123",
    ]

    options, args = parse_args_with_help(parser, args, examples)

    if len(args) != 1:
        print("Error: Missing required argument EVENT_NAME", file=sys.stderr)
        parser.print_help()
        sys.exit(1)

    event_name = args[0]
    config = config_from_options(options)

    payload_json = build_json_params(options.params, options.base_payload)

    queue = validate_queue_name(options.queue or "default")

    ensure_queue_exists(config, queue)

    details = [
        ("Event", event_name),
        ("Queue", queue),
        ("Payload", payload_json),
    ]
    details.extend(get_common_db_details(config))
    print_verbose_configuration(options.verbose, details)

    query = "SELECT absurd.emit_event(:'queue', :'event_name', :'payload_json'::jsonb);"
    run_psql(
        config,
        query,
        variables={
            "queue": queue,
            "event_name": event_name,
            "payload_json": payload_json,
        },
    )
    print(f"Event '{event_name}' emitted successfully to queue '{queue}'")


def cmd_install_skill(args):
    """Install the bundled Absurd agent skill into a skills directory."""
    usage = "usage: %prog install-skill [options] [TARGET_DIR]"
    parser = OptionParser(
        usage=usage,
        add_help_option=False,
        formatter=CleanHelpFormatter(),
    )
    parser.add_option(
        "--force",
        dest="force",
        action="store_true",
        default=False,
        help="Overwrite an existing installed skill if it differs",
    )
    parser.add_option(
        "--help",
        action="store_true",
        help="Show this help message",
    )

    examples = [
        "absurdctl install-skill",
        "absurdctl install-skill .pi/skills",
        "absurdctl install-skill ~/.pi/agent/skills --force",
    ]

    options, args = parse_args_with_help(parser, args, examples)

    if len(args) > 1:
        print("Error: install-skill accepts at most one positional TARGET_DIR", file=sys.stderr)
        parser.print_help()
        sys.exit(1)

    target_root = os.path.expanduser(args[0] if args else ".agents/skills")
    skill_name = "absurd"
    relative_skill_path = f"skills/{skill_name}/SKILL.md"
    script_dir = os.path.dirname(os.path.abspath(__file__))
    skill_content, skill_source = load_skill_file(script_dir, relative_skill_path, False)

    destination = os.path.join(target_root, skill_name, "SKILL.md")
    destination_dir = os.path.dirname(destination)
    os.makedirs(destination_dir, exist_ok=True)

    if os.path.exists(destination):
        with open(destination, "r", encoding="utf-8") as f:
            existing = f.read()
        if existing == skill_content:
            print(f"Skill '{skill_name}' is already installed at {destination}")
            return
        if not options.force:
            print(
                f"Error: {destination} already exists and differs. Re-run with --force to overwrite.",
                file=sys.stderr,
            )
            sys.exit(1)

    with open(destination, "w", encoding="utf-8") as f:
        f.write(skill_content)

    print(f"Installed skill '{skill_name}' to {destination} ({skill_source})")


def show_help():
    print(
        """Usage: absurdctl COMMAND [OPTIONS]

A command-line utility for managing Absurd queues.

Commands:
  init            Initialize the Absurd schema by applying absurd.sql
  migrate         Apply schema migrations to a target version
  schema-version  Show the recorded Absurd schema version
  cleanup         Clean up old completed, failed, or cancelled tasks and events
  create-queue    Create a new queue
  drop-queue      Drop an existing queue
  list-queues     List all existing queues
  spawn-task      Spawn a new task
  retry-task      Retry a failed task
  emit-event      Emit an event to wake waiting tasks
  list-tasks      List tasks with optional filtering
  cancel-task     Cancel a running or pending task
  dump-task       Dump task or run details including checkpoints
  install-skill   Install the bundled Absurd agent skill into a skills folder
  help            Show this help message

Run 'absurdctl COMMAND --help' for more information on a command.

Environment Variables:
  ABSURD_DATABASE_URL  PostgreSQL connection URI (checked before PGDATABASE)
  PGHOST          Database host
  PGPORT          Database port
  PGUSER          Database user
  PGDATABASE      Database name or full PostgreSQL connection URI
  PGPASSWORD      Database password (recommended over command line)

Examples:
  export PGDATABASE="postgresql://user:pass@localhost:5432/mydb"
  absurdctl init
  absurdctl migrate
  absurdctl schema-version
  absurdctl cleanup myqueue 30
  absurdctl create-queue myqueue
  absurdctl drop-queue myqueue --yes
  absurdctl list-queues
  absurdctl spawn-task my-task -P foo=bar -P count:=42
  absurdctl retry-task 019a32d3-8425-7ae2-a5af-2f17a6707666 --max-attempts 5
  absurdctl emit-event order.completed -P orderId=123
  absurdctl list-tasks --queue=default --status=failed
  absurdctl cancel-task 019a32d3-8425-7ae2-a5af-2f17a6707666
  absurdctl dump-task --task-id=019a32d3-8425-7ae2-a5af-2f17a6707666
  absurdctl install-skill .pi/skills
"""
    )


def main():
    if len(sys.argv) < 2 or sys.argv[1] in ("help", "--help", "-h"):
        show_help()
        sys.exit(0)

    command = sys.argv[1]
    args = sys.argv[2:]

    if command == "cleanup":
        cmd_cleanup(args)
    elif command == "migrate":
        cmd_migrate(args)
    elif command == "schema-version":
        cmd_schema_version(args)
    elif command == "create-queue":
        cmd_create_queue(args)
    elif command == "drop-queue":
        cmd_drop_queue(args)
    elif command == "list-queues":
        cmd_list_queues(args)
    elif command == "init":
        cmd_init(args)
    elif command == "spawn-task":
        cmd_spawn_task(args)
    elif command == "retry-task":
        cmd_retry_task(args)
    elif command == "emit-event":
        cmd_emit_event(args)
    elif command == "list-tasks":
        cmd_list_tasks(args)
    elif command == "cancel-task":
        cmd_cancel_task(args)
    elif command == "dump-task":
        cmd_dump_task(args)
    elif command == "install-skill":
        cmd_install_skill(args)
    else:
        print(f"Unknown command: {command}", file=sys.stderr)
        print("")
        show_help()
        sys.exit(1)


if __name__ == "__main__":
    main()
