diff --git a/packages/hub/README.md b/packages/hub/README.md index f0b48d7144..ddfee3e338 100644 --- a/packages/hub/README.md +++ b/packages/hub/README.md @@ -139,6 +139,8 @@ hfjs upload --repo-type dataset coyotte508/test-dataset . --revision release hfjs --help hfjs upload --help + +hfjs help jobs ``` ## OAuth Login diff --git a/packages/hub/cli.ts b/packages/hub/cli.ts index 9559ed707b..1bef2d240c 100644 --- a/packages/hub/cli.ts +++ b/packages/hub/cli.ts @@ -2,7 +2,21 @@ import { parseArgs } from "node:util"; import { typedEntries } from "./src/utils/typedEntries"; -import { createBranch, createRepo, deleteBranch, deleteRepo, repoExists, uploadFilesWithProgress } from "./src"; +import { + createBranch, + createRepo, + deleteBranch, + deleteRepo, + getJob, + listJobHardware, + listJobs, + repoExists, + runJob, + streamJobLogs, + uploadFilesWithProgress, + whoAmI, + type SpaceHardwareFlavor, +} from "./src"; import { pathToFileURL } from "node:url"; import { stat } from "node:fs/promises"; import { basename, join } from "node:path"; @@ -137,6 +151,7 @@ interface ArgDef { name: string; short?: string; positional?: boolean; + multiple?: boolean; description?: string; required?: boolean; boolean?: boolean; @@ -324,6 +339,121 @@ const commands = { description: "Print the version of the CLI", args: [] as const, } satisfies SingleCommand, + jobs: { + description: "Manage jobs on the Hub", + subcommands: { + run: { + description: "Run a new job", + args: [ + { + name: "docker-image-or-space" as const, + description: + "The Docker image to run (e.g., python:3.12) or Space ID (e.g., hf.co/spaces/username/space-name or username/space-name)", + positional: true, + required: true, + }, + { + name: "command" as const, + description: + "The command to run (can be multiple arguments preceded by --, e.g., -- python -c 'import os; print(os.environ[\"FOO\"])')", + positional: true, + multiple: true, + }, + { + name: "env" as const, + short: "e", + multiple: true, + description: "Environment variable in the format KEY=VALUE (can be specified multiple times)", + }, + { + name: "secret" as const, + short: "s", + multiple: true, + description: + "Secret in the format KEY=VALUE (will be encrypted server-side, can be specified multiple times)", + }, + { + name: "flavor" as const, + description: "Hardware flavor to use (defaults to cpu-basic)", + default: "cpu-basic", + }, + { + name: "attempts" as const, + description: "Maximum number of attempts (defaults to 1)", + }, + { + name: "namespace" as const, + description: "The namespace (username or organization name). Defaults to the current user's username.", + }, + { + name: "token" as const, + description: + "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", + default: process.env.HF_TOKEN, + }, + { + name: "detach" as const, + short: "d", + description: "Don't stream logs after creating the job", + boolean: true, + }, + ] as const, + }, + ps: { + description: "List jobs", + args: [ + { + name: "all" as const, + short: "a", + description: "List all jobs (not just running ones)", + boolean: true, + }, + { + name: "namespace" as const, + description: "The namespace (username or organization name). Defaults to the current user's username.", + }, + { + name: "token" as const, + description: + "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", + default: process.env.HF_TOKEN, + }, + ] as const, + }, + hardware: { + description: "List available hardware options for jobs", + args: [ + { + name: "token" as const, + description: + "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", + default: process.env.HF_TOKEN, + }, + ] as const, + }, + logs: { + description: "Show logs for a job", + args: [ + { + name: "job-id" as const, + description: "The job ID", + positional: true, + required: true, + }, + { + name: "namespace" as const, + description: "The namespace (username or organization name). Defaults to the current user's username.", + }, + { + name: "token" as const, + description: + "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", + default: process.env.HF_TOKEN, + }, + ] as const, + }, + }, + } satisfies CommandGroup, } satisfies Record; type TopLevelCommandName = keyof typeof commands; @@ -478,6 +608,13 @@ async function run() { const branchCommandGroup = commands.branch; const currentSubCommandName = subCommandName as keyof typeof branchCommandGroup.subcommands | undefined; + // Check if --help is in subcommand position (e.g., "hfjs branch --help") + if (subCommandName === "--help" || subCommandName === "-h") { + console.log(listSubcommands("branch", branchCommandGroup)); + break; + } + + // Check if --help is in args position (e.g., "hfjs branch create --help") if (cliArgs[0] === "--help" || cliArgs[0] === "-h") { if (currentSubCommandName && branchCommandGroup.subcommands[currentSubCommandName]) { console.log(detailedUsageForSubcommand("branch", currentSubCommandName)); @@ -539,6 +676,13 @@ async function run() { const repoCommandGroup = commands.repo; const currentSubCommandName = subCommandName as keyof typeof repoCommandGroup.subcommands | undefined; + // Check if --help is in subcommand position (e.g., "hfjs repo --help") + if (subCommandName === "--help" || subCommandName === "-h") { + console.log(listSubcommands("repo", repoCommandGroup)); + break; + } + + // Check if --help is in args position (e.g., "hfjs repo delete --help") if (cliArgs[0] === "--help" || cliArgs[0] === "-h") { if (currentSubCommandName && repoCommandGroup.subcommands[currentSubCommandName]) { console.log(detailedUsageForSubcommand("repo", currentSubCommandName)); @@ -591,6 +735,296 @@ async function run() { console.log(`hfjs version: ${version}`); break; } + case "jobs": { + const jobsCommandGroup = commands.jobs; + const currentSubCommandName = subCommandName as keyof typeof jobsCommandGroup.subcommands | undefined; + + // Check if --help is in subcommand position (e.g., "hfjs jobs --help") + if (subCommandName === "--help" || subCommandName === "-h") { + console.log(listSubcommands("jobs", jobsCommandGroup)); + break; + } + + // Check if --help is in args position (e.g., "hfjs jobs run --help") + if (cliArgs[0] === "--help" || cliArgs[0] === "-h") { + if (currentSubCommandName && jobsCommandGroup.subcommands[currentSubCommandName]) { + console.log(detailedUsageForSubcommand("jobs", currentSubCommandName)); + } else { + console.log(listSubcommands("jobs", jobsCommandGroup)); + } + break; + } + + if (!currentSubCommandName || !jobsCommandGroup.subcommands[currentSubCommandName]) { + console.error(`Error: Missing or invalid subcommand for 'jobs'.`); + console.log(listSubcommands("jobs", jobsCommandGroup)); + process.exitCode = 1; + break; + } + + const subCmdDef = jobsCommandGroup.subcommands[currentSubCommandName]; + + switch (currentSubCommandName) { + case "run": { + const parsedArgs = advParseArgs(cliArgs, subCmdDef.args, "jobs run"); + const { + dockerImageOrSpace: firstArg, + command: commandArray, + env, + secret, + flavor, + attempts: attemptsStr, + namespace, + token, + detach, + } = parsedArgs; + const envVars = env; + const secretVars = secret; + let attempts: number | undefined; + if (attemptsStr) { + const parsed = parseInt(attemptsStr, 10); + if (isNaN(parsed) || parsed < 1) { + throw new Error("Attempts must be a positive integer"); + } + attempts = parsed; + } + + // Detect if first argument is a space ID (format: hf.co/spaces/namespace/space-name or namespace/space-name) + let dockerImage: string | undefined; + let spaceId: string | undefined; + + // Check for hf.co/spaces/* format to identify Space IDs + const hfCoSpacesMatch = firstArg.match(/^hf\.co\/spaces\/(.+)$/); + if (hfCoSpacesMatch) { + spaceId = hfCoSpacesMatch[1]; + } else { + // Everything else is treated as a docker image + dockerImage = firstArg; + } + + // Get namespace from whoAmI if not provided + let finalNamespace = namespace; + if (!finalNamespace) { + if (!token) { + throw new Error( + "Cannot determine namespace without authentication. Please provide --namespace or --token.", + ); + } + const userInfo = await whoAmI({ + accessToken: token as string, + hubUrl: process.env.HF_ENDPOINT ?? HUB_URL, + }); + if (userInfo.type !== "user") { + throw new Error("Cannot determine namespace. Please provide --namespace explicitly."); + } + finalNamespace = userInfo.name; + } + + // Parse environment variables + const environment: Record = {}; + if (envVars) { + for (const envVar of envVars) { + const equalIndex = envVar.indexOf("="); + if (equalIndex === -1) { + throw new Error(`Invalid environment variable format: ${envVar}. Expected KEY=VALUE`); + } + const key = envVar.slice(0, equalIndex); + const value = envVar.slice(equalIndex + 1); + environment[key] = value; + } + } + + // Parse secrets + const secrets: Record = {}; + if (secretVars) { + for (const secretVar of secretVars) { + const equalIndex = secretVar.indexOf("="); + if (equalIndex === -1) { + throw new Error(`Invalid secret format: ${secretVar}. Expected KEY=VALUE`); + } + const key = secretVar.slice(0, equalIndex); + const value = secretVar.slice(equalIndex + 1); + secrets[key] = value; + } + } + + const jobParams = { + namespace: finalNamespace, + ...(dockerImage ? { dockerImage } : {}), + ...(spaceId ? { spaceId } : {}), + flavor: flavor as SpaceHardwareFlavor, + command: commandArray.length > 0 ? commandArray : undefined, + environment, + secrets, + ...(attempts !== undefined ? { attempts } : {}), + hubUrl: process.env.HF_ENDPOINT ?? HUB_URL, + ...(token ? { accessToken: token } : {}), + } as Parameters[0]; + + const job = await runJob(jobParams); + + console.log(`Job created: ${job.id}`); + console.log(`Status: ${job.status.stage}`); + + if (!detach) { + const logsParams = { + namespace: finalNamespace, + jobId: job.id, + hubUrl: process.env.HF_ENDPOINT ?? HUB_URL, + ...(token ? { accessToken: token } : {}), + } as Parameters[0]; + + // Display logs with proper line breaks + for await (const logChunk of streamJobLogs(logsParams)) { + console.log(logChunk.message); + } + } + break; + } + case "ps": { + const parsedArgs = advParseArgs(cliArgs, subCmdDef.args, "jobs ps"); + const { all, namespace, token } = parsedArgs; + + // Get namespace from whoAmI if not provided + let finalNamespace = namespace; + if (!finalNamespace) { + if (!token) { + throw new Error( + "Cannot determine namespace without authentication. Please provide --namespace or --token.", + ); + } + const userInfo = await whoAmI({ + accessToken: token as string, + hubUrl: process.env.HF_ENDPOINT ?? HUB_URL, + }); + if (userInfo.type !== "user") { + throw new Error("Cannot determine namespace. Please provide --namespace explicitly."); + } + finalNamespace = userInfo.name; + } + + const jobs = await listJobs({ + namespace: finalNamespace, + accessToken: token, + hubUrl: process.env.HF_ENDPOINT ?? HUB_URL, + }); + + // Filter by status if not showing all + const filteredJobs = all ? jobs : jobs.filter((job) => job.status.stage === "RUNNING"); + + if (filteredJobs.length === 0) { + console.log(all ? "No jobs found." : "No running jobs found."); + break; + } + + // Display jobs in a table-like format + console.log(`${"ID".padEnd(40)} ${"STATUS".padEnd(12)} ${"CREATED".padEnd(20)} ${"DOCKER IMAGE"}`); + console.log("-".repeat(100)); + for (const job of filteredJobs) { + const createdAt = new Date(job.createdAt).toLocaleString(); + const dockerImage = job.dockerImage || job.spaceId || "N/A"; + const status = job.status.stage; + console.log(`${job.id.padEnd(40)} ${status.padEnd(12)} ${createdAt.padEnd(20)} ${dockerImage}`); + } + break; + } + case "hardware": { + const parsedArgs = advParseArgs(cliArgs, subCmdDef.args, "jobs hardware"); + const { token } = parsedArgs; + + const hardwareParams: { + hubUrl?: string; + accessToken?: string; + } = { + hubUrl: process.env.HF_ENDPOINT ?? HUB_URL, + }; + if (token) { + hardwareParams.accessToken = token; + } + + const hardware = await listJobHardware(hardwareParams); + + // Format and display the hardware list + console.log( + `${"NAME".padEnd(15)} ${"PRETTY NAME".padEnd(22)} ${"CPU".padEnd(8)} ${"RAM".padEnd(7)} ${"ACCELERATOR".padEnd(16)} ${"COST/MIN".padEnd(9)} ${"COST/HOUR"}`, + ); + console.log("-".repeat(100)); + + for (const hw of hardware) { + // Format accelerator + let accelerator = "N/A"; + if (hw.accelerator) { + accelerator = `${hw.accelerator.quantity}x ${hw.accelerator.model} (${hw.accelerator.vram})`; + } + + // Format costs - unitCostMicroUSD is in microUSD (divide by 1,000,000 to get USD) + // unitCostUSD appears to be per minute based on the example + const costPerMin = (hw.unitCostMicroUSD / 1_000_000).toFixed(4); + const costPerHour = ((hw.unitCostMicroUSD / 1_000_000) * 60).toFixed(2); + + console.log( + `${hw.name.padEnd(15)} ${hw.prettyName.padEnd(22)} ${hw.cpu.padEnd(8)} ${hw.ram.padEnd(7)} ${accelerator.padEnd(16)} $${costPerMin.padStart(8)} $${costPerHour.padStart(9)}`, + ); + } + break; + } + case "logs": { + const parsedArgs = advParseArgs(cliArgs, subCmdDef.args, "jobs logs"); + const { jobId, namespace, token } = parsedArgs; + + // Get namespace from whoAmI if not provided + let finalNamespace = namespace; + if (!finalNamespace) { + if (!token) { + throw new Error( + "Cannot determine namespace without authentication. Please provide --namespace or --token.", + ); + } + const userInfo = await whoAmI({ + accessToken: token as string, + hubUrl: process.env.HF_ENDPOINT ?? HUB_URL, + }); + if (userInfo.type !== "user") { + throw new Error("Cannot determine namespace. Please provide --namespace explicitly."); + } + finalNamespace = userInfo.name; + } + + const logsParams = { + namespace: finalNamespace, + jobId, + hubUrl: process.env.HF_ENDPOINT ?? HUB_URL, + ...(token ? { accessToken: token } : {}), + } as Parameters[0]; + + // Get job info to check for error messages + const jobInfoParams = { + namespace: finalNamespace, + jobId, + hubUrl: process.env.HF_ENDPOINT ?? HUB_URL, + ...(token ? { accessToken: token } : {}), + } as Parameters[0]; + const jobInfo = await getJob(jobInfoParams); + + // Show error message if job failed (check both FAILED stage and message field) + if (jobInfo.status.stage === "ERROR" && jobInfo.status.message) { + console.error(`\n❌ Job failed: ${jobInfo.status.message}\n`); + } + + for await (const logChunk of streamJobLogs(logsParams)) { + console.log(logChunk.message); + } + break; + } + default: + // Should be caught by the check above + console.error(`Error: Unknown subcommand '${currentSubCommandName}' for 'jobs'.`); + console.log(listSubcommands("jobs", jobsCommandGroup)); + process.exitCode = 1; + break; + } + break; + } default: console.error("Command not found: " + mainCommandName); // Print general help @@ -621,7 +1055,8 @@ function usage(commandName: TopLevelCommandName, subCommandName?: string): strin if ("subcommands" in commandEntry) { if (subCommandName && subCommandName in commandEntry.subcommands) { - cmdArgs = commandEntry.subcommands[subCommandName as keyof typeof commandEntry.subcommands].args; + const subCmd = commandEntry.subcommands[subCommandName as keyof typeof commandEntry.subcommands] as SingleCommand; + cmdArgs = subCmd.args; fullCommandName = `${commandName} ${subCommandName}`; } else { return `${commandName} `; @@ -693,10 +1128,13 @@ function detailedUsageForSubcommand( subCommandName: keyof CommandGroup["subcommands"], ): string { const commandGroup = commands[commandName]; - if (!("subcommands" in commandGroup) || !(subCommandName in commandGroup.subcommands)) { + if (!("subcommands" in commandGroup)) { + throw new Error(`Command ${commandName} does not have subcommands`); + } + if (!(subCommandName in commandGroup.subcommands)) { throw new Error(`Subcommand ${subCommandName as string} not found for ${commandName}`); } - const subCommandDef = commandGroup.subcommands[subCommandName as keyof typeof commandGroup.subcommands]; + const subCommandDef = (commandGroup.subcommands as Record)[subCommandName]; return _detailedUsage(subCommandDef.args, usage(commandName, subCommandName as string), subCommandDef.description); } @@ -707,6 +1145,10 @@ function listSubcommands(commandName: TopLevelCommandName, commandGroup: Command ret += typedEntries(commandGroup.subcommands) .map(([subName, subDef]) => ` ${subName}\t${subDef.description}`) .join("\n"); + if (commandName === "jobs") { + ret += + '\n\nExample:\n hfjs jobs run -e FOO=foo -e BAR=bar python:3.12 -- python -c \'import os; print(os.environ["FOO"], os.environ["BAR"])\''; + } ret += `\n\nRun \`hfjs help ${commandName} \` for more information on a specific subcommand.`; return ret; } @@ -715,10 +1157,16 @@ type ParsedArgsResult = { [K in TArgsDef[number] as Camelize]: K["boolean"] extends true ? boolean : K["required"] extends true - ? string + ? K["multiple"] extends true + ? string[] // Multiple strings are arrays + : string : K["default"] extends undefined - ? string | undefined // Optional strings without default can be undefined - : string; // Strings with default or required are strings + ? K["multiple"] extends true + ? string[] | undefined // Multiple optional strings are arrays or undefined if not provided + : string | undefined // Optional strings without default can be undefined + : K["multiple"] extends true + ? string[] // Multiple strings with default are arrays + : string; // Strings with default or required are strings }; function advParseArgs( @@ -726,6 +1174,7 @@ function advParseArgs( argDefs: TArgsDef, commandNameForError: string, ): ParsedArgsResult { + const hasMultiplePositional = argDefs.some((arg) => arg.multiple && arg.positional); const { tokens } = parseArgs({ options: Object.fromEntries( argDefs @@ -734,6 +1183,7 @@ function advParseArgs( const optionConfig = { type: arg.boolean ? ("boolean" as const) : ("string" as const), ...(arg.short && { short: arg.short }), + ...(arg.multiple && { multiple: true }), ...(arg.default !== undefined && { default: typeof arg.default === "function" ? arg.default() : arg.default, }), @@ -759,7 +1209,7 @@ function advParseArgs( ); } - if (providedPositionalTokens.length > expectedPositionals.length) { + if (providedPositionalTokens.length > expectedPositionals.length && !hasMultiplePositional) { throw new Error( `Command '${commandNameForError}': Too many positional arguments. Usage: hfjs ${usage( commandNameForError.split(" ")[0] as TopLevelCommandName, @@ -768,7 +1218,7 @@ function advParseArgs( ); } - const result: Record = {}; + const result: Record = {}; // Populate from defaults first for (const argDef of argDefs) { @@ -780,9 +1230,11 @@ function advParseArgs( } // Populate positionals - providedPositionalTokens.forEach((token, i) => { - if (expectedPositionals[i]) { - result[expectedPositionals[i].name] = token.value; + expectedPositionals.forEach((argDef, i) => { + if (argDef.multiple) { + result[argDef.name] = providedPositionalTokens.slice(i).map((token) => token.value); + } else if (providedPositionalTokens[i]) { + result[argDef.name] = providedPositionalTokens[i].value; } }); @@ -808,7 +1260,13 @@ function advParseArgs( }. Expected one of: ${argDef.enum.join(", ")}`, ); } - result[argDef.name] = token.value; + if (argDef.multiple) { + const existing = (result[argDef.name] as string[]) || []; + existing.push(token.value); + result[argDef.name] = existing; + } else { + result[argDef.name] = token.value; + } } }); diff --git a/packages/hub/src/lib/index.ts b/packages/hub/src/lib/index.ts index 0d19b7e826..283c5ae9ba 100644 --- a/packages/hub/src/lib/index.ts +++ b/packages/hub/src/lib/index.ts @@ -15,6 +15,7 @@ export * from "./download-file"; export * from "./download-file-to-cache-dir"; export * from "./file-download-info"; export * from "./file-exists"; +export * from "./jobs"; export * from "./list-commits"; export * from "./list-datasets"; export * from "./list-files"; diff --git a/packages/hub/src/lib/jobs/cancel-job.ts b/packages/hub/src/lib/jobs/cancel-job.ts new file mode 100644 index 0000000000..52a6a80378 --- /dev/null +++ b/packages/hub/src/lib/jobs/cancel-job.ts @@ -0,0 +1,45 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; +import type { ApiJob } from "../../types/api/api-jobs"; + +/** + * Cancel a job. + */ +export async function cancelJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): Promise { + const accessToken = checkCredentials(params); + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}/cancel`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, + }, + ); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} diff --git a/packages/hub/src/lib/jobs/create-scheduled-job.ts b/packages/hub/src/lib/jobs/create-scheduled-job.ts new file mode 100644 index 0000000000..d9b37fa248 --- /dev/null +++ b/packages/hub/src/lib/jobs/create-scheduled-job.ts @@ -0,0 +1,82 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; +import type { ApiScheduledJob, CreateScheduledJobOptions } from "../../types/api/api-jobs"; + +/** + * Create a scheduled job. + */ +export async function createScheduledJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CreateScheduledJobOptions & + CredentialsParams, +): Promise { + const accessToken = checkCredentials(params); + + const { namespace, hubUrl, fetch: customFetch, ...rest } = params; + + if (!rest.jobSpec.dockerImage && !rest.jobSpec.spaceId) { + throw new Error("Either dockerImage or spaceId must be provided in jobSpec"); + } + + if (rest.jobSpec.dockerImage && rest.jobSpec.spaceId) { + throw new Error("Cannot provide both dockerImage and spaceId in jobSpec"); + } + + const body: Record = { + jobSpec: { + flavor: rest.jobSpec.flavor, + }, + schedule: rest.schedule, + suspend: rest.suspend ?? false, + concurrency: rest.concurrency ?? false, + }; + + if (rest.jobSpec.dockerImage) { + (body.jobSpec as Record).dockerImage = rest.jobSpec.dockerImage; + } + if (rest.jobSpec.spaceId) { + (body.jobSpec as Record).spaceId = rest.jobSpec.spaceId; + } + if (rest.jobSpec.command) { + (body.jobSpec as Record).command = rest.jobSpec.command; + } + (body.jobSpec as Record).environment = rest.jobSpec.environment || {}; + if (rest.jobSpec.secrets) { + (body.jobSpec as Record).secrets = rest.jobSpec.secrets; + } + if (rest.jobSpec.arch) { + (body.jobSpec as Record).arch = rest.jobSpec.arch; + } + if (rest.jobSpec.timeoutSeconds !== undefined) { + (body.jobSpec as Record).timeoutSeconds = rest.jobSpec.timeoutSeconds; + } + if (rest.jobSpec.attempts !== undefined) { + (body.jobSpec as Record).attempts = rest.jobSpec.attempts; + } + + const response = await (customFetch || fetch)(`${hubUrl || HUB_URL}/api/scheduled-jobs/${namespace}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} diff --git a/packages/hub/src/lib/jobs/delete-scheduled-job.ts b/packages/hub/src/lib/jobs/delete-scheduled-job.ts new file mode 100644 index 0000000000..03bfee16fd --- /dev/null +++ b/packages/hub/src/lib/jobs/delete-scheduled-job.ts @@ -0,0 +1,42 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; + +/** + * Delete a scheduled job. + */ +export async function deleteScheduledJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The scheduled job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): Promise { + const accessToken = checkCredentials(params); + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}/${params.jobId}`, + { + method: "DELETE", + headers: { + "Content-Type": "application/json", + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, + }, + ); + + if (!response.ok) { + throw await createApiError(response); + } +} diff --git a/packages/hub/src/lib/jobs/duplicate-job.ts b/packages/hub/src/lib/jobs/duplicate-job.ts new file mode 100644 index 0000000000..9e5e444e9d --- /dev/null +++ b/packages/hub/src/lib/jobs/duplicate-job.ts @@ -0,0 +1,45 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; +import type { ApiJob } from "../../types/api/api-jobs"; + +/** + * Duplicate a job (re-run with the same spec). + */ +export async function duplicateJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The job ID to duplicate + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): Promise { + const accessToken = checkCredentials(params); + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}/duplicate`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, + }, + ); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} diff --git a/packages/hub/src/lib/jobs/get-job.ts b/packages/hub/src/lib/jobs/get-job.ts new file mode 100644 index 0000000000..8a027516ad --- /dev/null +++ b/packages/hub/src/lib/jobs/get-job.ts @@ -0,0 +1,43 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; +import type { ApiJob } from "../../types/api/api-jobs"; + +/** + * Get a specific job by ID. + */ +export async function getJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): Promise { + const accessToken = checkCredentials(params); + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}`, + { + headers: { + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, + }, + ); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} diff --git a/packages/hub/src/lib/jobs/get-scheduled-job.ts b/packages/hub/src/lib/jobs/get-scheduled-job.ts new file mode 100644 index 0000000000..de8d152443 --- /dev/null +++ b/packages/hub/src/lib/jobs/get-scheduled-job.ts @@ -0,0 +1,43 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; +import type { ApiScheduledJob } from "../../types/api/api-jobs"; + +/** + * Get a specific scheduled job by ID. + */ +export async function getScheduledJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The scheduled job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): Promise { + const accessToken = checkCredentials(params); + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}/${params.jobId}`, + { + headers: { + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, + }, + ); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} diff --git a/packages/hub/src/lib/jobs/index.ts b/packages/hub/src/lib/jobs/index.ts new file mode 100644 index 0000000000..c7f9aad3d0 --- /dev/null +++ b/packages/hub/src/lib/jobs/index.ts @@ -0,0 +1,16 @@ +export * from "./cancel-job"; +export * from "./create-scheduled-job"; +export * from "./delete-scheduled-job"; +export * from "./duplicate-job"; +export * from "./get-job"; +export * from "./get-scheduled-job"; +export * from "./list-job-hardware"; +export * from "./list-jobs"; +export * from "./list-scheduled-jobs"; +export * from "./resume-scheduled-job"; +export * from "./run-job"; +export * from "./run-scheduled-job"; +export * from "./stream-job-events"; +export * from "./stream-job-logs"; +export * from "./stream-job-metrics"; +export * from "./suspend-scheduled-job"; diff --git a/packages/hub/src/lib/jobs/list-job-hardware.ts b/packages/hub/src/lib/jobs/list-job-hardware.ts new file mode 100644 index 0000000000..a6d129a2fc --- /dev/null +++ b/packages/hub/src/lib/jobs/list-job-hardware.ts @@ -0,0 +1,37 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; +import type { ApiJobHardware } from "../../types/api/api-jobs"; + +/** + * Get the list of available hardware for jobs. + * This endpoint is public and does not require authentication, but authentication is optional. + */ +export async function listJobHardware( + params?: { + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & Partial, +): Promise { + const accessToken = checkCredentials(params ?? {}); + + const headers: Record = {}; + + if (accessToken) { + headers.Authorization = `Bearer ${accessToken}`; + } + + const response = await (params?.fetch || fetch)(`${params?.hubUrl || HUB_URL}/api/jobs/hardware`, { + headers, + }); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} diff --git a/packages/hub/src/lib/jobs/list-jobs.ts b/packages/hub/src/lib/jobs/list-jobs.ts new file mode 100644 index 0000000000..7b214b24f0 --- /dev/null +++ b/packages/hub/src/lib/jobs/list-jobs.ts @@ -0,0 +1,36 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; +import type { ApiJob } from "../../types/api/api-jobs"; + +/** + * List jobs for a namespace (user or organization). + */ +export async function listJobs( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): Promise { + const accessToken = checkCredentials(params); + + const response = await (params.fetch || fetch)(`${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}`, { + headers: { + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, + }); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} diff --git a/packages/hub/src/lib/jobs/list-scheduled-jobs.ts b/packages/hub/src/lib/jobs/list-scheduled-jobs.ts new file mode 100644 index 0000000000..4769386832 --- /dev/null +++ b/packages/hub/src/lib/jobs/list-scheduled-jobs.ts @@ -0,0 +1,36 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; +import type { ApiScheduledJob } from "../../types/api/api-jobs"; + +/** + * List scheduled jobs for a namespace. + */ +export async function listScheduledJobs( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): Promise { + const accessToken = checkCredentials(params); + + const response = await (params.fetch || fetch)(`${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}`, { + headers: { + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, + }); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} diff --git a/packages/hub/src/lib/jobs/resume-scheduled-job.ts b/packages/hub/src/lib/jobs/resume-scheduled-job.ts new file mode 100644 index 0000000000..c7c0aa331b --- /dev/null +++ b/packages/hub/src/lib/jobs/resume-scheduled-job.ts @@ -0,0 +1,41 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; + +/** + * Resume a scheduled job. + */ +export async function resumeScheduledJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The scheduled job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): Promise { + const accessToken = checkCredentials(params); + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}/${params.jobId}/resume`, + { + method: "POST", + headers: { + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, + }, + ); + + if (!response.ok) { + throw await createApiError(response); + } +} diff --git a/packages/hub/src/lib/jobs/run-job.ts b/packages/hub/src/lib/jobs/run-job.ts new file mode 100644 index 0000000000..0779e591df --- /dev/null +++ b/packages/hub/src/lib/jobs/run-job.ts @@ -0,0 +1,78 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; +import type { ApiJob, CreateJobOptions } from "../../types/api/api-jobs"; + +/** + * Run a new job. + */ +export async function runJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CreateJobOptions & + CredentialsParams, +): Promise { + const accessToken = checkCredentials(params); + + if (!params.dockerImage && !params.spaceId) { + throw new Error("Either dockerImage or spaceId must be provided"); + } + + if (params.dockerImage && params.spaceId) { + throw new Error("Cannot provide both dockerImage and spaceId"); + } + + const body: Record = { + flavor: params.flavor, + environment: params.environment || {}, + }; + + if (params.dockerImage) { + body.dockerImage = params.dockerImage; + } + if (params.spaceId) { + body.spaceId = params.spaceId; + } + if (params.command) { + body.command = params.command; + } + if (params.arguments) { + body.arguments = params.arguments; + } + if (params.secrets) { + body.secrets = params.secrets; + } + if (params.arch) { + body.arch = params.arch; + } + if (params.timeoutSeconds !== undefined) { + body.timeoutSeconds = params.timeoutSeconds; + } + if (params.attempts !== undefined) { + body.attempts = params.attempts; + } + + const response = await (params.fetch || fetch)(`${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} diff --git a/packages/hub/src/lib/jobs/run-scheduled-job.ts b/packages/hub/src/lib/jobs/run-scheduled-job.ts new file mode 100644 index 0000000000..3224830b90 --- /dev/null +++ b/packages/hub/src/lib/jobs/run-scheduled-job.ts @@ -0,0 +1,50 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; +import type { ApiJob } from "../../types/api/api-jobs"; + +/** + * Trigger a scheduled job to run immediately. + * Returns the job that was triggered, or null if another instance is already running. + */ +export async function runScheduledJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The scheduled job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): Promise { + const accessToken = checkCredentials(params); + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}/${params.jobId}/run`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, + }, + ); + + if (!response.ok) { + if (response.status === 409) { + // Another instance is already running + return null; + } + throw await createApiError(response); + } + + return await response.json(); +} diff --git a/packages/hub/src/lib/jobs/stream-job-events.ts b/packages/hub/src/lib/jobs/stream-job-events.ts new file mode 100644 index 0000000000..8fd55f18eb --- /dev/null +++ b/packages/hub/src/lib/jobs/stream-job-events.ts @@ -0,0 +1,79 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; + +/** + * Stream job events using Server-Sent Events (SSE). + * Returns an async iterable of event chunks. + */ +export async function* streamJobEvents( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): AsyncGenerator { + const accessToken = checkCredentials(params); + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}/events`, + { + headers: { + Accept: "text/event-stream", + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, + }, + ); + + if (!response.ok) { + throw await createApiError(response); + } + + if (!response.body) { + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + yield line.slice(6); + } + } + } + + // Process remaining buffer + if (buffer) { + const lines = buffer.split("\n"); + for (const line of lines) { + if (line.startsWith("data: ")) { + yield line.slice(6); + } + } + } + } finally { + reader.releaseLock(); + } +} diff --git a/packages/hub/src/lib/jobs/stream-job-logs.ts b/packages/hub/src/lib/jobs/stream-job-logs.ts new file mode 100644 index 0000000000..1e6b7c038f --- /dev/null +++ b/packages/hub/src/lib/jobs/stream-job-logs.ts @@ -0,0 +1,89 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; + +/** + * Stream job logs using Server-Sent Events (SSE). + * Returns an async iterable of log chunks. + */ +export async function* streamJobLogs( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): AsyncGenerator<{ message: string; timestamp: Date }, void, unknown> { + const accessToken = checkCredentials(params); + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}/logs`, + { + headers: { + Accept: "text/event-stream", + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, + }, + ); + + if (!response.ok) { + throw await createApiError(response); + } + + if (!response.body) { + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + try { + const data = JSON.parse(line.slice(6)); + yield { message: data.data, timestamp: new Date(data.timestamp) }; + } catch { + yield { message: line.slice(6), timestamp: new Date() }; + } + } + } + } + + // Process remaining buffer + if (buffer) { + const lines = buffer.split("\n"); + for (const line of lines) { + if (line.startsWith("data: ")) { + try { + const data = JSON.parse(line.slice(6)); + yield { message: data.data, timestamp: new Date(data.timestamp) }; + } catch { + yield { message: line.slice(6), timestamp: new Date() }; + } + } + } + } + } finally { + reader.releaseLock(); + } +} diff --git a/packages/hub/src/lib/jobs/stream-job-metrics.ts b/packages/hub/src/lib/jobs/stream-job-metrics.ts new file mode 100644 index 0000000000..b0b2834c6b --- /dev/null +++ b/packages/hub/src/lib/jobs/stream-job-metrics.ts @@ -0,0 +1,79 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; + +/** + * Stream job metrics using Server-Sent Events (SSE). + * Returns an async iterable of metric chunks. + */ +export async function* streamJobMetrics( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): AsyncGenerator { + const accessToken = checkCredentials(params); + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}/metrics`, + { + headers: { + Accept: "text/event-stream", + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, + }, + ); + + if (!response.ok) { + throw await createApiError(response); + } + + if (!response.body) { + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + yield line.slice(6); + } + } + } + + // Process remaining buffer + if (buffer) { + const lines = buffer.split("\n"); + for (const line of lines) { + if (line.startsWith("data: ")) { + yield line.slice(6); + } + } + } + } finally { + reader.releaseLock(); + } +} diff --git a/packages/hub/src/lib/jobs/suspend-scheduled-job.ts b/packages/hub/src/lib/jobs/suspend-scheduled-job.ts new file mode 100644 index 0000000000..dcf4a5e3dd --- /dev/null +++ b/packages/hub/src/lib/jobs/suspend-scheduled-job.ts @@ -0,0 +1,42 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; + +/** + * Suspend (pause) a scheduled job. + */ +export async function suspendScheduledJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The scheduled job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): Promise { + const accessToken = checkCredentials(params); + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}/${params.jobId}/suspend`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, + }, + ); + + if (!response.ok) { + throw await createApiError(response); + } +} diff --git a/packages/hub/src/types/api/api-jobs.ts b/packages/hub/src/types/api/api-jobs.ts new file mode 100644 index 0000000000..f30d195915 --- /dev/null +++ b/packages/hub/src/types/api/api-jobs.ts @@ -0,0 +1,140 @@ +import type { SpaceHardwareFlavor } from "../public"; + +export interface ApiJobHardware { + name: string; + prettyName: string; + cpu: string; + ram: string; + accelerator: { + type: "gpu" | "neuron"; + model: string; + quantity: string; + vram: string; + manufacturer: "Nvidia" | "AWS"; + } | null; + unitCostMicroUSD: number; + unitCostUSD: number; + unitLabel: string; +} + +export type JobStatusStage = "DELETING" | "RUNNING" | "PAUSED" | "STOPPED" | "UPDATING" | "ERROR"; + +export interface ApiJobStatus { + stage: JobStatusStage; + message?: string | null; + failureCount: number; +} + +export interface ApiJobUser { + id: string; + name: string; + type?: "user" | "org"; + avatarUrl?: string; +} + +export interface ApiJob { + type: "job"; + id: string; + status: ApiJobStatus; + createdAt: string; + updatedAt?: string; + startedAt?: string | null; + finishedAt?: string | null; + createdBy?: { + id: string; + name: string; + }; + dockerImage?: string | null; + spaceId?: string | null; + command?: string[] | null; + arguments?: string[] | null; + environment?: Record | null; + flavor: SpaceHardwareFlavor; + arch?: "amd64" | "arm64" | null; + timeoutSeconds?: number | null; + attempts?: number; + owner?: ApiJobUser; + initiator?: ApiJobUser; + secrets?: string[]; +} + +export interface ApiScheduledJob { + id: string; + schedule: string; + suspend: boolean; + concurrency: boolean; + createdAt: string; + updatedAt: string; + jobSpec: { + dockerImage?: string | null; + spaceId?: string | null; + command?: string[] | null; + environment?: Record | null; + flavor: SpaceHardwareFlavor; + arch?: "amd64" | "arm64" | null; + timeoutSeconds?: number | null; + attempts?: number; + }; +} + +export interface CreateJobOptions { + /** + * The Docker image to run (e.g., "python:3.12" or "pytorch/pytorch:2.6.0-cuda12.4-cudnn9-devel") + */ + dockerImage?: string; + /** + * The Space ID to run (e.g., "username/space-name") + */ + spaceId?: string; + /** + * The command to run (array of strings) + */ + command?: string[]; + /** + * Additional arguments to pass to the command + */ + arguments?: string[]; + /** + * Environment variables to set + */ + environment?: Record; + /** + * Secrets to pass (will be encrypted server-side) + */ + secrets?: Record; + /** + * Hardware flavor to use + */ + flavor: SpaceHardwareFlavor; + /** + * Architecture (defaults to "amd64") + */ + arch?: "amd64" | "arm64"; + /** + * Timeout in seconds + */ + timeoutSeconds?: number | null; + /** + * Maximum number of attempts (defaults to 1) + */ + attempts?: number; +} + +export interface CreateScheduledJobOptions { + /** + * The job specification + */ + jobSpec: Omit; + /** + * CRON schedule expression (e.g., "0 9 * * 1" for 9 AM every Monday) or shortcuts like "@hourly", "@daily" + */ + schedule: string; + /** + * Whether the scheduled job is suspended (paused) + */ + suspend?: boolean; + /** + * Whether multiple instances of this job can run concurrently + */ + concurrency?: boolean; +} diff --git a/packages/hub/src/types/public.ts b/packages/hub/src/types/public.ts index 6a5b4a3004..d9cd7c3808 100644 --- a/packages/hub/src/types/public.ts +++ b/packages/hub/src/types/public.ts @@ -40,18 +40,25 @@ export type CredentialsParams = export type SpaceHardwareFlavor = | "cpu-basic" | "cpu-upgrade" + | "cpu-performance" + | "cpu-xl" + | "sprx8" + | "zero-a10g" + | "inf2x6" | "t4-small" | "t4-medium" | "l4x1" | "l4x4" + | "l40sx1" + | "l40sx4" + | "l40sx8" | "a10g-small" | "a10g-large" | "a10g-largex2" | "a10g-largex4" | "a100-large" - | "v5e-1x1" - | "v5e-2x2" - | "v5e-2x4"; + | "a100x4" + | "a100x8"; export type SpaceSdk = "streamlit" | "gradio" | "docker" | "static"; diff --git a/packages/hub/tsconfig.json b/packages/hub/tsconfig.json index c66aef0cff..39d83f3de1 100644 --- a/packages/hub/tsconfig.json +++ b/packages/hub/tsconfig.json @@ -14,7 +14,8 @@ "noImplicitOverride": true, "outDir": "./dist", "declaration": true, - "declarationMap": true + "declarationMap": true, + "sourceMap": true }, "include": ["src", "index.ts", "cli.ts"], "exclude": ["dist"]