/** * madoo-runtime.ts — the official, dependency-free TypeScript helper for running a Madoo workflow * and reading its result the easy way. * * It calls the canonical result surface, `GET /api/v1/executions/{id}/result`, ONLY — it never reads * or parses the raw `/outputs` list. Results come back indexed by key, with text/JSON already parsed * (including JSON produced by a `text` node), so the classic "I read `value` and got null" bug is gone. * * const client = madooClient({ * baseUrl: "https://api.madoo.ai", * token: madooClientCredentials("https://api.madoo.ai", CLIENT_ID, CLIENT_SECRET), * }); * * const result = await runWorkflowAndGetResult(client, { * workflow: "wf_…", * inputs: { product_description: inlineValue("A hand-poured soy candle") }, * }); * * const copy = result.result.by_key.newsletter_copy?.value; // already an object/string * const hero = result.result.by_key.hero_image?.url; // a download URL * * Copy this file into your project. It is a thin client over the public API — it does not reimplement * any normalization; the server does that. Targets any runtime with global `fetch` (Node 18+, Deno, * browsers — though you should NOT ship your client secret to a browser). * * See: docs/public-api/04-executions.md §6 and docs/public-api/03-workflows.md §6. */ // ───────────────────────────────────────────────────────────────────────────── // Wire types (the shape of GET /api/v1/executions/{id}/result) // ───────────────────────────────────────────────────────────────────────────── export type ExecutionStatus = | "pending" | "running" | "cancelling" | "completed" | "partial_success" | "failed" | "cancelled"; export type OutputState = "present" | "absent" | "missing" | "not_ready"; export type OutputDelivery = "inline" | "resolved_text" | "asset" | "absent" | "unavailable"; export type ParseStatus = "parsed" | "failed" | "not_attempted" | "too_large"; export type ParseFormat = "json" | "text" | null; export interface ExecutionResultParse { status: ParseStatus; format: ParseFormat; strategy: "declared_json" | "opportunistic_text_json" | "none"; } export interface ExecutionResultSource { name?: string | null; logical_name?: string | null; node_id?: string | null; } export interface ExecutionResultItem { key: string; interface_key?: string | null; logical_name?: string | null; type?: string | null; presence?: "present" | "absent" | null; state: OutputState; delivery: OutputDelivery; content_type?: string | null; size_bytes?: number | null; /** Parsed JSON object, or a string for prose; null for binary/absent. */ value?: unknown; raw_value?: string | null; url?: string | null; thumbnail_url?: string | null; parse: ExecutionResultParse; source: ExecutionResultSource; } export interface ExecutionResultGroup { key: string; cardinality: "single" | "multiple"; presence?: "present" | "absent" | null; state: OutputState; /** Group-level shortcut (single cardinality only): parsed JSON object or a string. */ value?: unknown; /** Group-level shortcut (single cardinality only). */ url?: string | null; items: ExecutionResultItem[]; } export interface ExecutionResultDiagnostics { message: string; lookup_rule: string; expected_output_keys: string[]; available_output_keys: string[]; warnings: string[]; } export interface ExecutionResult { execution_id: string; workflow_id: string; status: ExecutionStatus; is_terminal: boolean; next_poll_after_ms?: number | null; /** Credits the whole run consumed; present only once terminal. */ credits_used?: number; result: { by_key: Record; items: ExecutionResultItem[]; }; diagnostics: ExecutionResultDiagnostics; } // ───────────────────────────────────────────────────────────────────────────── // Client // ───────────────────────────────────────────────────────────────────────────── /** A bearer token, or a (cached) async provider of one. */ export type TokenSource = string | (() => Promise); export interface MadooClient { baseUrl: string; token: TokenSource; } export function madooClient(config: MadooClient): MadooClient { return { baseUrl: trimSlash(config.baseUrl), token: config.token }; } /** Raised for any non-2xx Madoo response. Carries the parsed error body when there is one. */ export class MadooError extends Error { constructor( public readonly status: number, message: string, public readonly body?: unknown, ) { super(message); this.name = "MadooError"; } } /** * Builds a cached OAuth2 client-credentials token provider. Keep the secret server-side. * Reuses the token until ~30s before expiry. */ export function madooClientCredentials( baseUrl: string, clientId: string, clientSecret: string, ): () => Promise { let cache: { token: string; expiresAt: number } | null = null; const base = trimSlash(baseUrl); return async () => { const now = Date.now(); if (cache && cache.expiresAt > now + 30_000) return cache.token; const basic = base64(`${clientId}:${clientSecret}`); const res = await fetch(`${base}/api/v1/auth/token`, { method: "POST", headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ grant_type: "client_credentials" }), }); const json = await readJson(res); if (!res.ok) throw new MadooError(res.status, `Madoo auth failed`, json); cache = { token: json.access_token as string, expiresAt: now + ((json.expires_in ?? 300) as number) * 1000, }; return cache.token; }; } // ───────────────────────────────────────────────────────────────────────────── // Input encoding helpers // ───────────────────────────────────────────────────────────────────────────── /** An execution input value: an inline JSON-encoded scalar, or an uploaded asset path. */ export type ExecutionInput = { value: string } | { asset_path: string }; /** * Encodes an inline value for `inputs`. The API's `value` is a JSON-encoded STRING, not the raw value: * text keeps its quotes (`"\"hello\""`), numbers/booleans are bare (`"5"`, `"true"`). This wraps that. */ export function inlineValue(raw: unknown): { value: string } { return { value: JSON.stringify(raw) }; } /** References a previously uploaded asset (from POST /api/v1/assets) for a file input. */ export function assetInput(assetPath: string): { asset_path: string } { return { asset_path: assetPath }; } // ───────────────────────────────────────────────────────────────────────────── // Core operations (all read via /result — never the raw /outputs list) // ───────────────────────────────────────────────────────────────────────────── export interface RunWorkflowArgs { workflow: string; inputs?: Record; /** Pin a specific published version; omit for the latest. */ version?: number; /** A custom interface id; omit for the default interface. */ interface?: string; /** Propagated through the pipeline and echoed back; useful for tracing. */ correlationId?: string; } /** Submits a run and returns its execution id immediately (execution is asynchronous). */ export async function runWorkflow(client: MadooClient, args: RunWorkflowArgs): Promise { const body: Record = { workflow: args.workflow }; if (args.inputs) body.inputs = args.inputs; if (args.version != null) body.version = args.version; if (args.interface) body.interface = args.interface; const res = await authedFetch(client, `/api/v1/executions`, { method: "POST", headers: { "Content-Type": "application/json", ...(args.correlationId ? { "X-Correlation-ID": args.correlationId } : {}), }, body: JSON.stringify(body), }); const json = await readJson(res); if (!res.ok) throw new MadooError(res.status, `Madoo run failed`, json); return json.id as string; } /** Reads the canonical result once. Returns 200 whether the run is terminal or still in progress. */ export async function getExecutionResult(client: MadooClient, executionId: string): Promise { const res = await authedFetch(client, `/api/v1/executions/${executionId}/result`); const json = await readJson(res); if (!res.ok) throw new MadooError(res.status, `Madoo result failed`, json); return json as ExecutionResult; } export interface WaitOptions { timeoutMs?: number; /** Override the poll interval; by default the server's `next_poll_after_ms` hint is used. */ intervalMs?: number; } /** * Polls `/result` until the run is terminal, then returns the terminal result. Uses the server's * `next_poll_after_ms` hint between polls (falls back to 2s). Throws on timeout. */ export async function waitForExecution( client: MadooClient, executionId: string, options: WaitOptions = {}, ): Promise { const timeoutMs = options.timeoutMs ?? 120_000; const start = Date.now(); while (Date.now() - start < timeoutMs) { const result = await getExecutionResult(client, executionId); if (result.is_terminal) return result; const wait = options.intervalMs ?? result.next_poll_after_ms ?? 2000; await sleep(wait); } throw new MadooError(408, `Timed out waiting for ${executionId} after ${timeoutMs}ms`); } /** Runs a workflow and waits for the terminal result in one call. */ export async function runWorkflowAndGetResult( client: MadooClient, args: RunWorkflowArgs, options: WaitOptions = {}, ): Promise { const executionId = await runWorkflow(client, args); return waitForExecution(client, executionId, options); } // ───────────────────────────────────────────────────────────────────────────── // Reading helpers — direct lookups on result.by_key, no JSON.parse, no name matching // ───────────────────────────────────────────────────────────────────────────── /** * Reads a text/JSON output by key — already parsed (an object for JSON, a string for prose). * Returns `undefined` when the key is absent/missing/not produced. Branches on `parse` so you never * have to guess whether `value` is an object or a string. */ export function readValue(result: ExecutionResult, key: string): T | undefined { const group = result.result.by_key[key]; if (!group || group.state !== "present") return undefined; return group.value as T; } /** Reads a binary output's download URL by key (image/video/audio/3D). Undefined when not present. */ export function readUrl(result: ExecutionResult, key: string): string | undefined { const group = result.result.by_key[key]; if (!group || group.state !== "present") return undefined; return group.url ?? undefined; } /** * Throws a helpful error if a run did not finish successfully or an expected key is missing — the * message carries `available_output_keys` so the caller can self-correct. */ export function assertOutput(result: ExecutionResult, key: string): ExecutionResultGroup { const group = result.result.by_key[key]; if (!group || group.state !== "present") { const available = result.diagnostics.available_output_keys.join(", ") || "(none)"; throw new MadooError( 404, `Output "${key}" is ${group?.state ?? "not present"} (status=${result.status}). ` + `Available output keys: ${available}.`, result.diagnostics, ); } return group; } // ───────────────────────────────────────────────────────────────────────────── // Internals // ───────────────────────────────────────────────────────────────────────────── async function authedFetch(client: MadooClient, path: string, init: RequestInit = {}): Promise { const token = typeof client.token === "string" ? client.token : await client.token(); return fetch(`${client.baseUrl}${path}`, { ...init, headers: { Authorization: `Bearer ${token}`, ...(init.headers ?? {}) }, }); } async function readJson(res: Response): Promise { const text = await res.text(); if (!text) return {}; try { return JSON.parse(text); } catch { return { raw: text }; } } function trimSlash(value: string): string { return value.replace(/\/+$/, ""); } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function base64(value: string): string { // btoa in browsers/Deno; Buffer in Node. if (typeof btoa === "function") return btoa(value); // eslint-disable-next-line @typescript-eslint/no-explicit-any return (globalThis as any).Buffer.from(value, "utf-8").toString("base64"); }