This is an automated email from the ASF dual-hosted git repository. yasithdev pushed a commit to branch feat/generic-experiment-launcher in repository https://gitbox.apache.org/repos/asf/airavata-portals.git
commit 1d6d87b29de74d04e0c135d3b64bfa5ecf934dff Author: yasithdev <[email protected]> AuthorDate: Fri Apr 24 22:10:34 2026 -0400 feat(launcher): Pinia store + types + service wrapper Adds TypeScript types (launch-types.ts), a typed REST service wrapper (launcherService.ts), and the useLaunchStore Pinia store with 5 passing unit tests covering validation, reset-on-change, and FNV hash stability. --- .../tests/unit/stores/launch.spec.ts | 66 +++++++++ .../static/common/js/services/launcherService.ts | 70 +++++++++ .../static/common/js/stores/launch-types.ts | 89 +++++++++++ .../static/common/js/stores/launch.ts | 163 +++++++++++++++++++++ 4 files changed, 388 insertions(+) diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/stores/launch.spec.ts b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/stores/launch.spec.ts new file mode 100644 index 000000000..9046aacea --- /dev/null +++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/stores/launch.spec.ts @@ -0,0 +1,66 @@ +import { setActivePinia, createPinia } from "pinia"; +import { beforeEach, describe, expect, it } from "vitest"; +import { useLaunchStore } from "django-airavata-common-ui/js/stores/launch"; +import type { InterfaceDescriptor } from "django-airavata-common-ui/js/stores/launch-types"; + +const RUN_IFACE: InterfaceDescriptor = { + name: "run", + inputs: [ + { name: "sim_dir", type: "dir", required: true }, + { name: "steps", type: "int", required: true }, + ], + outputs: [{ name: "trajectory", type: "file" }], +}; + +describe("useLaunchStore", () => { + beforeEach(() => setActivePinia(createPinia())); + + it("starts with an invalid empty draft", () => { + const s = useLaunchStore(); + expect(s.tab1Valid).toBe(false); + expect(s.tab2Valid).toBe(false); + }); + + it("validates tab 1 once name+project+app+iface+inputs+outputs are set", () => { + const s = useLaunchStore(); + s.setMeta({ name: "x", project_id: "p1", description: "" }); + s.pickApp({ app_id: "namd", name: "NAMD", category: "MD", + content: { kind: "github", url: "g" }, interfaces: [RUN_IFACE] }); + s.pickInterface("run"); + s.setInput("sim_dir", { storage_id: "my-home", path: "/x" }); + s.setInput("steps", 100); + s.setOutput("trajectory", { storage_id: "my-home", path: "/y" }); + expect(s.tab1Valid).toBe(true); + }); + + it("clears interface + inputs when app changes", () => { + const s = useLaunchStore(); + s.pickApp({ app_id: "namd", name: "NAMD", category: "MD", + content: { kind: "github", url: "g" }, interfaces: [RUN_IFACE] }); + s.pickInterface("run"); + s.setInput("steps", 100); + s.pickApp({ app_id: "gromacs", name: "GROMACS", category: "MD", + content: { kind: "tarball", url: "t" }, interfaces: [] }); + expect(s.draft.interface_name).toBeNull(); + expect(s.draft.inputs).toEqual({}); + }); + + it("clears compute fields when project changes", () => { + const s = useLaunchStore(); + s.setMeta({ name: "x", project_id: "p1", description: "" }); + s.setRuntime({ compute_resource_id: "cr-1", partition: "RM", walltime: "01:00:00", nodes: 1, cpus_per_node: 8 }); + s.setMeta({ name: "x", project_id: "p2", description: "" }); + expect(s.draft.runtime.compute_resource_id).toBeNull(); + expect(s.draft.runtime.partition).toBeNull(); + }); + + it("computes a stable hash that changes only on draft change", () => { + const s = useLaunchStore(); + const h1 = s.draftHash; + s.setMeta({ name: "x", project_id: "p1", description: "" }); + expect(s.draftHash).not.toBe(h1); + const h2 = s.draftHash; + s.setMeta({ name: "x", project_id: "p1", description: "" }); + expect(s.draftHash).toBe(h2); + }); +}); diff --git a/airavata-django-portal/django_airavata/static/common/js/services/launcherService.ts b/airavata-django-portal/django_airavata/static/common/js/services/launcherService.ts new file mode 100644 index 000000000..2b66483e4 --- /dev/null +++ b/airavata-django-portal/django_airavata/static/common/js/services/launcherService.ts @@ -0,0 +1,70 @@ +import type { + Application, + ExperimentDraft, + PreviewResponse, + ResourceProfile, + UserStorage, +} from "../stores/launch-types"; + +const API = "/api/launcher"; + +async function getJson<T>(url: string, init: RequestInit = {}): Promise<T> { + const res = await fetch(url, { + ...init, + credentials: "same-origin", + headers: { Accept: "application/json", ...(init.headers ?? {}) }, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({ message: res.statusText })); + throw Object.assign(new Error(body.message ?? res.statusText), { status: res.status, body }); + } + return res.json() as Promise<T>; +} + +function csrf(): string { + const m = document.cookie.match(/csrftoken=([^;]+)/); + return m?.[1] ?? ""; +} + +export const launcherService = { + listApplications(params: { category?: string; search?: string } = {}): Promise<{ results: Application[] }> { + const qs = new URLSearchParams(); + if (params.category) qs.set("category", params.category); + if (params.search) qs.set("search", params.search); + const tail = qs.toString(); + return getJson(`${API}/applications/${tail ? "?" + tail : ""}`); + }, + + getApplication(appId: string): Promise<Application> { + return getJson(`${API}/applications/${encodeURIComponent(appId)}/`); + }, + + getProjectResourceProfile(projectId: string): Promise<ResourceProfile> { + return getJson(`${API}/projects/${encodeURIComponent(projectId)}/resource-profile/`); + }, + + listUserStorages(): Promise<{ results: UserStorage[] }> { + return getJson(`${API}/storages/`); + }, + + listProjects(): Promise<{ results: Array<{ project_id: string; name: string }> }> { + return getJson(`${API}/projects/`); + }, + + generatePreview(draft: ExperimentDraft, signal?: AbortSignal): Promise<PreviewResponse> { + return getJson(`${API}/experiment-drafts/preview/`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-CSRFToken": csrf() }, + body: JSON.stringify(draft), + signal, + }); + }, + + launchExperiment(draft: ExperimentDraft): Promise<{ experiment_id: string }> { + return getJson(`${API}/experiment-drafts/`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-CSRFToken": csrf() }, + body: JSON.stringify(draft), + }); + }, +}; diff --git a/airavata-django-portal/django_airavata/static/common/js/stores/launch-types.ts b/airavata-django-portal/django_airavata/static/common/js/stores/launch-types.ts new file mode 100644 index 000000000..676280745 --- /dev/null +++ b/airavata-django-portal/django_airavata/static/common/js/stores/launch-types.ts @@ -0,0 +1,89 @@ +export type ScalarType = "string" | "int" | "float" | "bool" | "enum" | "multi-string"; +export type FileType = "file" | "dir"; +export type IOType = ScalarType | FileType; + +export interface IODescriptor { + name: string; + type: IOType; + required?: boolean; + options?: string[]; // for enum +} + +export interface InterfaceDescriptor { + name: string; + inputs: IODescriptor[]; + outputs: IODescriptor[]; +} + +export interface AppContent { + kind: "tarball" | "github"; + url: string; +} + +export interface Application { + app_id: string; + name: string; + category: string; + content: AppContent; + interfaces: InterfaceDescriptor[]; +} + +export interface Partition { + name: string; + max_walltime: string; + max_nodes: number; + cpus_per_node: number; +} + +export interface MappedStorage { + storage_id: string; + scratch_path: string; +} + +export interface ComputeResource { + compute_resource_id: string; + name: string; + mapped_storage: MappedStorage; + partitions: Partition[]; +} + +export interface ResourceProfile { + project_id: string; + allocation_id: string; + compute_resources: ComputeResource[]; +} + +export interface UserStorage { + storage_id: string; + name: string; + is_primary: boolean; +} + +export type StorageRef = { storage_id: string; path: string }; +export type ScalarValue = string | number | boolean; +export type InputValue = ScalarValue | StorageRef | null; + +export interface RuntimeChoice { + compute_resource_id: string | null; + partition: string | null; + walltime: string; + nodes: number; + cpus_per_node: number; +} + +export interface ExperimentDraft { + name: string; + project_id: string | null; + description: string; + app_id: string | null; + interface_name: string | null; + inputs: Record<string, InputValue>; + outputs: Record<string, StorageRef>; + runtime: RuntimeChoice; +} + +export interface PreviewResponse { + invocation_command: string; + script_contents: string; + warnings: string[]; +} diff --git a/airavata-django-portal/django_airavata/static/common/js/stores/launch.ts b/airavata-django-portal/django_airavata/static/common/js/stores/launch.ts new file mode 100644 index 000000000..e249d41ab --- /dev/null +++ b/airavata-django-portal/django_airavata/static/common/js/stores/launch.ts @@ -0,0 +1,163 @@ +import { defineStore } from "pinia"; +import { computed, reactive, ref } from "vue"; +import type { + Application, + ExperimentDraft, + IODescriptor, + InterfaceDescriptor, + InputValue, + PreviewResponse, + ResourceProfile, + RuntimeChoice, + StorageRef, + UserStorage, +} from "./launch-types"; + +const EMPTY_RUNTIME: RuntimeChoice = { + compute_resource_id: null, + partition: null, + walltime: "01:00:00", + nodes: 1, + cpus_per_node: 1, +}; + +function makeDraft(): ExperimentDraft { + return { + name: "", + project_id: null, + description: "", + app_id: null, + interface_name: null, + inputs: {}, + outputs: {}, + runtime: { ...EMPTY_RUNTIME }, + }; +} + +function isStorageRef(v: InputValue): v is StorageRef { + return typeof v === "object" && v !== null && "storage_id" in v && "path" in v; +} + +function inputHasValue(io: IODescriptor, v: InputValue): boolean { + if (v === null || v === undefined) return !io.required; + if (io.type === "file" || io.type === "dir") { + return isStorageRef(v) && v.path.length > 0 && v.storage_id.length > 0; + } + return true; +} + +export const useLaunchStore = defineStore("launch", () => { + const draft = reactive<ExperimentDraft>(makeDraft()); + const pickedApp = ref<Application | null>(null); + const profile = ref<ResourceProfile | null>(null); + const storages = ref<UserStorage[]>([]); + const preview = ref<PreviewResponse | null>(null); + const previewError = ref<string | null>(null); + const previewLoading = ref(false); + const lastPreviewedHash = ref<string | null>(null); + + function setMeta(m: { name: string; project_id: string | null; description: string }) { + if (draft.project_id !== m.project_id) { + draft.runtime = { ...EMPTY_RUNTIME }; + profile.value = null; + } + draft.name = m.name; + draft.project_id = m.project_id; + draft.description = m.description; + } + + function pickApp(a: Application) { + pickedApp.value = a; + draft.app_id = a.app_id; + draft.interface_name = null; + draft.inputs = {}; + draft.outputs = {}; + } + + function pickInterface(name: string) { + draft.interface_name = name; + draft.inputs = {}; + draft.outputs = {}; + } + + function setInput(name: string, value: InputValue) { + draft.inputs[name] = value; + } + + function setOutput(name: string, value: StorageRef) { + draft.outputs[name] = value; + } + + function setRuntime(r: RuntimeChoice) { + draft.runtime = { ...r }; + } + + const pickedInterface = computed<InterfaceDescriptor | null>(() => { + const a = pickedApp.value; + const n = draft.interface_name; + if (!a || !n) return null; + return a.interfaces.find((i) => i.name === n) ?? null; + }); + + const tab1Valid = computed(() => { + if (!draft.name || !draft.project_id || !draft.app_id || !draft.interface_name) return false; + const iface = pickedInterface.value; + if (!iface) return false; + for (const io of iface.inputs) { + if (!inputHasValue(io, draft.inputs[io.name] ?? null)) return false; + } + for (const io of iface.outputs) { + if (io.type !== "file" && io.type !== "dir") continue; + const v = draft.outputs[io.name]; + if (!v || !v.path || !v.storage_id) return false; + } + return true; + }); + + const tab2Valid = computed(() => { + const r = draft.runtime; + return Boolean(r.compute_resource_id && r.partition && r.walltime && r.nodes >= 1 && r.cpus_per_node >= 1); + }); + + // FNV-1a 32-bit string hash on JSON of the draft. Stable, fast, no deps. + const draftHash = computed(() => { + const s = JSON.stringify(draft); + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return (h >>> 0).toString(16); + }); + + function reset() { + Object.assign(draft, makeDraft()); + pickedApp.value = null; + profile.value = null; + preview.value = null; + previewError.value = null; + lastPreviewedHash.value = null; + } + + return { + draft, + pickedApp, + pickedInterface, + profile, + storages, + preview, + previewError, + previewLoading, + lastPreviewedHash, + tab1Valid, + tab2Valid, + draftHash, + setMeta, + pickApp, + pickInterface, + setInput, + setOutput, + setRuntime, + reset, + }; +});
