import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { createRepo, uploadFiles } from "@huggingface/hub"; import { HF_HUB } from "./config"; import type { HardwareFlavor } from "./hardware"; import type { Workspace } from "./agent/workspace"; const execFileAsync = promisify(execFile); export interface DeployResult { url: string; repoId: string; hardware: string; hardwareRequested: boolean; method: "git" | "upload"; } export function spaceRemoteUrl(owner: string, spaceName: string, token: string): string { return `https://${encodeURIComponent(owner)}:${token}@huggingface.co/spaces/${owner}/${spaceName}`; } async function git(workspace: Workspace, args: string[]): Promise { const { stdout } = await execFileAsync("git", args, { cwd: workspace.dir, maxBuffer: 8_000_000, timeout: 120_000, }); return stdout; } async function requestHardware( repoId: string, token: string, hardware: HardwareFlavor, ): Promise { if (hardware === "cpu-basic") return false; try { const res = await fetch(`${HF_HUB}/api/spaces/${repoId}/hardware`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ flavor: hardware }), }); return res.ok; } catch { return false; } } /** Deploy the workspace to the user's Space. Ensures the repo exists, then * pushes via git (preferred — the agent can also `git push` manually), falling * back to the Hub upload API if git push fails. */ export async function deployWorkspace(opts: { accessToken: string; owner: string; spaceName: string; workspace: Workspace; isPrivate: boolean; hardware: HardwareFlavor; }): Promise { const repoId = `${opts.owner}/${opts.spaceName}`; const repo = { type: "space" as const, name: repoId }; try { await createRepo({ repo, accessToken: opts.accessToken, visibility: opts.isPrivate ? "private" : "public", sdk: "gradio", }); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (!/already (exists|created)/i.test(message)) throw err; } let method: "git" | "upload" = "git"; try { const remote = spaceRemoteUrl(opts.owner, opts.spaceName, opts.accessToken); await git(opts.workspace, ["remote", "remove", "space"]).catch(() => {}); await git(opts.workspace, ["remote", "add", "space", remote]); await git(opts.workspace, ["add", "-A"]); // Commit; ignore "nothing to commit" so re-deploys still push. await git(opts.workspace, [ "-c", "user.name=mfn", "-c", "user.email=mfn@example.com", "commit", "-m", "Deploy via SpaceFactory", ]).catch(() => {}); await git(opts.workspace, ["push", "--force", "space", "HEAD:main"]); } catch { // Fallback: Hub upload API. method = "upload"; await uploadFiles({ repo, accessToken: opts.accessToken, files: opts.workspace.toFiles().map((f) => ({ path: f.path, content: new Blob([f.content]), })), commitTitle: "Deploy via SpaceFactory", }); } const hardwareRequested = await requestHardware( repoId, opts.accessToken, opts.hardware, ); return { url: `${HF_HUB}/spaces/${repoId}`, repoId, hardware: opts.hardware, hardwareRequested, method, }; } export interface SpaceRuntime { stage: string; hardware?: string; } export async function getSpaceRuntime( repoId: string, token: string, ): Promise { const res = await fetch(`${HF_HUB}/api/spaces/${repoId}`, { headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) throw new Error(`Could not read Space ${repoId} (HTTP ${res.status})`); const data = (await res.json()) as { runtime?: { stage?: string; hardware?: { current?: string } }; }; return { stage: data.runtime?.stage ?? "UNKNOWN", hardware: data.runtime?.hardware?.current, }; } export async function fetchSpaceLogs( repoId: string, token: string, kind: "build" | "run", maxLines = 120, ): Promise { const res = await fetch(`${HF_HUB}/api/spaces/${repoId}/logs/${kind}`, { headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream" }, signal: AbortSignal.timeout(30_000), }); if (!res.ok) throw new Error(`Could not fetch ${kind} logs (HTTP ${res.status})`); const text = await res.text(); const lines: string[] = []; for (const line of text.split("\n")) { if (!line.startsWith("data:")) continue; try { const data = JSON.parse(line.slice(5).trim()) as { data?: string }; if (data.data != null) lines.push(String(data.data).replace(/\n$/, "")); } catch { // ignore keepalives / partial frames } } return lines.slice(-maxLines).join("\n") || "(no logs yet)"; } const TERMINAL_STAGES = new Set([ "RUNNING", "BUILD_ERROR", "RUNTIME_ERROR", "CONFIG_ERROR", "ERROR", "APP_STARTING_ERROR", "DELETING", "PAUSED", ]); export function isTerminalStage(stage: string): boolean { return TERMINAL_STAGES.has(stage); }