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,
+  };
+});

Reply via email to