/** * Cloud HTTP client. * * Provides authenticated access to the maina cloud API for prompt sync, * team management, and feedback reporting. All methods return Result. */ import type { Result } from "../db/index"; import type { ApiResponse, CloudConfig, CloudEpisodicEntry, CloudFeedbackPayload, CloudPromptImprovement, EpisodicCloudEntry, FeedbackEvent, FeedbackImprovementsResponse, ProfileUpdatePayload, ProfileUpdateResponse, PromptRecord, SubmitVerifyPayload, TeamInfo, TeamMember, VerifyResultResponse, VerifyStatusResponse, } from "./types"; // ── Helpers ───────────────────────────────────────────────────────────────── function ok(value: T): Result { return { ok: true, value }; } function err(error: string): Result { return { ok: false, error }; } const DEFAULT_TIMEOUT_MS = 10_000; const DEFAULT_MAX_RETRIES = 3; const INITIAL_BACKOFF_MS = 500; /** Status codes that trigger a retry. */ function isRetryable(status: number): boolean { return status === 429 || status >= 500; } /** Title-case a machine-readable plan id for display. `free` → `Free`. */ function titleCasePlan(plan: string): string { if (!plan) return "Free"; return plan.charAt(0).toUpperCase() + plan.slice(1); } /** * Sleep for the given number of milliseconds. * Extracted so tests can verify backoff behaviour. */ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } // ── Cloud Client ──────────────────────────────────────────────────────────── export interface CloudClient { /** Check API availability. */ health(): Promise>; /** Update user profile (email, name) during onboarding. */ updateProfile( payload: ProfileUpdatePayload, ): Promise>; /** Download team prompts. */ getPrompts(): Promise>; /** Upload local prompts. */ putPrompts(prompts: PromptRecord[]): Promise>; /** Fetch team information. */ getTeam(): Promise>; /** List team members. */ getTeamMembers(): Promise>; /** Invite a new member by email. */ inviteTeamMember( email: string, role?: "admin" | "member", ): Promise>; /** Report prompt feedback to the cloud. */ postFeedback( payload: CloudFeedbackPayload, ): Promise>; /** Upload a batch of feedback events to the cloud. */ postFeedbackBatch( events: FeedbackEvent[], ): Promise>; /** Fetch feedback-based improvement suggestions from the cloud. */ getFeedbackImprovements(): Promise< Result >; /** Post workflow stats to cloud analytics. */ postWorkflowStats(stats: { totalCommits: number; totalVerifyTimeMs: number; avgVerifyTimeMs: number; totalFindings: number; totalContextTokens: number; cacheHitRate: number; passRate: number; }): Promise>; /** Upload episodic entries to the cloud for team sharing. */ postEpisodicEntries( entries: EpisodicCloudEntry[], ): Promise>; /** Fetch team's episodic entries for a repo from the cloud. */ getEpisodicEntries( repo: string, ): Promise>; /** Submit a diff for cloud verification. */ submitVerify( payload: SubmitVerifyPayload, ): Promise>; /** Poll the status of a verification job. */ getVerifyStatus(jobId: string): Promise>; /** Retrieve the full result of a completed verification job. */ getVerifyResult(jobId: string): Promise>; } /** * Create a cloud API client. * * Every request attaches `Authorization: Bearer ` when a token is * present in the config. Transient failures (429, 5xx) are retried up to * `maxRetries` times with exponential backoff. */ export function createCloudClient(config: CloudConfig): CloudClient { const { baseUrl, token, timeoutMs = DEFAULT_TIMEOUT_MS, maxRetries = DEFAULT_MAX_RETRIES, } = config; /** Build standard headers. */ function headers(): Record { const h: Record = { "Content-Type": "application/json", Accept: "application/json", }; if (token) { h.Authorization = `Bearer ${token}`; } return h; } /** Make an HTTP request with retry + timeout. */ async function request( method: string, path: string, body?: unknown, ): Promise> { let lastError = "Unknown error"; for (let attempt = 0; attempt <= maxRetries; attempt++) { if (attempt > 0) { const backoff = INITIAL_BACKOFF_MS * 2 ** (attempt - 1); await sleep(backoff); } try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); const response = await fetch(`${baseUrl}${path}`, { method, headers: headers(), body: body ? JSON.stringify(body) : undefined, signal: controller.signal, }); clearTimeout(timer); if (isRetryable(response.status) && attempt < maxRetries) { lastError = `HTTP ${response.status}`; continue; } if (!response.ok) { const text = await response.text(); let message: string; try { const parsed = JSON.parse(text) as ApiResponse; message = parsed.error ?? `HTTP ${response.status}`; } catch { message = text || `HTTP ${response.status}`; } return err(message); } // 204 No Content — return void-compatible if (response.status === 204) { return ok(undefined as unknown as T); } const json = (await response.json()) as ApiResponse; if (json.error) { return err(json.error); } return ok(json.data as T); } catch (e) { if ( e instanceof DOMException && e.name === "AbortError" && attempt < maxRetries ) { lastError = "Request timed out"; continue; } lastError = e instanceof Error ? e.message : String(e); // Transient network errors are retried via the outer loop } } return err(`Request failed after ${maxRetries + 1} attempts: ${lastError}`); } return { health: () => request<{ status: string }>("GET", "/health"), updateProfile: async (payload) => { // biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping const result = await request("PATCH", "/auth/profile", payload); if (!result.ok) return result; const d = result.value; return ok({ email: d.email, name: d.name, isOnboarded: d.isOnboarded ?? d.is_onboarded ?? false, }); }, getPrompts: async () => { const result = await request("GET", "/prompts"); if (!result.ok) return result; return ok(Array.isArray(result.value) ? result.value : []); }, putPrompts: async (prompts) => { for (const p of prompts) { const result = await request("PUT", "/prompts", { name: p.id ?? p.path?.replace(/\.md$/, "") ?? "unknown", content: p.content, hash: p.hash, }); if (!result.ok) return result; } return { ok: true as const, value: undefined }; }, getTeam: async () => { // biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping const result = await request("GET", "/team"); if (!result.ok) return result; const d = result.value ?? {}; const plan = d.plan ?? "free"; // Prefer an explicit server-supplied label; otherwise derive one from // the machine-readable id so older / partial responses still render a // human-friendly `Plan: Free` instead of `Plan: free` in the CLI. const serverLabel = d.planDisplay ?? d.plan_display; const planDisplay = typeof serverLabel === "string" && serverLabel.length > 0 ? serverLabel : titleCasePlan(plan); return ok({ id: d.id, name: d.name, plan, planDisplay, seats: d.seats ?? { used: 0, total: 0 }, } satisfies TeamInfo); }, getTeamMembers: () => request("GET", "/team/members"), inviteTeamMember: (email, role = "member") => request<{ invited: boolean }>("POST", "/team/invite", { email, role }), postFeedback: (payload) => request<{ recorded: boolean }>("POST", "/feedback", payload), postFeedbackBatch: async (events) => { // Map camelCase → snake_case for cloud API const snakeEvents = events.map((e) => ({ prompt_hash: e.promptHash, command: e.command, accepted: e.accepted, context: e.context, diff_hash: e.diffHash, timestamp: e.timestamp, })); return request<{ received: number }>("POST", "/feedback/batch", { events: snakeEvents, }); }, getFeedbackImprovements: async () => { // biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping const result = await request("GET", "/feedback/improvements"); if (!result.ok) return result; const d = result.value; const rawImprovements = d.improvements ?? []; const improvements: CloudPromptImprovement[] = rawImprovements.map( // biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping (i: any) => ({ command: i.command, promptHash: i.promptHash ?? i.prompt_hash, samples: i.samples, acceptRate: i.acceptRate ?? i.accept_rate, status: i.status, }), ); const totals = d.teamTotals ?? d.team_totals ?? {}; return ok({ improvements, teamTotals: { totalEvents: totals.totalEvents ?? totals.total_events ?? 0, acceptRate: totals.acceptRate ?? totals.accept_rate ?? 0, }, }); }, postEpisodicEntries: async (entries) => { // Map camelCase → snake_case for cloud API const snakeEntries = entries.map((e) => ({ repo: e.repo, entry_type: e.entryType, title: e.title, summary: e.summary, relevance_score: e.relevanceScore, })); return request<{ received: number }>("POST", "/context/episodic", { entries: snakeEntries, }); }, getEpisodicEntries: async (repo) => { // biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping const result = await request( "GET", `/context/episodic?repo=${encodeURIComponent(repo)}`, ); if (!result.ok) return result; const d = result.value; const rawEntries = d.entries ?? []; const mapped: CloudEpisodicEntry[] = rawEntries.map( // biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping (e: any) => ({ id: e.id, repo: e.repo, entryType: e.entryType ?? e.entry_type, title: e.title, summary: e.summary, relevanceScore: e.relevanceScore ?? e.relevance_score ?? 1.0, memberId: e.memberId ?? e.member_id, decayFactor: e.decayFactor ?? e.decay_factor ?? 1.0, createdAt: e.createdAt ?? e.created_at, accessedAt: e.accessedAt ?? e.accessed_at, }), ); return ok(mapped); }, postWorkflowStats: (stats) => request<{ received: boolean }>("POST", "/feedback/stats", { total_commits: stats.totalCommits, total_verify_time_ms: stats.totalVerifyTimeMs, avg_verify_time_ms: stats.avgVerifyTimeMs, total_findings: stats.totalFindings, total_context_tokens: stats.totalContextTokens, cache_hit_rate: stats.cacheHitRate, pass_rate: stats.passRate, }), submitVerify: async (payload) => { // biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping const result = await request("POST", "/verify", { diff: payload.diff, repo: payload.repo, base_branch: payload.baseBranch, }); if (!result.ok) return result; const d = result.value; return ok({ jobId: d.jobId ?? d.job_id }); }, getVerifyStatus: async (jobId) => { // biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping const result = await request("GET", `/verify/${jobId}/status`); if (!result.ok) return result; const d = result.value; return ok({ status: d.status, currentStep: d.currentStep ?? d.current_step ?? d.step ?? "", }); }, getVerifyResult: async (jobId) => { // biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping const result = await request("GET", `/verify/${jobId}`); if (!result.ok) return result; const d = result.value; const items = d.findings?.items ?? d.findings ?? []; return ok({ id: d.id, status: d.status, passed: d.passed, findings: items, findingsErrors: d.findingsErrors ?? d.findings_errors ?? d.findings?.errors ?? 0, findingsWarnings: d.findingsWarnings ?? d.findings_warnings ?? d.findings?.warnings ?? 0, proofKey: d.proofKey ?? d.proof_key ?? d.proof_url ?? null, durationMs: d.durationMs ?? d.duration_ms ?? 0, }); }, }; }