You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by ya...@apache.org on 2026/04/25 05:16:29 UTC
(airavata-portals) 12/34: feat(launcher): Pinia store + types + service wrapper
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 <ya...@icloud.com>
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,
+ };
+});