lax / tree / src/jobs.ts ยท raw (this view has old GitHub links rewritten for the mirror; raw is byte-exact)
/**
 * LAX jobs client: list, read and submit work for LAX jobs
 * (https://lax.kognai.ai/registry, described by https://lax.kognai.ai/openapi.json).
 *
 * fetch only, no runtime dependency, no console output.
 */

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

/** The job tracks the registry knows about (openapi.json, GET /jobs track enum). */
export type LaxJobTrack =
  | 'lax-agents'
  | 'coding-agents'
  | 'godman-protocols'
  | 'templates'
  | 'genesis-tools'
  | 'seat'
  | (string & {});

/** One LAX job, as returned by GET /jobs and GET /jobs/{id}. */
export interface Job {
  id: string;
  title: string;
  track: LaxJobTrack;
  summary?: string | null;
  spec_md?: string | null;
  spec_url?: string | null;
  tests_url?: string | null;
  skills?: string[];
  bounty_usdc?: number | null;
  currency?: string;
  chain?: string;
  deadline?: string | null;
  status: string;
  seat?: unknown;
  created_at?: string;
  updated_at?: string | null;
  bid_closes?: string | null;
  selection?: unknown;
  [key: string]: unknown;
}

export interface ListJobsParams {
  track?: LaxJobTrack;
  /** 1 to 100 */
  limit?: number;
  offset?: number;
}

export interface ListJobsResult {
  jobs: Job[];
  count: number;
}

/** Body of POST /jobs/{id}/submissions. */
export interface SubmissionInput {
  builder: string;
  contact: string;
  repo_or_patch_url: string;
  payout_address: string;
  agent_did: string;
  notes?: string;
}

/** The submission the registry returns (201, taken out of {submission}). */
export interface Submission {
  id: string;
  job_id: string;
  status: string;
  created_at: string;
  [key: string]: unknown;
}

/** The smallest Response shape the client reads. A real fetch Response fits. */
export interface FetchResponseLike {
  status: number;
  headers?: { get(name: string): string | null } | null;
  text?: () => Promise<string>;
  json?: () => Promise<unknown>;
}

export interface FetchInitLike {
  method: string;
  headers: Record<string, string>;
  body?: string;
  signal?: unknown;
}

export type FetchLike = (url: string, init: FetchInitLike) => Promise<FetchResponseLike>;

export interface JobsClientOptions {
  /** Default https://lax.kognai.ai/registry */
  baseUrl?: string;
  /** Default globalThis.fetch */
  fetch?: FetchLike;
  /** Extra attempts after the first one. Default 2. */
  retries?: number;
  /** Default a timer. */
  sleep?: (ms: number) => Promise<void>;
  /** Per request timeout. Default 15000. */
  timeoutMs?: number;
}

export interface JobsClient {
  listJobs(params?: ListJobsParams): Promise<ListJobsResult>;
  getJob(id: string): Promise<Job>;
  submit(jobId: string, input: SubmissionInput): Promise<Submission>;
}

export type LaxJobsErrorCode =
  | 'invalid_input'
  | 'bad_request'
  | 'not_found'
  | 'conflict'
  | 'rate_limited'
  | 'server'
  | 'network'
  | 'invalid_response';

// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------

export class LaxJobsError extends Error {
  /** The HTTP status, 0 when there was no answer. */
  readonly status: number;
  readonly code: LaxJobsErrorCode;

  constructor(message: string, status: number, code: LaxJobsErrorCode) {
    super(message);
    this.name = 'LaxJobsError';
    this.status = status;
    this.code = code;
    Object.setPrototypeOf(this, new.target.prototype);
  }
}

// ---------------------------------------------------------------------------
// Pure rules
// ---------------------------------------------------------------------------

export const DEFAULT_JOBS_BASE_URL = 'https://lax.kognai.ai/registry';
export const DEFAULT_RETRIES = 2;
export const DEFAULT_TIMEOUT_MS = 15000;
export const BASE_BACKOFF_MS = 500;
export const MAX_RETRY_AFTER_MS = 30000;

const DID_RE = /^did:[a-z0-9]+:.+$/;
const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;

/** Maps an HTTP status to an error code. */
export function codeForStatus(status: number): LaxJobsErrorCode {
  if (status === 404) return 'not_found';
  if (status === 409) return 'conflict';
  if (status === 429) return 'rate_limited';
  if (status >= 500) return 'server';
  return 'bad_request';
}

/** Retry-After in seconds (or an HTTP date) to milliseconds, at most 30 s. null when absent or unreadable. */
export function parseRetryAfter(value: string | null | undefined, now: number = Date.now()): number | null {
  if (value == null) return null;
  const v = String(value).trim();
  if (v === '') return null;
  let ms: number;
  if (/^\d+(\.\d+)?$/.test(v)) {
    ms = Number(v) * 1000;
  } else {
    const t = Date.parse(v);
    if (Number.isNaN(t)) return null;
    ms = Math.max(0, t - now);
  }
  return Math.min(ms, MAX_RETRY_AFTER_MS);
}

/** Backoff for retry number n (0 based): 500 ms doubled each time. */
export function backoffMs(n: number): number {
  return BASE_BACKOFF_MS * 2 ** n;
}

function isHttpsUrl(value: unknown): boolean {
  // https scheme, a host, and no whitespace anywhere
  return typeof value === 'string' && /^https:\/\/[^\s/?#@]+(?:[/?#]\S*)?$/i.test(value);
}

/** Checks a submission before any request. Throws LaxJobsError invalid_input. */
export function validateSubmission(jobId: unknown, input: unknown): asserts input is SubmissionInput {
  const bad = (msg: string): never => {
    throw new LaxJobsError(msg, 0, 'invalid_input');
  };
  if (typeof jobId !== 'string' || jobId.trim() === '') bad('jobId is required');
  if (input == null || typeof input !== 'object') bad('submission input is required');
  const s = input as Record<string, unknown>;
  if (typeof s.agent_did !== 'string' || s.agent_did === '') bad('agent_did is required');
  if (!DID_RE.test(s.agent_did as string)) bad('agent_did must be a DID (did:method:identifier)');
  if (typeof s.payout_address !== 'string' || !ADDRESS_RE.test(s.payout_address)) {
    bad('payout_address must be 0x followed by 40 hex characters');
  }
  if (!isHttpsUrl(s.repo_or_patch_url)) bad('repo_or_patch_url must be an https link');
  if (typeof s.builder !== 'string' || s.builder.trim() === '') bad('builder is required');
  if (typeof s.contact !== 'string' || s.contact.trim() === '') bad('contact is required');
  if (s.notes !== undefined && typeof s.notes !== 'string') bad('notes must be a string');
}

function validateListParams(p: ListJobsParams): void {
  const bad = (msg: string): never => {
    throw new LaxJobsError(msg, 0, 'invalid_input');
  };
  if (p.limit !== undefined && (!Number.isInteger(p.limit) || p.limit < 1 || p.limit > 100)) {
    bad('limit must be an integer from 1 to 100');
  }
  if (p.offset !== undefined && (!Number.isInteger(p.offset) || p.offset < 0)) {
    bad('offset must be an integer of 0 or more');
  }
  if (p.track !== undefined && (typeof p.track !== 'string' || p.track === '')) bad('track must be a non empty string');
}

/** Query string with only the given parameters, in a stable order. */
export function buildListQuery(p: ListJobsParams = {}): string {
  const parts: string[] = [];
  if (p.track !== undefined) parts.push(`track=${encodeURIComponent(p.track)}`);
  if (p.limit !== undefined) parts.push(`limit=${p.limit}`);
  if (p.offset !== undefined) parts.push(`offset=${p.offset}`);
  return parts.length ? `?${parts.join('&')}` : '';
}

// ---------------------------------------------------------------------------
// Transport
// ---------------------------------------------------------------------------

interface Runtime {
  fetch?: FetchLike;
  setTimeout(fn: () => void, ms: number): unknown;
  clearTimeout(t: unknown): void;
  AbortController?: new () => { signal: unknown; abort(): void };
}
const rt = globalThis as unknown as Runtime;

const defaultSleep = (ms: number): Promise<void> => new Promise((resolve) => rt.setTimeout(resolve, ms));

type Attempt =
  | { kind: 'response'; status: number; body: unknown; text: string; retryAfter: string | null }
  | { kind: 'network'; message: string };

async function readBody(res: FetchResponseLike): Promise<{ body: unknown; text: string }> {
  if (typeof res.text === 'function') {
    const text = await res.text();
    if (text === '') return { body: undefined, text };
    try {
      return { body: JSON.parse(text), text };
    } catch {
      return { body: undefined, text };
    }
  }
  if (typeof res.json === 'function') {
    try {
      const body = await res.json();
      return { body, text: '' };
    } catch {
      return { body: undefined, text: '' };
    }
  }
  return { body: undefined, text: '' };
}

function registryMessage(body: unknown, text: string, status: number): string {
  if (body && typeof body === 'object') {
    const b = body as Record<string, unknown>;
    for (const k of ['error', 'message', 'detail']) {
      const v = b[k];
      if (typeof v === 'string' && v !== '') return v;
      if (v && typeof v === 'object' && typeof (v as Record<string, unknown>).message === 'string') {
        return (v as Record<string, string>).message as string;
      }
    }
  }
  if (text && text.length <= 500) return text;
  return `HTTP ${status}`;
}

// ---------------------------------------------------------------------------
// Client
// ---------------------------------------------------------------------------

export function createJobsClient(options: JobsClientOptions = {}): JobsClient {
  const baseUrl = (options.baseUrl ?? DEFAULT_JOBS_BASE_URL).replace(/\/+$/, '');
  const retries = Math.max(0, Math.floor(options.retries ?? DEFAULT_RETRIES));
  const sleep = options.sleep ?? defaultSleep;
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;

  function getFetch(): FetchLike {
    const f = options.fetch ?? (typeof rt.fetch === 'function' ? rt.fetch.bind(globalThis) : undefined);
    if (!f) throw new LaxJobsError('fetch is not available in this runtime', 0, 'network');
    return f;
  }

  async function attempt(url: string, init: FetchInitLike): Promise<Attempt> {
    const f = getFetch();
    const ctrl = rt.AbortController ? new rt.AbortController() : null;
    let timedOut = false;
    const timer = ctrl
      ? rt.setTimeout(() => {
          timedOut = true;
          ctrl.abort();
        }, timeoutMs)
      : null;
    try {
      const res = await f(url, ctrl ? { ...init, signal: ctrl.signal } : init);
      const { body, text } = await readBody(res);
      const retryAfter = res.headers && typeof res.headers.get === 'function' ? res.headers.get('retry-after') : null;
      return { kind: 'response', status: res.status, body, text, retryAfter };
    } catch (e) {
      const message = timedOut ? `request timed out after ${timeoutMs} ms` : e instanceof Error ? e.message : String(e);
      return { kind: 'network', message: message || 'network error' };
    } finally {
      if (timer !== null) rt.clearTimeout(timer);
    }
  }

  async function request(url: string, init: FetchInitLike, isRead: boolean): Promise<unknown> {
    for (let n = 0; ; n++) {
      const a = await attempt(url, init);
      const canRetry = n < retries;
      if (a.kind === 'network') {
        if (isRead && canRetry) {
          await sleep(backoffMs(n));
          continue;
        }
        throw new LaxJobsError(a.message, 0, 'network');
      }
      if (a.status >= 200 && a.status < 300) {
        if (a.body === undefined) throw new LaxJobsError('the registry answer is not JSON', a.status, 'invalid_response');
        return a.body;
      }
      const code = codeForStatus(a.status);
      const retryable = code === 'rate_limited' || (isRead && code === 'server');
      if (retryable && canRetry) {
        const wait = code === 'rate_limited' ? parseRetryAfter(a.retryAfter) : null;
        await sleep(wait ?? backoffMs(n));
        continue;
      }
      throw new LaxJobsError(registryMessage(a.body, a.text, a.status), a.status, code);
    }
  }

  function pick<T>(body: unknown, key: string, status = 200): T {
    if (body && typeof body === 'object' && key in (body as object)) {
      const v = (body as Record<string, unknown>)[key];
      if (v && typeof v === 'object') return v as T;
    }
    throw new LaxJobsError(`the registry answer has no ${key}`, status, 'invalid_response');
  }

  return {
    async listJobs(params: ListJobsParams = {}): Promise<ListJobsResult> {
      validateListParams(params);
      const body = await request(`${baseUrl}/jobs${buildListQuery(params)}`, { method: 'GET', headers: { accept: 'application/json' } }, true);
      const b = body as Record<string, unknown> | null;
      if (!b || !Array.isArray(b.jobs)) throw new LaxJobsError('the registry answer has no jobs list', 200, 'invalid_response');
      const count = typeof b.count === 'number' ? b.count : b.jobs.length;
      return { jobs: b.jobs as Job[], count };
    },

    async getJob(id: string): Promise<Job> {
      if (typeof id !== 'string' || id.trim() === '') throw new LaxJobsError('id is required', 0, 'invalid_input');
      const body = await request(`${baseUrl}/jobs/${encodeURIComponent(id)}`, { method: 'GET', headers: { accept: 'application/json' } }, true);
      return pick<Job>(body, 'job');
    },

    async submit(jobId: string, input: SubmissionInput): Promise<Submission> {
      validateSubmission(jobId, input);
      const payload: Record<string, string> = {
        builder: input.builder,
        contact: input.contact,
        repo_or_patch_url: input.repo_or_patch_url,
        payout_address: input.payout_address,
        agent_did: input.agent_did,
      };
      if (input.notes !== undefined) payload.notes = input.notes;
      const body = await request(
        `${baseUrl}/jobs/${encodeURIComponent(jobId)}/submissions`,
        { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(payload) },
        false,
      );
      return pick<Submission>(body, 'submission', 201);
    },
  };
}

agentgate-source: code mirror on Cloudflare Pages. All repos