velxio/frontend/src/services/compilation.ts

222 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import axios from 'axios';
import { getApiBase } from '../lib/apiBase';
import type { ESP32BoardOptions, SpiffsFile } from '../types/boardOptions';
export interface SketchFile {
name: string;
content: string;
}
/**
* Per-board build options forwarded to the backend. Only meaningful for
* ESP32 targets — `board_options` is structurally translated into sdkconfig
* knobs and a generated partitions.csv by the ESP-IDF compiler. `spiffs_files`
* (if non-empty) are baked into a SPIFFS partition image via `mkspiffs`.
*/
export interface CompileExtras {
boardOptions?: ESP32BoardOptions;
spiffsFiles?: SpiffsFile[];
// P2.3 — declared library manifest (the loaded example/project's libraries).
// Sent as the ESP-IDF resolution SCOPE; null/omitted = legacy scan-all.
// Ignored by the backend for non-ESP32 (arduino-cli) boards.
libraries?: string[] | null;
// Pure ESP-IDF mode (issue #139): 'espidf' compiles the files as a pure
// ESP-IDF project (user app_main, no arduino-esp32 component). Omitted /
// undefined = classic Arduino sketch compile. ESP32 boards only.
language?: 'espidf';
// Who triggered the compile — 'agent' when the AI assistant's tool did.
// Threads through to backend metrics; omitted = manual user action.
initiatedBy?: 'agent';
// The editor's BoardKind — analytics only. Distinct boards can share one
// FQBN (Pimoroni RP2350 boards all compile as rpipico2); the kind is the
// only identifier that tells them apart in the metrics.
boardKind?: string;
}
export interface CompileResult {
success: boolean;
hex_content?: string;
binary_content?: string; // base64-encoded .bin for RP2040
binary_type?: 'bin' | 'uf2';
has_wifi?: boolean; // True when sketch uses WiFi (ESP32 only)
stdout: string;
stderr: string;
error?: string;
core_install_log?: string;
}
interface CompileStartResponse {
job_id: string;
}
interface CompileStatusResponse {
state: 'pending' | 'running' | 'done' | 'error';
started_at: number;
finished_at: number | null;
stdout: string;
result: CompileResult | null;
error: string | null;
}
/**
* Live progress callback — called on every poll while state ∈ {pending,
* running}, plus one final call with state 'done' carrying the complete
* buffer (the lines that landed between the last running-poll and job
* completion would otherwise never be delivered). `stdout` is the full live
* cmake + ninja output captured so far (cap of ~256 KB on the server side,
* tail kept). Caller can compute a delta against the previous call if it
* wants to append-only render.
*/
export type CompileProgress = (info: {
state: 'pending' | 'running' | 'done';
stdout: string;
elapsedSeconds: number;
}) => void;
const POLL_INTERVAL_MS = 2000;
const MAX_POLL_DURATION_MS = 15 * 60 * 1000; // 15 minutes — covers cold ESP-IDF builds
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/**
* Compile a sketch via the async job pipeline.
*
* POST /compile/start → { job_id }
* GET /compile/status/<job_id> (×N) → { state, stdout, result?, error? }
*
* Each individual request returns in milliseconds, so Cloudflare's 100s edge
* timeout never kicks in — even when the underlying ESP-IDF cold build runs
* for 5-7 minutes. Falls back to throwing an Error after MAX_POLL_DURATION_MS.
*
* `onProgress` (optional): called every poll with the live cmake + ninja
* output so the editor can stream the compilation console instead of
* waiting for everything at the end.
*/
export async function compileCode(
files: SketchFile[],
board: string = 'arduino:avr:uno',
projectId?: string | null,
onProgress?: CompileProgress,
extras?: CompileExtras,
): Promise<CompileResult> {
console.log('Sending compilation request to:', `${getApiBase()}/compile/start`);
console.log('Board:', board);
console.log(
'Files:',
files.map((f) => f.name),
);
// Translate camelCase frontend keys to snake_case backend keys. Backend
// only inspects these fields for esp32:* FQBNs — other boards pass them
// through unread.
const board_options = extras?.boardOptions ? { ...extras.boardOptions } : null;
const spiffs_files = extras?.spiffsFiles?.length
? extras.spiffsFiles.map((f) => ({ name: f.name, content_b64: f.contentB64 }))
: null;
// P2.3 — library manifest (resolution scope). null = legacy scan-all.
const libraries = extras?.libraries && extras.libraries.length ? extras.libraries : null;
let jobId: string;
try {
const startResp = await axios.post<CompileStartResponse>(
`${getApiBase()}/compile/start`,
{
files,
board_fqbn: board,
project_id: projectId ?? null,
board_options,
spiffs_files,
board_kind: extras?.boardKind ?? null,
libraries,
language: extras?.language ?? null,
initiated_by: extras?.initiatedBy ?? null,
},
{ withCredentials: true, timeout: 30000 },
);
jobId = startResp.data.job_id;
console.log('[compile] queued job', jobId);
} catch (error) {
console.error('Compilation request failed:', error);
if (axios.isAxiosError(error) && error.response) {
// Server returned a structured error (422, 500, etc.) — surface as a
// failed CompileResult so the editor can show stderr/error.
return error.response.data as CompileResult;
}
throw error instanceof Error
? error
: new Error('No response from server. Is the backend running?');
}
const startedAt = Date.now();
// Initial small delay so we don't hit /status before the background task
// has even moved past 'pending'.
await sleep(500);
while (true) {
if (Date.now() - startedAt > MAX_POLL_DURATION_MS) {
throw new Error(
`Compile timed out client-side after ${Math.round(MAX_POLL_DURATION_MS / 1000)}s`,
);
}
let status: CompileStatusResponse;
try {
const resp = await axios.get<CompileStatusResponse>(
`${getApiBase()}/compile/status/${jobId}`,
{ withCredentials: true, timeout: 30000 },
);
status = resp.data;
} catch (error) {
// Transient poll error — log, wait, retry. Only abort on 404 (job
// expired or never existed).
if (axios.isAxiosError(error) && error.response?.status === 404) {
throw new Error(`Compile job ${jobId} not found (server may have restarted)`);
}
console.warn('[compile] status poll error, retrying:', error);
await sleep(POLL_INTERVAL_MS);
continue;
}
if (status.state === 'done' && status.result) {
const elapsed = Math.round((Date.now() - startedAt) / 1000);
console.log(`[compile] job ${jobId} done in ${elapsed}s`);
// Final flush: the buffer grew between the last running-poll and
// completion (esptool + binary-size lines usually live there).
if (onProgress) {
try {
onProgress({ state: 'done', stdout: status.stdout || '', elapsedSeconds: elapsed });
} catch (err) {
console.warn('[compile] onProgress threw:', err);
}
}
return status.result;
}
if (status.state === 'error') {
console.error(`[compile] job ${jobId} errored:`, status.error);
return {
success: false,
stdout: status.stdout || '',
stderr: '',
error: status.error || 'Compile failed',
};
}
// state ∈ {pending, running} — surface live build output if requested
if (onProgress) {
try {
onProgress({
state: status.state,
stdout: status.stdout || '',
elapsedSeconds: Math.round((Date.now() - startedAt) / 1000),
});
} catch (err) {
// A faulty UI hook must never break the polling loop.
console.warn('[compile] onProgress threw:', err);
}
}
await sleep(POLL_INTERVAL_MS);
}
}