/**
* 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