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:17 UTC
(airavata-portals) branch feat/generic-experiment-launcher created (now b14365110)
This is an automated email from the ASF dual-hosted git repository.
yasithdev pushed a change to branch feat/generic-experiment-launcher
in repository https://gitbox.apache.org/repos/asf/airavata-portals.git
at b14365110 fix(launcher): deferred review polish (deepcopy, type hints, test gaps, aria-labels)
This branch includes the following new commits:
new 42950c647 docs(feat/generic-launcher): generic experiment launcher design spec
new 41d78797a chore: ignore .superpowers/ brainstorming scratch dir in django portal
new 15edd66b8 docs(feat/generic-launcher): implementation plan (28 tasks across 9 phases)
new 4a675e249 feat(launcher): launcher_client stub + settings flags
new d0c3e210c feat(launcher): DRF serializers + JSON contract schemas
new 237b4fe7a fix(launcher): tighten ExperimentDraftSerializer.validate_inputs + add reject tests
new 3bc56cb27 feat(launcher): listing endpoints (apps, profile, storages, projects)
new 86d5544cd fix(launcher): standardize 404 body via DRF NotFound + add missing test coverage
new cf5be4ed5 feat(launcher): preview + create endpoints with 502/400 handling
new 3b79918d2 feat(launcher): /workspace/launch view + 301 redirects from old URLs
new d557d002b fix(launcher): active_nav_item + workspace base template + third redirect test
new 1d6d87b29 feat(launcher): Pinia store + types + service wrapper
new 5a3db3915 fix(launcher): add setStorages/setProfile + honor optional outputs + reset previewLoading
new bbff3c557 feat(launcher): ExperimentMetaHeader (name/project/description)
new c5f97a3a4 feat(launcher): WizardTabs with strict-forward gating
new fe5298a0e feat(launcher): LaunchContainer + entry-launch.ts (tabs scaffolding)
new fbb34a3f2 feat(launcher): AppPicker (categories + search + tile grid)
new 91509ab9c feat(launcher): InterfacePicker verb cards
new 1d1e486f7 feat(launcher): ScalarInputRow + FileInputRow
new fad7719f0 feat(launcher): InputList + OutputList + FileOutputRow
new 5be9e5023 feat(launcher): Tab1ApplicationInputs assembly
new 67680b5da feat(launcher): RuntimeInputs (profile-driven cr/partition/walltime)
new 7e7ddf477 feat(launcher): Tab2Runtime assembly + readouts
new 939c109c3 feat(launcher): ScriptPreview + InvocationCommand presentational components
new dc6303902 feat(launcher): Tab3ReviewLaunch preview fetch + launch flow
new 2e5af0f95 feat(launcher): localStorage draft persistence + hydrate()
new e80fff597 feat(launcher): confirm prompts on app/interface/project change
new ff10e84e0 test(launcher): full happy-path integration spec (mocked API)
new 618e40d28 test(launcher): Playwright happy path + add /workspace/launch to smoke
new 0064d23c6 test(launcher): Playwright error-path coverage (gate, preview fail, project change)
new 10845f609 refactor(launcher): retarget repo-internal links from old apps URL to /workspace/launch
new 9d90b2d58 refactor(launcher): delete old experiment-editor surface
new f6d95cc63 chore(launcher): default FEATURE_GENERIC_LAUNCHER to on
new b14365110 fix(launcher): deferred review polish (deepcopy, type hints, test gaps, aria-labels)
The 34 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(airavata-portals) 34/34: fix(launcher): deferred review polish (deepcopy, type hints, test gaps, aria-labels)
Posted by ya...@apache.org.
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 b14365110c80e53eb9e3f0cebfc88d8fe7f7df8f
Author: yasithdev <ya...@icloud.com>
AuthorDate: Sat Apr 25 01:15:39 2026 -0400
fix(launcher): deferred review polish (deepcopy, type hints, test gaps, aria-labels)
---
.../django_airavata/apps/api/launcher_client.py | 19 +++++-----
.../apps/api/tests/test_launcher_client.py | 40 ++++++++++++++++++++++
.../js/components/launch/ExperimentMetaHeader.vue | 3 ++
.../tests/unit/stores/launch.spec.ts | 28 +++++++++++++++
4 files changed, 81 insertions(+), 9 deletions(-)
diff --git a/airavata-django-portal/django_airavata/apps/api/launcher_client.py b/airavata-django-portal/django_airavata/apps/api/launcher_client.py
index b25ca5e60..514776474 100644
--- a/airavata-django-portal/django_airavata/apps/api/launcher_client.py
+++ b/airavata-django-portal/django_airavata/apps/api/launcher_client.py
@@ -9,6 +9,7 @@ stub and the real client. Tests should mock at the ``get_client`` boundary.
from __future__ import annotations
+import copy
from typing import Any, Protocol
from django.conf import settings
@@ -55,7 +56,7 @@ class _StubClient:
self.user_token = user_token
def list_applications(self, *, category: str | None, search: str | None) -> list[dict[str, Any]]:
- results = list(_STUB_APPS)
+ results = [copy.deepcopy(a) for a in _STUB_APPS]
if category:
results = [a for a in results if a["category"] == category]
if search:
@@ -66,7 +67,7 @@ class _StubClient:
def get_application(self, *, app_id: str) -> dict[str, Any]:
for a in _STUB_APPS:
if a["app_id"] == app_id:
- return a
+ return copy.deepcopy(a)
raise LookupError(f"unknown app_id {app_id!r}")
def get_project_resource_profile(self, *, project_id: str) -> dict[str, Any]:
@@ -152,25 +153,25 @@ class _RealClient:
def __init__(self, user_token: str) -> None:
self.user_token = user_token
- def list_applications(self, *, category, search):
+ def list_applications(self, *, category: str | None, search: str | None) -> list[dict[str, Any]]:
raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)")
- def get_application(self, *, app_id):
+ def get_application(self, *, app_id: str) -> dict[str, Any]:
raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)")
- def get_project_resource_profile(self, *, project_id):
+ def get_project_resource_profile(self, *, project_id: str) -> dict[str, Any]:
raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)")
- def list_user_storages(self):
+ def list_user_storages(self) -> list[dict[str, Any]]:
raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)")
- def list_projects(self):
+ def list_projects(self) -> list[dict[str, Any]]:
raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)")
- def generate_preview(self, draft):
+ def generate_preview(self, draft: dict[str, Any]) -> dict[str, Any]:
raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)")
- def launch_experiment(self, draft):
+ def launch_experiment(self, draft: dict[str, Any]) -> dict[str, Any]:
raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)")
diff --git a/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_client.py b/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_client.py
index 2ba428000..77426d2a4 100644
--- a/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_client.py
+++ b/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_client.py
@@ -57,3 +57,43 @@ class StubLauncherClientTest(TestCase):
self.assertTrue(len(projects) >= 1)
self.assertIn("project_id", projects[0])
self.assertIn("name", projects[0])
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_generate_preview_emits_slurm_script_for_bridges(self):
+ client = launcher_client.get_client(user_token="ignored")
+ draft = {
+ "name": "x",
+ "project_id": "proj-stub",
+ "app_id": "app-stub",
+ "interface_name": "run",
+ "inputs": {},
+ "outputs": {},
+ "runtime": {
+ "compute_resource_id": "bridges-2",
+ "partition": "RM",
+ "walltime": "01:00:00",
+ "nodes": 1,
+ "cpus_per_node": 8,
+ },
+ }
+ preview = client.generate_preview(draft)
+ self.assertTrue(preview["invocation_command"].startswith("sbatch"))
+ self.assertIn("#SBATCH", preview["script_contents"])
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_list_applications_filters_by_category(self):
+ client = launcher_client.get_client(user_token="ignored")
+ results = client.list_applications(category="Molecular Dynamics", search=None)
+ self.assertTrue(len(results) >= 1)
+ self.assertTrue(all(a["category"] == "Molecular Dynamics" for a in results))
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_list_applications_filters_by_search(self):
+ client = launcher_client.get_client(user_token="ignored")
+ results = client.list_applications(category=None, search="namd")
+ self.assertTrue(any("NAMD" in a["name"] for a in results))
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_list_applications_search_returns_empty_when_no_match(self):
+ client = launcher_client.get_client(user_token="ignored")
+ self.assertEqual(client.list_applications(category=None, search="nonexistent-app"), [])
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/ExperimentMetaHeader.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/ExperimentMetaHeader.vue
index 2e9e030e2..b2300dbd7 100644
--- a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/ExperimentMetaHeader.vue
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/ExperimentMetaHeader.vue
@@ -3,6 +3,7 @@
<div class="col-md-6">
<input
data-test="exp-name"
+ aria-label="Experiment name"
class="form-control"
:value="store.draft.name"
placeholder="Experiment name"
@@ -13,6 +14,7 @@
<div class="col-md-6">
<select
data-test="exp-project"
+ aria-label="Project"
class="form-select"
:value="store.draft.project_id ?? ''"
@change="onProject(($event.target as HTMLSelectElement).value)"
@@ -26,6 +28,7 @@
<div class="col-12">
<textarea
data-test="exp-description"
+ aria-label="Description"
class="form-control"
rows="2"
:value="store.draft.description"
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
index ad073e075..3b3527233 100644
--- 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
@@ -63,6 +63,34 @@ describe("useLaunchStore", () => {
s.setMeta({ name: "x", project_id: "p1", description: "" });
expect(s.draftHash).toBe(h2);
});
+
+ it("treats non-required null inputs as valid for tab1", () => {
+ const s = useLaunchStore();
+ const iface = {
+ name: "run",
+ inputs: [
+ { name: "a", type: "int" as const, required: true },
+ { name: "b", type: "int" as const, required: false },
+ ],
+ outputs: [],
+ };
+ s.setMeta({ name: "x", project_id: "p1", description: "" });
+ s.pickApp({ app_id: "x", name: "X", category: "C",
+ content: { kind: "github", url: "g" }, interfaces: [iface] });
+ s.pickInterface("run");
+ s.setInput("a", 1);
+ // 'b' deliberately unset (null), but optional
+ expect(s.tab1Valid).toBe(true);
+ });
+
+ it("rejects empty walltime in tab2Valid", () => {
+ const s = useLaunchStore();
+ s.setRuntime({
+ compute_resource_id: "cr-1", partition: "RM",
+ walltime: "", nodes: 1, cpus_per_node: 8,
+ });
+ expect(s.tab2Valid).toBe(false);
+ });
});
describe("setters and reset", () => {
(airavata-portals) 31/34: refactor(launcher): retarget repo-internal links from old apps URL to /workspace/launch
Posted by ya...@apache.org.
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 10845f60992b59b7e27b0c68e9ecf5330c0b084e
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 23:21:06 2026 -0400
refactor(launcher): retarget repo-internal links from old apps URL to /workspace/launch
---
.../js/containers/UserProfileContainer.vue | 2 +-
.../js/containers/ApplicationEditorContainer.vue | 6 +++---
.../js/containers/DashboardContainer.vue | 2 +-
.../js/containers/ExperimentListContainer.vue | 4 ++--
.../js/containers/ProjectOverviewContainer.vue | 2 +-
.../js/containers/WorkspaceDashboardContainer.vue | 2 +-
.../static/django_airavata_workspace/js/utils/urls.ts | 16 ++++------------
.../django_airavata/wagtailapps/base/blocks.py | 2 +-
8 files changed, 14 insertions(+), 22 deletions(-)
diff --git a/airavata-django-portal/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue b/airavata-django-portal/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue
index 89269adc7..46d6bc8d7 100644
--- a/airavata-django-portal/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue
+++ b/airavata-django-portal/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue
@@ -27,7 +27,7 @@
</template>
<button class="btn btn-primary" @click="onSave">Save</button>
- <button v-if="!mustComplete" class="btn btn-success" href="/workspace/applications">
+ <button v-if="!mustComplete" class="btn btn-success" href="/workspace/launch">
Go to Applications
</button>
</div>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/ApplicationEditorContainer.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/ApplicationEditorContainer.vue
index ebcd22a5b..164e66082 100644
--- a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/ApplicationEditorContainer.vue
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/ApplicationEditorContainer.vue
@@ -723,7 +723,7 @@ const hasInterface = computed(() => {
const hasDeployments = computed(() => appDeployments.value.length > 0);
-const launchUrl = computed(() => "/workspace/applications/" + props.appModuleId + "/create_experiment");
+const launchUrl = computed(() => "/workspace/launch");
const inputDataTypes = computed(() => (models.InputDataObjectType as unknown as Record<string, unknown>).VALID_DATA_TYPES as unknown[]);
@@ -1034,7 +1034,7 @@ async function deleteApp(): Promise<void> {
if (props.appModuleId) {
await services.ApplicationModuleService.delete({ lookup: props.appModuleId });
}
- window.location.href = "/workspace/applications";
+ window.location.href = "/workspace/launch";
} catch (error) {
console.error("Failed to delete application", error);
const err = error as { message?: string; details?: unknown };
@@ -1048,7 +1048,7 @@ async function deleteApp(): Promise<void> {
}
function cancel(): void {
- window.location.href = "/workspace/applications";
+ window.location.href = "/workspace/launch";
}
onMounted(() => {
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/DashboardContainer.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/DashboardContainer.vue
index a16017f2f..375651179 100644
--- a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/DashboardContainer.vue
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/DashboardContainer.vue
@@ -214,7 +214,7 @@ function navigateToApp(appModule: unknown) {
}
function runExperimentUrl(appModule: unknown) {
- return "/workspace/applications/" + (appModule as { app_module_id: string }).app_module_id + "/create_experiment";
+ return "/workspace/launch";
}
function toggleFavorite(appModule: unknown) {
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/ExperimentListContainer.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/ExperimentListContainer.vue
index 1f82f1885..6b3a0d09e 100644
--- a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/ExperimentListContainer.vue
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/ExperimentListContainer.vue
@@ -7,7 +7,7 @@
<p class="text-muted mb-0">Search, view, and manage your computational experiments.</p>
</div>
<div class="col-auto">
- <a href="/workspace/applications" class="btn btn-primary btn-sm"
+ <a href="/workspace/launch" class="btn btn-primary btn-sm"
><i class="fa fa-plus me-1"></i>Create New</a
>
</div>
@@ -94,7 +94,7 @@
<div class="table-empty__title">No experiments yet</div>
<div class="table-empty__text">
Launch your first experiment from the
- <a href="/workspace/applications">Dashboard</a>.
+ <a href="/workspace/launch">Dashboard</a>.
</div>
</div>
</td>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/ProjectOverviewContainer.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/ProjectOverviewContainer.vue
index b7b40f8eb..b7b0547c0 100644
--- a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/ProjectOverviewContainer.vue
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/ProjectOverviewContainer.vue
@@ -235,7 +235,7 @@ const experimentsUrl = computed(() =>
const artifactsUrl = computed(() =>
`/workspace/projects/${encodeURIComponent(props.projectId)}/artifacts`,
);
-const newExperimentUrl = "/workspace/applications";
+const newExperimentUrl = "/workspace/launch";
const formattedCreationTime = computed(() => {
const p = project.value as Record<string, unknown> | null;
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/WorkspaceDashboardContainer.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/WorkspaceDashboardContainer.vue
index 38986a693..a31809d53 100644
--- a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/WorkspaceDashboardContainer.vue
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/WorkspaceDashboardContainer.vue
@@ -37,7 +37,7 @@
<h5 class="mb-0">Recent Experiments</h5>
<div class="ms-auto d-flex gap-2">
<a
- href="/workspace/applications?action=launch"
+ href="/workspace/launch"
class="btn btn-primary btn-sm"
title="Choose an application to run an experiment from"
>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/utils/urls.ts b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/utils/urls.ts
index 7e3f10a1a..a1c102085 100644
--- a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/utils/urls.ts
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/utils/urls.ts
@@ -2,10 +2,6 @@ interface Experiment {
experiment_id: string;
}
-interface AppModule {
- app_module_id: string;
-}
-
interface Project {
project_id: string;
}
@@ -54,15 +50,11 @@ export default {
this.viewExperiment(projectId, experiment, { launching })
);
},
- createExperiment(appModule: AppModule): string {
- return (
- "/workspace/applications/" +
- encodeURIComponent(appModule.app_module_id) +
- "/create_experiment"
- );
+ createExperiment(): string {
+ return "/workspace/launch";
},
- navigateToCreateExperiment(appModule: AppModule): void {
- window.location.assign(this.createExperiment(appModule));
+ navigateToCreateExperiment(): void {
+ window.location.assign(this.createExperiment());
},
projectOverview(project: Project): string {
return (
diff --git a/airavata-django-portal/django_airavata/wagtailapps/base/blocks.py b/airavata-django-portal/django_airavata/wagtailapps/base/blocks.py
index e791f6049..24ffd6b5b 100644
--- a/airavata-django-portal/django_airavata/wagtailapps/base/blocks.py
+++ b/airavata-django-portal/django_airavata/wagtailapps/base/blocks.py
@@ -654,7 +654,7 @@ class LoginNavItem(NavItem):
class DashboardLinkNavItem(NavItem):
class Meta:
default = {
- "link": "/workspace/applications",
+ "link": "/workspace/launch",
"link_text": "Go to Applications",
"icon_class": "fas fa-arrow-circle-right",
"show": "logged-in",
(airavata-portals) 08/34: fix(launcher): standardize 404 body via DRF NotFound + add missing test coverage
Posted by ya...@apache.org.
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 86d5544cd17529eeb4660e43a4b636dc0ada6348
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 21:40:56 2026 -0400
fix(launcher): standardize 404 body via DRF NotFound + add missing test coverage
---
airavata-django-portal/django_airavata/apps/api/launcher_views.py | 5 +++--
.../django_airavata/apps/api/tests/test_launcher_views.py | 7 +++++++
2 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/airavata-django-portal/django_airavata/apps/api/launcher_views.py b/airavata-django-portal/django_airavata/apps/api/launcher_views.py
index 7c23d0a69..f0b39ba3a 100644
--- a/airavata-django-portal/django_airavata/apps/api/launcher_views.py
+++ b/airavata-django-portal/django_airavata/apps/api/launcher_views.py
@@ -1,5 +1,6 @@
-from rest_framework import permissions, status
+from rest_framework import permissions
from rest_framework.decorators import api_view, permission_classes
+from rest_framework.exceptions import NotFound
from rest_framework.request import Request
from rest_framework.response import Response
@@ -26,7 +27,7 @@ def application_detail(request: Request, app_id: str) -> Response:
try:
return Response(_client(request).get_application(app_id=app_id))
except LookupError:
- return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
+ raise NotFound()
@api_view(["GET"])
diff --git a/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_views.py b/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_views.py
index ae4e35c9c..e328b936a 100644
--- a/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_views.py
+++ b/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_views.py
@@ -35,6 +35,12 @@ class LauncherListingViewsTest(APITestCase):
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()["app_id"], "namd")
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_application_detail_not_found(self):
+ resp = self.client.get("/api/launcher/applications/does-not-exist/")
+ self.assertEqual(resp.status_code, 404)
+ self.assertIn("detail", resp.json())
+
@override_settings(LAUNCHER_CLIENT_STUB=True)
def test_resource_profile_for_project(self):
resp = self.client.get("/api/launcher/projects/proj-1/resource-profile/")
@@ -61,6 +67,7 @@ class LauncherListingViewsTest(APITestCase):
self.assertIn("project_id", body["results"][0])
self.assertIn("name", body["results"][0])
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
def test_endpoints_require_auth(self):
# Clear the forced authentication so the requests are anonymous.
self.client.force_authenticate(user=None)
(airavata-portals) 17/34: feat(launcher): AppPicker (categories + search + tile grid)
Posted by ya...@apache.org.
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 fbb34a3f2e2c0d58dce4734891d3a85beb6d9dbf
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 22:38:06 2026 -0400
feat(launcher): AppPicker (categories + search + tile grid)
---
.../js/components/launch/AppPicker.vue | 80 ++++++++++++++++++++++
.../tests/unit/components/launch/AppPicker.spec.ts | 41 +++++++++++
2 files changed, 121 insertions(+)
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/AppPicker.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/AppPicker.vue
new file mode 100644
index 000000000..7df9ef1ad
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/AppPicker.vue
@@ -0,0 +1,80 @@
+<template>
+ <div>
+ <div class="d-flex flex-wrap gap-1 mb-2">
+ <button
+ v-for="cat in categories"
+ :key="cat"
+ type="button"
+ class="btn btn-sm"
+ :class="cat === activeCat ? 'btn-primary' : 'btn-light'"
+ :data-test="`cat-${cat}`"
+ @click="activeCat = cat"
+ >
+ {{ cat }}<span class="ms-1 opacity-50">{{ countByCat[cat] }}</span>
+ </button>
+ </div>
+ <input
+ v-model="search"
+ type="search"
+ class="form-control mb-2"
+ data-test="app-search"
+ placeholder="Filter…"
+ />
+ <div class="row g-2">
+ <div
+ v-for="a in filtered"
+ :key="a.app_id"
+ class="col-6 col-md-3"
+ data-test="app-tile"
+ >
+ <button
+ type="button"
+ class="card w-100 text-start p-2"
+ :class="{ 'border-primary': store.draft.app_id === a.app_id }"
+ :data-test="`app-tile-${a.app_id}`"
+ @click="pick(a)"
+ >
+ <strong>{{ a.name }}</strong>
+ <span class="text-muted small d-block">{{ a.content.url }}</span>
+ </button>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from "vue";
+import type { Application } from "django-airavata-common-ui/js/stores/launch-types";
+import { useLaunchStore } from "django-airavata-common-ui/js/stores/launch";
+
+const props = defineProps<{ applications: Application[] }>();
+const store = useLaunchStore();
+const activeCat = ref<string>("All");
+const search = ref("");
+
+const categories = computed(() => {
+ const set = new Set<string>(["All"]);
+ for (const a of props.applications) set.add(a.category);
+ return Array.from(set);
+});
+
+const countByCat = computed<Record<string, number>>(() => {
+ const out: Record<string, number> = { All: props.applications.length };
+ for (const a of props.applications) out[a.category] = (out[a.category] ?? 0) + 1;
+ return out;
+});
+
+const filtered = computed(() => {
+ let xs = props.applications;
+ if (activeCat.value !== "All") xs = xs.filter((a) => a.category === activeCat.value);
+ if (search.value) {
+ const n = search.value.toLowerCase();
+ xs = xs.filter((a) => a.name.toLowerCase().includes(n));
+ }
+ return xs;
+});
+
+function pick(a: Application) {
+ store.pickApp(a);
+}
+</script>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/AppPicker.spec.ts b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/AppPicker.spec.ts
new file mode 100644
index 000000000..3bf80e2e1
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/AppPicker.spec.ts
@@ -0,0 +1,41 @@
+import { mount } from "@vue/test-utils";
+import { createPinia, setActivePinia } from "pinia";
+import { beforeEach, describe, expect, it } from "vitest";
+import AppPicker from "../../../../js/components/launch/AppPicker.vue";
+import { useLaunchStore } from "django-airavata-common-ui/js/stores/launch";
+
+const APPS = [
+ { app_id: "namd", name: "NAMD", category: "Molecular Dynamics",
+ content: { kind: "github" as const, url: "g" }, interfaces: [] },
+ { app_id: "gromacs", name: "GROMACS", category: "Molecular Dynamics",
+ content: { kind: "tarball" as const, url: "t" }, interfaces: [] },
+ { app_id: "alphafold", name: "AlphaFold", category: "ML / AI",
+ content: { kind: "github" as const, url: "g" }, interfaces: [] },
+];
+
+describe("AppPicker", () => {
+ beforeEach(() => setActivePinia(createPinia()));
+
+ it("renders all apps when category=All", () => {
+ const w = mount(AppPicker, { props: { applications: APPS } });
+ expect(w.findAll("[data-test='app-tile']")).toHaveLength(3);
+ });
+
+ it("filters by category chip", async () => {
+ const w = mount(AppPicker, { props: { applications: APPS } });
+ await w.find("[data-test='cat-ML / AI']").trigger("click");
+ expect(w.findAll("[data-test='app-tile']")).toHaveLength(1);
+ });
+
+ it("filters by search text within current category", async () => {
+ const w = mount(AppPicker, { props: { applications: APPS } });
+ await w.find("input[data-test='app-search']").setValue("NAMD");
+ expect(w.findAll("[data-test='app-tile']")).toHaveLength(1);
+ });
+
+ it("clicking a tile picks the app via the store", async () => {
+ const w = mount(AppPicker, { props: { applications: APPS } });
+ await w.find("[data-test='app-tile-namd']").trigger("click");
+ expect(useLaunchStore().draft.app_id).toBe("namd");
+ });
+});
(airavata-portals) 06/34: fix(launcher): tighten ExperimentDraftSerializer.validate_inputs + add reject tests
Posted by ya...@apache.org.
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 237b4fe7aaa72a1b578953bf27f0117ebcdfc598
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 21:29:11 2026 -0400
fix(launcher): tighten ExperimentDraftSerializer.validate_inputs + add reject tests
---
.../django_airavata/apps/api/launcher_serializers.py | 6 +++++-
.../apps/api/tests/test_launcher_serializers.py | 15 ++++++++++++++-
2 files changed, 19 insertions(+), 2 deletions(-)
diff --git a/airavata-django-portal/django_airavata/apps/api/launcher_serializers.py b/airavata-django-portal/django_airavata/apps/api/launcher_serializers.py
index af186fc4d..268e17e55 100644
--- a/airavata-django-portal/django_airavata/apps/api/launcher_serializers.py
+++ b/airavata-django-portal/django_airavata/apps/api/launcher_serializers.py
@@ -35,10 +35,14 @@ class ExperimentDraftSerializer(serializers.Serializer):
runtime = RuntimeSerializer()
def validate_inputs(self, value):
- # Each input is either a scalar (str/int/float/bool) or a {storage_id, path} object.
+ scalar_types = (str, int, float, bool)
for name, v in value.items():
if isinstance(v, dict):
StorageRefSerializer(data=v).is_valid(raise_exception=True)
+ elif not isinstance(v, scalar_types):
+ raise serializers.ValidationError(
+ {name: "input must be a scalar (str/int/float/bool) or a {storage_id, path} object"}
+ )
return value
diff --git a/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_serializers.py b/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_serializers.py
index b1101d277..d7a616a2a 100644
--- a/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_serializers.py
+++ b/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_serializers.py
@@ -3,11 +3,12 @@ from pathlib import Path
from unittest import TestCase
import jsonschema
+from django.conf import settings
from django_airavata.apps.api import launcher_serializers
-CONTRACTS = Path(__file__).resolve().parents[4] / "tests" / "contracts"
+CONTRACTS = Path(settings.BASE_DIR) / "tests" / "contracts"
class ExperimentDraftSerializerTest(TestCase):
@@ -52,6 +53,18 @@ class ExperimentDraftSerializerTest(TestCase):
serializer = launcher_serializers.ExperimentDraftSerializer(data=draft)
self.assertFalse(serializer.is_valid())
+ def test_serializer_rejects_malformed_file_input(self):
+ draft = self._valid_draft()
+ draft["inputs"]["sim_dir"] = {"storage_id": "my-home"} # missing 'path'
+ serializer = launcher_serializers.ExperimentDraftSerializer(data=draft)
+ self.assertFalse(serializer.is_valid())
+
+ def test_serializer_rejects_non_scalar_non_dict_input(self):
+ draft = self._valid_draft()
+ draft["inputs"]["steps"] = [1, 2, 3] # neither scalar nor {storage_id, path}
+ serializer = launcher_serializers.ExperimentDraftSerializer(data=draft)
+ self.assertFalse(serializer.is_valid())
+
class PreviewResponseSchemaTest(TestCase):
def test_valid_response(self):
(airavata-portals) 32/34: refactor(launcher): delete old experiment-editor surface
Posted by ya...@apache.org.
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 9d90b2d58f8c03575e21076cd30080575a0cfeae
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 23:26:06 2026 -0400
refactor(launcher): delete old experiment-editor surface
Remove CreateExperimentContainer, EditExperimentContainer, ExperimentEditor,
ComputationalResourceSchedulingEditor, QueueSettingsEditor, GroupResourceProfileSelector,
entry-create-experiment.js, and entry-edit-experiment.js. Drop the matching view
functions (create_experiment, edit_experiment, applications, get_custom_template) and
orphaned imports from workspace/views.py. Replace the still-routed edit_experiment URL
with a 301 redirect to /workspace/launch (missed by Task 5). Drop create-experiment and
edit-experiment entries from vite.config.js.
---
.../ComputationalResourceSchedulingEditor.vue | 296 -----------
.../js/components/experiment/ExperimentEditor.vue | 423 ----------------
.../experiment/GroupResourceProfileSelector.vue | 114 -----
.../components/experiment/QueueSettingsEditor.vue | 544 ---------------------
.../js/containers/CreateExperimentContainer.vue | 99 ----
.../js/containers/EditExperimentContainer.vue | 78 ---
.../js/entry-create-experiment.js | 32 --
.../js/entry-edit-experiment.js | 25 -
.../django_airavata/apps/workspace/urls.py | 4 +-
.../django_airavata/apps/workspace/views.py | 120 -----
.../django_airavata/apps/workspace/vite.config.js | 2 -
11 files changed, 2 insertions(+), 1735 deletions(-)
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/ComputationalResourceSchedulingEditor.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/ComputationalResourceSchedulingEditor.vue
deleted file mode 100644
index 6e7c50454..000000000
--- a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/ComputationalResourceSchedulingEditor.vue
+++ /dev/null
@@ -1,296 +0,0 @@
-<template>
- <div>
- <div class="row">
- <div class="col">
- <form-group
- label="Compute Resource"
- label-for="compute-resource"
- :feedback="getValidationFeedback('resource_host_id')"
- :state="getValidationState('resource_host_id')"
- >
- <select
- id="compute-resource"
- v-model="resource_host_id"
- class="form-select"
- required
- :state="getValidationState('resource_host_id')"
- :disabled="!computeResourceOptions || computeResourceOptions.length === 0"
- @change="computeResourceChanged(($event.target as HTMLSelectElement).value)"
- >
- <option :value="null" disabled>Select a Compute Resource</option>
- <option v-for="opt in computeResourceOptions" :key="opt.value" :value="opt.value">
- {{ opt.text }}
- </option>
- </select>
- </form-group>
- </div>
- </div>
- <div class="row">
- <div class="col">
- <QueueSettingsEditor
- v-if="appDeploymentId"
- v-model="data"
- :app-module-id="appModuleId"
- :app-deployment-id="appDeploymentId"
- :compute-resource-policy="selectedComputeResourcePolicy"
- :batch-queue-resource-policies="batchQueueResourcePolicies"
- @input="queueSettingsChanged"
- @valid="queueSettingsValidityChanged(true)"
- @invalid="queueSettingsValidityChanged(false)"
- >
- </QueueSettingsEditor>
- </div>
- </div>
- </div>
-</template>
-
-<script setup lang="ts">
-import { ref, computed, watch, onMounted } from "vue";
-import QueueSettingsEditor from "./QueueSettingsEditor.vue";
-import { errors, models, services, utils as apiUtils } from "django-airavata-api";
-import { utils } from "django-airavata-common-ui";
-
-type ComputationalResourceSchedulingModel = InstanceType<typeof models.ComputationalResourceSchedulingModel>;
-
-interface ComputeResourceOption {
- value: string;
- text: string;
-}
-
-interface WorkspacePreferences {
- most_recent_compute_resource_id?: string | null;
-}
-
-interface GroupResourceProfileData {
- group_resource_profile_id: string;
- compute_resource_policies: Array<{ compute_resource_id: string; [key: string]: unknown }>;
- batch_queue_resource_policies: Array<{ compute_resource_id: string; [key: string]: unknown }>;
-}
-
-interface ApplicationDeployment {
- compute_host_id: string;
- app_deployment_id: string;
-}
-
-const props = defineProps<{
- modelValue: ComputationalResourceSchedulingModel;
- appModuleId: string;
- groupResourceProfileId: string;
-}>();
-
-const emit = defineEmits<{
- "update:modelValue": [value: ComputationalResourceSchedulingModel];
- input: [value: ComputationalResourceSchedulingModel];
- valid: [];
- invalid: [];
-}>();
-
-// VModelMixin inline
-function copyValue(value: ComputationalResourceSchedulingModel) {
- return value instanceof models.BaseModel
- ? (value as unknown as { clone: () => ComputationalResourceSchedulingModel }).clone()
- : value;
-}
-
-const data = ref<ComputationalResourceSchedulingModel>(copyValue(props.modelValue));
-
-watch(
- () => props.modelValue,
- (newValue) => {
- data.value = copyValue(newValue);
- },
- { deep: true },
-);
-
-watch(
- data,
- (newValue, oldValue) => {
- if (typeof props.modelValue === "object" && newValue === oldValue) {
- emit("update:modelValue", newValue);
- emit("input", newValue);
- } else if (
- (props.modelValue === null || typeof props.modelValue !== "object") &&
- newValue !== oldValue
- ) {
- emit("update:modelValue", newValue);
- emit("input", newValue);
- }
- },
- { deep: true },
-);
-
-const computeResources = ref<Record<string, string>>({});
-const applicationDeployments = ref<ApplicationDeployment[]>([]);
-const selectedGroupResourceProfileData = ref<GroupResourceProfileData | null>(null);
-const resource_host_id = ref<string | null>(props.modelValue.resource_host_id ?? null);
-const invalidQueueSettings = ref(false);
-const workspacePreferences = ref<WorkspacePreferences | null>(null);
-
-const localComputationalResourceScheduling = computed(() => data.value);
-
-const computeResourceOptions = computed<ComputeResourceOption[]>(() => {
- const options = applicationDeployments.value.map((dep) => ({
- value: dep.compute_host_id,
- text: dep.compute_host_id in computeResources.value ? computeResources.value[dep.compute_host_id] : "",
- }));
- options.sort((a, b) => a.text.localeCompare(b.text));
- return options;
-});
-
-const selectedComputeResourcePolicy = computed(() => {
- if (selectedGroupResourceProfileData.value === null) return null;
- return selectedGroupResourceProfileData.value.compute_resource_policies.find(
- (crp) => crp.compute_resource_id === localComputationalResourceScheduling.value.resource_host_id,
- ) ?? null;
-});
-
-interface BatchQueueResourcePolicyLike {
- compute_resource_id: string;
- queuename: string;
- maxAllowedCores: number;
- maxAllowedNodes: number;
- maxAllowedWalltime: number;
- resourcePolicyId?: string;
- [key: string]: unknown;
-}
-
-const batchQueueResourcePolicies = computed<BatchQueueResourcePolicyLike[] | null>(() => {
- if (selectedGroupResourceProfileData.value === null) return null;
- return selectedGroupResourceProfileData.value.batch_queue_resource_policies.filter(
- (bqrp) => bqrp.compute_resource_id === localComputationalResourceScheduling.value.resource_host_id,
- ) as BatchQueueResourcePolicyLike[];
-});
-
-const appDeploymentId = computed<string | null>(() => {
- if (!resource_host_id.value || applicationDeployments.value.length === 0) return null;
- const selectedDep = applicationDeployments.value.find(
- (dep) => dep.compute_host_id === resource_host_id.value,
- );
- if (!selectedDep) {
- throw new Error("Failed to find application deployment!");
- }
- return selectedDep.app_deployment_id;
-});
-
-const validation = computed(() => {
- const queueInfo = {};
- return localComputationalResourceScheduling.value.validate(queueInfo);
-});
-
-const valid = computed(() => !invalidQueueSettings.value && Object.keys(validation.value).length === 0);
-
-watch(computeResourceOptions, (newOptions) => {
- if (resource_host_id.value !== null && !newOptions.find((opt) => opt.value === resource_host_id.value)) {
- resource_host_id.value = null;
- }
- if (
- resource_host_id.value === null &&
- workspacePreferences.value?.most_recent_compute_resource_id &&
- newOptions.find((opt) => opt.value === workspacePreferences.value!.most_recent_compute_resource_id)
- ) {
- resource_host_id.value = workspacePreferences.value.most_recent_compute_resource_id;
- }
- if (resource_host_id.value === null && newOptions.length > 0) {
- resource_host_id.value = newOptions[0].value;
- }
- computeResourceChanged(resource_host_id.value);
-});
-
-watch(
- () => props.groupResourceProfileId,
- (newGroupResourceProfileId) => {
- loadApplicationDeployments(props.appModuleId, newGroupResourceProfileId);
- if (
- selectedGroupResourceProfileData.value &&
- selectedGroupResourceProfileData.value.group_resource_profile_id !== newGroupResourceProfileId
- ) {
- loadGroupResourceProfile();
- }
- },
-);
-
-onMounted(() => {
- loadWorkspacePreferences().then(() => {
- loadApplicationDeployments(props.appModuleId, props.groupResourceProfileId);
- });
- loadComputeResourceNames();
- loadGroupResourceProfile();
- validate();
-});
-
-function computeResourceChanged(selectedComputeResourceId: string | null) {
- data.value.resource_host_id = selectedComputeResourceId;
-}
-
-function loadApplicationDeployments(appModuleId: string, groupResourceProfileId: string) {
- services.ApplicationDeploymentService.list(
- { app_module_id: appModuleId, group_resource_profile_id: groupResourceProfileId },
- { ignoreErrors: true },
- )
- .then((deps: unknown) => {
- applicationDeployments.value = deps as ApplicationDeployment[];
- })
- .catch((error: unknown) => {
- if (!errors.ErrorUtils.isUnauthorizedError(error)) {
- return Promise.reject(error);
- }
- })
- .catch(apiUtils.FetchUtils.reportError);
-}
-
-function loadGroupResourceProfile() {
- services.ProjectResourceProfileService.retrieve(
- { lookup: props.groupResourceProfileId },
- { ignoreErrors: true },
- )
- .then((grp: unknown) => {
- selectedGroupResourceProfileData.value = grp as GroupResourceProfileData;
- })
- .catch((error: unknown) => {
- if (!errors.ErrorUtils.isUnauthorizedError(error)) {
- return Promise.reject(error);
- }
- })
- .catch(apiUtils.FetchUtils.reportError);
-}
-
-function loadComputeResourceNames() {
- services.ComputeResourceService.names().then(
- (names: unknown) => (computeResources.value = names as Record<string, string>),
- );
-}
-
-function loadWorkspacePreferences(): Promise<void> {
- return services.WorkspacePreferencesService.get().then(
- (prefs: unknown) => (workspacePreferences.value = prefs as WorkspacePreferences),
- );
-}
-
-function queueSettingsChanged() {
- localComputationalResourceScheduling.value.resource_host_id = resource_host_id.value;
- emit("input", data.value);
-}
-
-function queueSettingsValidityChanged(validValue: boolean) {
- invalidQueueSettings.value = !validValue;
- validate();
-}
-
-function validate() {
- if (!valid.value) {
- emit("invalid");
- } else {
- emit("valid");
- }
-}
-
-function getValidationFeedback(properties: string): unknown {
- return utils.getProperty(validation.value, properties);
-}
-
-function getValidationState(properties: string): boolean | null {
- return getValidationFeedback(properties) ? false : null;
-}
-</script>
-
-<style></style>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/ExperimentEditor.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/ExperimentEditor.vue
deleted file mode 100644
index be28bbc44..000000000
--- a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/ExperimentEditor.vue
+++ /dev/null
@@ -1,423 +0,0 @@
-<template>
- <div>
- <UnsavedChangesGuard :dirty="dirty" />
- <div class="row">
- <div class="col-auto me-auto">
- <h1 class="h4 mb-4">
- <div v-if="appModule" class="application-name text-muted text-uppercase">
- <i class="fa fa-code" aria-hidden="true"></i>
- {{ appModule.app_module_name }}
- </div>
- <slot name="title">Experiment Editor</slot>
- </h1>
- </div>
- <div class="col-auto">
- <ShareButton
- ref="shareButton"
- :entity-id="localExperiment.experiment_id"
- :entity-label="'Experiment'"
- :parent-entity-id="localExperiment.project_id"
- :parent-entity-label="'Project'"
- :auto-add-default-gateway-users-group="false"
- />
- </div>
- </div>
- <form novalidate>
- <div class="row">
- <div class="col">
- <form-group
- label="Experiment Name"
- label-for="experiment-name"
- :feedback="getValidationFeedback('experiment_name')"
- :state="getValidationState('experiment_name')"
- >
- <input
- id="experiment-name"
- v-model="localExperiment.experiment_name"
- class="form-control"
- type="text"
- required
- placeholder="Experiment name"
- :state="getValidationState('experiment_name')"
- />
- </form-group>
- <ExperimentDescriptionEditor v-model="localExperiment.description" />
- </div>
- </div>
- <div class="row">
- <div class="col">
- <form-group
- label="Project"
- label-for="project"
- :feedback="getValidationFeedback('project_id')"
- :state="getValidationState('project_id')"
- >
- <select
- id="project"
- v-model="localExperiment.project_id"
- class="form-select"
- required
- :state="getValidationState('project_id')"
- >
- <option :value="null" disabled>Select a Project</option>
- <optgroup label="My Projects">
- <option
- v-for="project in myProjectOptions"
- :key="project.value"
- :value="project.value"
- >
- {{ project.text }}
- </option>
- </optgroup>
- <optgroup label="Projects Shared With Me">
- <option
- v-for="project in sharedProjectOptions"
- :key="project.value"
- :value="project.value"
- >
- {{ project.text }}
- </option>
- </optgroup>
- </select>
- </form-group>
- </div>
- </div>
- <div class="row">
- <div class="col">
- <WorkspaceNoticesManagementContainer
- v-if="appInterface && (appInterface as Record<string, unknown>).application_description"
- class="mt-2"
- :data="[{ notificationMessage: (appInterface as Record<string, unknown>).application_description as string | undefined }]"
- />
- </div>
- </div>
- <div class="row">
- <div class="col">
- <h1 class="h4 mt-2 mb-4">Application Configuration</h1>
- </div>
- </div>
- <div class="row">
- <div class="col">
- <div class="card border-default">
- <div class="card-body">
- <h2 class="h6 mb-3">Application Inputs</h2>
- <transition-group name="fade">
- <InputEditorContainer
- v-for="experimentInput in localExperiment.experiment_inputs"
- v-show="experimentInput.show"
- :key="experimentInput.name"
- v-model="experimentInput.value"
- :experiment-input="experimentInput"
- :experiment="localExperiment"
- @invalid="recordInvalidInputEditorValue(experimentInput.name)"
- @valid="recordValidInputEditorValue(experimentInput.name)"
- @input="inputValueChanged"
- @uploadstart="uploadStart(experimentInput.name)"
- @uploadend="uploadEnd(experimentInput.name)"
- />
- </transition-group>
- </div>
- </div>
- </div>
- </div>
- <GroupResourceProfileSelector
- v-model="localExperiment.user_configuration_data.group_resource_profile_id"
- @invalid="invalidGroupResourceProfileSelector = true"
- @valid="invalidGroupResourceProfileSelector = false"
- >
- </GroupResourceProfileSelector>
- <div class="row">
- <div class="col">
- <ComputationalResourceSchedulingEditor
- v-if="localExperiment.user_configuration_data.group_resource_profile_id"
- v-model="localExperiment.user_configuration_data.computational_resource_scheduling"
- :app-module-id="appModule.app_module_id"
- :group-resource-profile-id="
- localExperiment.user_configuration_data.group_resource_profile_id
- "
- @invalid="invalidComputationalResourceSchedulingEditor = true"
- @valid="invalidComputationalResourceSchedulingEditor = false"
- >
- </ComputationalResourceSchedulingEditor>
- </div>
- </div>
- <div class="row">
- <div class="col">
- <div class="mb-3" label="Email Settings">
- <div class="form-check">
- <input
- v-model="localExperiment.enable_email_notification"
- class="form-check-input"
- type="checkbox"
- />
- Receive email notification of experiment status
- </div>
- </div>
- </div>
- </div>
- <div class="row">
- <div id="col-exp-buttons" class="col">
- <button
- class="btn btn-success btn-sm"
- :disabled="isSaveDisabled"
- @click="saveAndLaunchExperiment"
- >
- Save and Launch
- </button>
- <button class="btn btn-primary btn-sm" :disabled="isSaveDisabled" @click="saveExperiment">
- Save
- </button>
- </div>
- </div>
- </form>
- </div>
-</template>
-
-<script setup lang="ts">
-import { ref, computed, watch, onMounted } from "vue";
-import ComputationalResourceSchedulingEditor from "./ComputationalResourceSchedulingEditor.vue";
-import ExperimentDescriptionEditor from "./ExperimentDescriptionEditor.vue";
-import GroupResourceProfileSelector from "./GroupResourceProfileSelector.vue";
-import InputEditorContainer from "./input-editors/InputEditorContainer.vue";
-import { models, services } from "django-airavata-api";
-import { components, utils } from "django-airavata-common-ui";
-import WorkspaceNoticesManagementContainer from "../notices/WorkspaceNoticesManagementContainer.vue";
-
-const ShareButton = components.ShareButton;
-const UnsavedChangesGuard = components.UnsavedChangesGuard;
-
-type Experiment = InstanceType<typeof models.Experiment>;
-type ApplicationModule = InstanceType<typeof models.ApplicationModule>;
-type ApplicationInterfaceDefinition = InstanceType<typeof models.ApplicationInterfaceDefinition>;
-
-interface ProjectOption {
- value: string;
- text: string;
-}
-
-const props = defineProps<{
- experiment: Experiment;
- appModule: ApplicationModule;
- appInterface: ApplicationInterfaceDefinition;
-}>();
-
-const emit = defineEmits<{
- saved: [experiment: Experiment];
- savedAndLaunched: [experiment: Experiment];
-}>();
-
-const shareButton = ref<{ mergeAndSave: (_id: string) => Promise<unknown> } | null>(null);
-
-const projects = ref<unknown[]>([]);
-const localExperiment = ref<Experiment>(props.experiment.clone() as Experiment);
-const invalidInputs = ref<string[]>([]);
-const invalidComputationalResourceSchedulingEditor = ref(false);
-const invalidGroupResourceProfileSelector = ref(false);
-const edited = ref(false);
-const saved_ = ref(false);
-const uploadingInputs = ref<string[]>([]);
-
-const sharedProjectOptions = computed<ProjectOption[]>(() =>
- (projects.value as Array<Record<string, unknown>>)
- .filter((p) => !p.is_owner)
- .map((project) => ({
- value: project.project_id as string,
- text: project.name + (!project.is_owner ? " (owned by " + project.owner + ")" : ""),
- })),
-);
-
-const myProjectOptions = computed<ProjectOption[]>(() =>
- (projects.value as Array<Record<string, unknown>>)
- .filter((p) => p.is_owner)
- .map((project) => ({
- value: project.project_id as string,
- text: project.name as string,
- })),
-);
-
-const valid = computed(() => {
- const validation = localExperiment.value.validate();
- return (
- Object.keys(validation).length === 0 &&
- invalidInputs.value.length === 0 &&
- !invalidComputationalResourceSchedulingEditor.value &&
- !invalidGroupResourceProfileSelector.value
- );
-});
-
-const isSaveDisabled = computed(() => !valid.value || hasUploadingInputs.value);
-
-const dirty = computed(() => edited.value && !saved_.value);
-
-const hasUploadingInputs = computed(() => uploadingInputs.value.length > 0);
-
-watch(
- () => props.experiment,
- (newValue) => {
- localExperiment.value = newValue.clone() as Experiment;
- },
-);
-
-watch(
- localExperiment,
- () => {
- edited.value = true;
- },
- { deep: true },
-);
-
-watch(
- () => (props.experiment as unknown as Record<string, unknown>).experiment_inputs,
- () => {
- experimentInputsChanged();
- },
- { deep: true },
-);
-
-watch(
- () => {
- const ucd = (props.experiment as unknown as {
- user_configuration_data?: {
- computational_resource_scheduling?: { resource_host_id?: string };
- };
- }).user_configuration_data;
- return ucd?.computational_resource_scheduling?.resource_host_id;
- },
- () => {
- resourceHostIdChanged();
- },
-);
-
-onMounted(() => {
- services.ProjectService.listAll().then((projs: unknown) => {
- projects.value = projs as unknown[];
- if (!localExperiment.value.project_id) {
- services.WorkspacePreferencesService.get().then((workspacePreferences: unknown) => {
- const prefs = workspacePreferences as Record<string, unknown>;
- if (!localExperiment.value.project_id) {
- localExperiment.value.project_id = prefs.most_recent_project_id as string;
- }
- });
- }
- });
-});
-
-function saveExperiment() {
- return saveOrUpdateExperiment().then((experiment: Experiment) => {
- localExperiment.value = experiment;
- emit("saved", experiment);
- });
-}
-
-function saveAndLaunchExperiment() {
- return saveOrUpdateExperiment().then((experiment: Experiment) => {
- localExperiment.value = experiment;
- return services.ExperimentService.launch({
- lookup: experiment.experiment_id,
- }).then(() => {
- emit("savedAndLaunched", experiment);
- });
- });
-}
-
-function saveOrUpdateExperiment(): Promise<Experiment> {
- if (localExperiment.value.experiment_id) {
- return services.ExperimentService.update({
- lookup: localExperiment.value.experiment_id,
- data: localExperiment.value,
- }).then((experiment: unknown) => {
- saved_.value = true;
- return experiment as Experiment;
- });
- } else {
- return services.ExperimentService.create({
- data: localExperiment.value,
- }).then((experiment: unknown) => {
- const exp = experiment as Experiment;
- saved_.value = true;
- return shareButton.value!
- .mergeAndSave(exp.experiment_id)
- .then(() => exp);
- });
- }
-}
-
-function getValidationFeedback(properties: string): unknown {
- return utils.getProperty(localExperiment.value.validate(), properties);
-}
-
-function getValidationState(properties: string): boolean | null {
- return getValidationFeedback(properties) ? false : null;
-}
-
-function recordInvalidInputEditorValue(experimentInputName: string) {
- if (!invalidInputs.value.includes(experimentInputName)) {
- invalidInputs.value.push(experimentInputName);
- }
-}
-
-function recordValidInputEditorValue(experimentInputName: string) {
- if (invalidInputs.value.includes(experimentInputName)) {
- const index = invalidInputs.value.indexOf(experimentInputName);
- invalidInputs.value.splice(index, 1);
- }
-}
-
-function uploadStart(experimentInputName: string) {
- if (!uploadingInputs.value.includes(experimentInputName)) {
- uploadingInputs.value.push(experimentInputName);
- }
-}
-
-function uploadEnd(experimentInputName: string) {
- if (uploadingInputs.value.includes(experimentInputName)) {
- const index = uploadingInputs.value.indexOf(experimentInputName);
- uploadingInputs.value.splice(index, 1);
- }
-}
-
-function inputValueChanged() {
- (localExperiment.value as unknown as { evaluateInputDependencies: () => void }).evaluateInputDependencies();
-}
-
-// Inline debounce wrapper for calculateQueueSettings
-let calcTimer: ReturnType<typeof setTimeout> | undefined;
-function calculateQueueSettings() {
- clearTimeout(calcTimer);
- calcTimer = setTimeout(async () => {
- const queueSettingsUpdate = await services.QueueSettingsCalculatorService.calculate(
- {
- lookup: (props.appInterface as unknown as Record<string, unknown>).queue_settings_calculator_id,
- data: localExperiment.value,
- },
- { showSpinner: false },
- );
- Object.assign(
- (localExperiment.value as unknown as Record<string, unknown> & { user_configuration_data: Record<string, unknown> }).user_configuration_data.computationalResourceScheduling as object,
- queueSettingsUpdate,
- );
- }, 500);
-}
-
-function experimentInputsChanged() {
- if ((props.appInterface as unknown as Record<string, unknown>).queue_settings_calculator_id) {
- calculateQueueSettings();
- }
-}
-
-function resourceHostIdChanged() {
- if ((props.appInterface as unknown as Record<string, unknown>).queue_settings_calculator_id) {
- calculateQueueSettings();
- }
-}
-</script>
-
-<style>
-.application-name {
- font-size: 12px;
-}
-
-#col-exp-buttons {
- text-align: right;
-}
-</style>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/GroupResourceProfileSelector.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/GroupResourceProfileSelector.vue
deleted file mode 100644
index ca3a591d4..000000000
--- a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/GroupResourceProfileSelector.vue
+++ /dev/null
@@ -1,114 +0,0 @@
-<template>
- <div class="row">
- <div class="col">
- <div class="mb-3" label="Allocation" label-for="group-resource-profile">
- <select
- id="group-resource-profile"
- v-model="groupResourceProfileId"
- class="form-select"
- required
- @change="emitValueChanged"
- >
- <option :value="null" disabled>Select an allocation</option>
- <option
- v-for="option in groupResourceProfileOptions"
- :key="option.value"
- :value="option.value"
- >
- {{ option.text }}
- </option>
- </select>
- </div>
- </div>
- </div>
-</template>
-
-<script setup lang="ts">
-import { ref, computed, onMounted } from "vue";
-import { services } from "django-airavata-api";
-
-interface GroupResourceProfile {
- group_resource_profile_id: string;
- group_resource_profile_name: string;
-}
-
-interface WorkspacePreferences {
- most_recent_project_resource_profile_id?: string;
-}
-
-const props = defineProps<{
- modelValue?: string | null;
-}>();
-
-const emit = defineEmits<{
- "update:modelValue": [value: string | null];
- valid: [];
- invalid: [];
-}>();
-
-const groupResourceProfileId = ref<string | null>(props.modelValue ?? null);
-const groupResourceProfiles = ref<GroupResourceProfile[]>([]);
-const workspacePreferences = ref<WorkspacePreferences | null>(null);
-
-const groupResourceProfileOptions = computed(() => {
- if (groupResourceProfiles.value.length > 0) {
- const options = groupResourceProfiles.value.map((grp) => ({
- value: grp.group_resource_profile_id,
- text: grp.group_resource_profile_name,
- }));
- options.sort((a, b) => a.text.localeCompare(b.text));
- return options;
- }
- return [];
-});
-
-const valid = computed(() => !!groupResourceProfileId.value);
-
-function emitValueChanged() {
- validate();
- emit("update:modelValue", groupResourceProfileId.value);
-}
-
-function validate() {
- if (!valid.value) {
- emit("invalid");
- } else {
- emit("valid");
- }
-}
-
-function selectedValueInGroupResourceProfileList(profiles: GroupResourceProfile[]) {
- return profiles.map((grp) => grp.group_resource_profile_id).indexOf(props.modelValue ?? "") >= 0;
-}
-
-function loadGroupResourceProfiles() {
- return services.ProjectResourceProfileService.list().then(
- (profiles: GroupResourceProfile[]) => {
- groupResourceProfiles.value = profiles;
- if (
- (!props.modelValue || !selectedValueInGroupResourceProfileList(profiles)) &&
- groupResourceProfiles.value.length > 0
- ) {
- // automatically select the last one user selected
- groupResourceProfileId.value =
- workspacePreferences.value?.most_recent_project_resource_profile_id ?? null;
- emitValueChanged();
- }
- },
- );
-}
-
-function loadWorkspacePreferences() {
- return services.WorkspacePreferencesService.get().then(
- (prefs: WorkspacePreferences) => (workspacePreferences.value = prefs),
- );
-}
-
-onMounted(async () => {
- await loadWorkspacePreferences();
- await loadGroupResourceProfiles();
- validate();
-});
-</script>
-
-<style></style>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/QueueSettingsEditor.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/QueueSettingsEditor.vue
deleted file mode 100644
index d5255364d..000000000
--- a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/QueueSettingsEditor.vue
+++ /dev/null
@@ -1,544 +0,0 @@
-<template>
- <div v-if="showQueueSettings">
- <div class="row">
- <div class="col">
- <div :class="['card border-default', { 'border-danger': !valid, 'is-disabled': disabled }]">
- <a
- class="card-link text-dark"
- :disabled="disabled"
- @click="showConfiguration = !showConfiguration"
- >
- <div class="card-body">
- <h5 class="card-title mb-4">Settings for queue {{ data.queue_name }}</h5>
- <div class="row">
- <div class="col">
- <h3 class="h5 mb-0">
- {{ data.node_count }}
- </h3>
- <span class="text-muted text-uppercase">NODE COUNT</span>
- </div>
- <div class="col">
- <h3 class="h5 mb-0">
- {{ data.total_cpu_count }}
- </h3>
- <span class="text-muted text-uppercase">CORE COUNT</span>
- </div>
- <div class="col">
- <h3 class="h5 mb-0">{{ data.wall_time_limit }} minutes</h3>
- <span class="text-muted text-uppercase">TIME LIMIT</span>
- </div>
- <div v-if="maxPhysicalMemory > 0" class="col">
- <h3 class="h5 mb-0">{{ data.total_physical_memory }} MB</h3>
- <span class="text-muted text-uppercase">PHYSICAL MEMORY</span>
- </div>
- </div>
- </div>
- </a>
- </div>
- </div>
- </div>
- <div v-if="showConfiguration">
- <div class="row">
- <div class="col">
- <form-group
- label="Select a Queue"
- label-for="queue"
- :invalid-feedback="getValidationFeedback('queue_name')"
- :state="getValidationState('queue_name')"
- >
- <select
- id="queue"
- v-model="data.queue_name"
- class="form-select"
- required
- @change="queueChanged"
- >
- <option v-for="opt in queueOptions" :key="opt.value" :value="opt.value">
- {{ opt.text }}
- </option>
- </select>
- <small class="form-text text-muted">{{ queueDescription }}</small>
- </form-group>
- </div>
- <div class="d-flex flex-row">
- <div class="flex-fill">
- <form-group
- label="Node Count"
- label-for="node-count"
- :invalid-feedback="getValidationFeedback('nodeCount')"
- :state="getValidationState('nodeCount', true)"
- >
- <input
- id="node-count"
- v-model="data.node_count"
- class="form-control"
- type="number"
- min="1"
- :max="maxNodes"
- required
- @input="nodeCountChanged"
- />
- <small class="form-text text-muted">
- <i class="fa fa-info-circle" aria-hidden="true"></i>
- Max Allowed Nodes = {{ maxNodes }}
- </small>
- </form-group>
- </div>
- <div class="flex-fill">
- <form-group
- label="Total Core Count"
- label-for="core-count"
- :invalid-feedback="getValidationFeedback('totalCPUCount')"
- :state="getValidationState('totalCPUCount', true)"
- >
- <input
- id="core-count"
- v-model="data.total_cpu_count"
- class="form-control"
- type="number"
- min="1"
- :max="maxCPUCount"
- required
- @input="cpuCountChanged"
- />
- <small class="form-text text-muted">
- <i class="fa fa-info-circle" aria-hidden="true"></i>
- Max Allowed Cores = {{ maxCPUCount
- }}<template v-if="selectedQueueDefault && selectedQueueDefault.cpu_per_node > 0"
- >. There are {{ selectedQueueDefault.cpu_per_node }} cores per node.
- </template>
- </small>
- </form-group>
- </div>
- </div>
- <div
- v-if="selectedQueueDefault && selectedQueueDefault.cpu_per_node > 0"
- class="d-flex flex-column"
- >
- <div
- class="flex-fill"
- style="
- border: 1px solid #6c757d;
- border-top-right-radius: 10px;
- margin-top: 51px;
- border-left-width: 0px;
- border-bottom-width: 0px;
- margin-right: 15px;
- "
- ></div>
- <button
- class="btn btn-sm btn-outline-secondary rounded-pill"
- @click="enableNodeCountToCpuCheck = !enableNodeCountToCpuCheck"
- >
- <i v-if="enableNodeCountToCpuCheck" class="fa fa-lock" aria-hidden="true"></i>
- <i v-else class="fa fa-unlock" aria-hidden="true"></i>
- </button>
- <div
- class="flex-fill"
- style="
- border: 1px solid #6c757d;
- border-bottom-right-radius: 10px;
- margin-bottom: 57px;
- border-left-width: 0px;
- border-top-width: 0px;
- margin-right: 15px;
- "
- ></div>
- </div>
- </div>
- <form-group
- label="Wall Time Limit"
- label-for="walltime-limit"
- :invalid-feedback="getValidationFeedback('wallTimeLimit')"
- :state="getValidationState('wallTimeLimit', true)"
- >
- <div class="input-group">
- <input
- id="walltime-limit"
- v-model="data.wall_time_limit"
- class="form-control"
- type="number"
- min="1"
- :max="maxWalltime"
- required
- />
- <span class="input-group-text">minutes</span>
- </div>
- <small class="form-text text-muted">
- <i class="fa fa-info-circle" aria-hidden="true"></i>
- Max Allowed Wall Time = {{ maxWalltime }} minutes
- </small>
- </form-group>
- <form-group
- v-if="maxPhysicalMemory > 0"
- label="Total Physical Memory"
- label-for="total-physical-memory"
- :invalid-feedback="getValidationFeedback('totalPhysicalMemory')"
- :state="getValidationState('totalPhysicalMemory', true)"
- >
- <div class="input-group">
- <input
- id="total-physical-memory"
- v-model="data.total_physical_memory"
- class="form-control"
- type="number"
- min="0"
- :max="maxPhysicalMemory"
- />
- <span class="input-group-text">MB</span>
- </div>
- <small class="form-text text-muted">
- <i class="fa fa-info-circle" aria-hidden="true"></i>
- Max Physical Memory = {{ maxPhysicalMemory }} MB
- </small>
- </form-group>
- <div>
- <a class="text-secondary action-link" href="#" @click.prevent="showConfiguration = false">
- <i class="fa fa-times text-secondary" aria-hidden="true"></i>
- Hide Settings</a
- >
- </div>
- </div>
- </div>
-</template>
-
-<script setup lang="ts">
-import { ref, computed, watch, onMounted } from "vue";
-import { models, services } from "django-airavata-api";
-import { utils } from "django-airavata-common-ui";
-
-type ComputationalResourceSchedulingModel = InstanceType<typeof models.ComputationalResourceSchedulingModel>;
-type ComputeResourcePolicy = InstanceType<typeof models.ComputeResourcePolicy>;
-
-interface QueueDefault {
- queue_name: string;
- queue_description?: string;
- max_processors: number;
- max_nodes: number;
- max_run_time: number;
- max_memory: number;
- default_cpu_count: number;
- default_node_count: number;
- default_walltime: number;
- is_default_queue: boolean;
- cpu_per_node: number;
-}
-
-interface BatchQueueResourcePolicy {
- queuename: string;
- maxAllowedCores: number;
- maxAllowedNodes: number;
- maxAllowedWalltime: number;
- resourcePolicyId?: string;
-}
-
-interface ApplicationInterface {
- show_queue_settings: boolean;
- queue_settings_calculator_id?: string;
-}
-
-const props = defineProps<{
- modelValue: ComputationalResourceSchedulingModel;
- appDeploymentId: string;
- appModuleId: string;
- computeResourcePolicy?: ComputeResourcePolicy | null;
- batchQueueResourcePolicies?: BatchQueueResourcePolicy[] | null;
-}>();
-
-const emit = defineEmits<{
- "update:modelValue": [value: ComputationalResourceSchedulingModel];
- valid: [];
- invalid: [];
- input: [value: ComputationalResourceSchedulingModel];
-}>();
-
-// VModelMixin inline
-function copyValue(value: ComputationalResourceSchedulingModel) {
- return value instanceof models.BaseModel ? (value as unknown as { clone: () => ComputationalResourceSchedulingModel }).clone() : value;
-}
-
-const data = ref<ComputationalResourceSchedulingModel>(copyValue(props.modelValue));
-
-watch(
- () => props.modelValue,
- (newValue) => {
- data.value = copyValue(newValue);
- },
- { deep: true },
-);
-
-watch(
- data,
- (newValue, oldValue) => {
- if (typeof props.modelValue === "object" && newValue === oldValue) {
- emit("update:modelValue", newValue);
- emit("input", newValue);
- } else if (
- (props.modelValue === null || typeof props.modelValue !== "object") &&
- newValue !== oldValue
- ) {
- emit("update:modelValue", newValue);
- emit("input", newValue);
- }
- },
- { deep: true },
-);
-
-const showConfiguration = ref(false);
-const appDeploymentQueues = ref<QueueDefault[] | null>(null);
-const enableNodeCountToCpuCheck = ref(true);
-const applicationInterface = ref<ApplicationInterface | null>(null);
-
-const queueOptions = computed(() =>
- queueDefaults.value.map((queueDefault) => ({
- value: queueDefault.queue_name,
- text: queueDefault.queue_name,
- })),
-);
-
-const selectedQueueDefault = computed<QueueDefault | undefined>(() =>
- queueDefaults.value.find((queue) => queue.queue_name === data.value.queue_name),
-);
-
-const batchQueueResourcePolicyComputed = computed<BatchQueueResourcePolicy | null>(() => {
- if (!selectedQueueDefault.value) return null;
- return getBatchQueueResourcePolicy(selectedQueueDefault.value.queue_name);
-});
-
-const maxCPUCount = computed<number>(() => {
- if (!selectedQueueDefault.value) return 0;
- const bqrp = batchQueueResourcePolicyComputed.value;
- if (bqrp) {
- return Math.min(bqrp.maxAllowedCores, selectedQueueDefault.value.max_processors);
- }
- return selectedQueueDefault.value.max_processors;
-});
-
-const maxNodes = computed<number>(() => {
- if (!selectedQueueDefault.value) return 0;
- const bqrp = batchQueueResourcePolicyComputed.value;
- if (bqrp) {
- return Math.min(bqrp.maxAllowedNodes, selectedQueueDefault.value.max_nodes);
- }
- return selectedQueueDefault.value.max_nodes;
-});
-
-const maxWalltime = computed<number>(() => {
- if (!selectedQueueDefault.value) return 0;
- const bqrp = batchQueueResourcePolicyComputed.value;
- if (bqrp) {
- return Math.min(bqrp.maxAllowedWalltime, selectedQueueDefault.value.max_run_time);
- }
- return selectedQueueDefault.value.max_run_time;
-});
-
-const maxPhysicalMemory = computed<number>(() =>
- selectedQueueDefault.value ? selectedQueueDefault.value.max_memory : 0,
-);
-
-const queueDefaults = computed<QueueDefault[]>(() => {
- if (!appDeploymentQueues.value) return [];
- return appDeploymentQueues.value
- .filter((q) => isQueueInComputeResourcePolicy(q.queue_name))
- .sort((a, b) => {
- if (a.is_default_queue) return -1;
- if (b.is_default_queue) return 1;
- return a.queue_name.localeCompare(b.queue_name);
- });
-});
-
-const validation = computed(() => {
- if (!selectedQueueDefault.value) {
- return data.value.validate();
- }
- return data.value.validate(selectedQueueDefault.value, batchQueueResourcePolicyComputed.value);
-});
-
-const valid = computed(() => Object.keys(validation.value).length === 0);
-
-const showQueueSettings = computed<boolean>(() =>
- applicationInterface.value ? applicationInterface.value.show_queue_settings : false,
-);
-
-const disabled = computed<boolean>(
- () => !!(applicationInterface.value && applicationInterface.value.queue_settings_calculator_id),
-);
-
-const queueDescription = computed<string | null>(() =>
- selectedQueueDefault.value ? (selectedQueueDefault.value.queue_description ?? null) : null,
-);
-
-watch(enableNodeCountToCpuCheck, () => {
- if (enableNodeCountToCpuCheck.value) {
- nodeCountChanged();
- }
-});
-
-watch(
- () => props.appDeploymentId,
- () => {
- loadAppDeploymentQueues().then(() => setDefaultQueue());
- },
-);
-
-watch(batchQueueResourcePolicyComputed, (value, oldValue) => {
- if (value && (!oldValue || value.resourcePolicyId !== oldValue.resourcePolicyId)) {
- applyBatchQueueResourcePolicy();
- }
-});
-
-watch(
- () => props.computeResourcePolicy,
- () => {
- if (!isQueueInComputeResourcePolicy(data.value.queue_name)) {
- setDefaultQueue();
- }
- },
-);
-
-watch(
- () => props.modelValue,
- () => {
- validate();
- },
- { deep: true },
-);
-
-onMounted(() => {
- loadAppDeploymentQueues().then(() => {
- if (!props.modelValue.queue_name) {
- setDefaultQueue();
- }
- });
- validate();
- loadApplicationInterface();
-});
-
-function queueChanged(event: Event) {
- const queueName = (event.target as HTMLSelectElement).value;
- const queueDefault = queueDefaults.value.find((queue) => queue.queue_name === queueName);
- if (queueDefault) {
- data.value.total_cpu_count = getDefaultCPUCount(queueDefault);
- data.value.node_count = getDefaultNodeCount(queueDefault);
- data.value.wall_time_limit = getDefaultWalltime(queueDefault);
- if (maxPhysicalMemory.value === 0) {
- data.value.total_physical_memory = 0;
- }
- }
-}
-
-function validate() {
- if (!valid.value) {
- emit("invalid");
- } else {
- emit("valid");
- }
-}
-
-function loadAppDeploymentQueues(): Promise<void> {
- return services.ApplicationDeploymentService.getQueues({
- lookup: props.appDeploymentId,
- }).then((queueDefaults: unknown) => {
- appDeploymentQueues.value = queueDefaults as QueueDefault[];
- });
-}
-
-function setDefaultQueue() {
- if (queueDefaults.value.length === 0) {
- data.value.queue_name = null;
- return;
- }
- const defaultQueue = queueDefaults.value[0];
- data.value.queue_name = defaultQueue.queue_name;
- data.value.total_cpu_count = getDefaultCPUCount(defaultQueue);
- data.value.node_count = getDefaultNodeCount(defaultQueue);
- data.value.wall_time_limit = getDefaultWalltime(defaultQueue);
- if (maxPhysicalMemory.value === 0) {
- data.value.total_physical_memory = 0;
- }
-}
-
-function isQueueInComputeResourcePolicy(queue_name: string): boolean {
- if (!props.computeResourcePolicy) return true;
- return (props.computeResourcePolicy as unknown as { allowedBatchQueues: string[] }).allowedBatchQueues.includes(queue_name);
-}
-
-function getBatchQueueResourcePolicy(queueName: string): BatchQueueResourcePolicy | null {
- if (!props.batchQueueResourcePolicies || props.batchQueueResourcePolicies.length === 0) {
- return null;
- }
- return props.batchQueueResourcePolicies.find((bqrp) => bqrp.queuename === queueName) ?? null;
-}
-
-function getDefaultCPUCount(queueDefault: QueueDefault): number {
- const bqrp = batchQueueResourcePolicyComputed.value;
- if (bqrp) {
- return Math.min(bqrp.maxAllowedCores, queueDefault.default_cpu_count);
- }
- return queueDefault.default_cpu_count;
-}
-
-function getDefaultNodeCount(queueDefault: QueueDefault): number {
- const bqrp = batchQueueResourcePolicyComputed.value;
- if (bqrp) {
- return Math.min(bqrp.maxAllowedNodes, queueDefault.default_node_count);
- }
- return queueDefault.default_node_count;
-}
-
-function getDefaultWalltime(queueDefault: QueueDefault): number {
- const bqrp = batchQueueResourcePolicyComputed.value;
- if (bqrp) {
- return Math.min(bqrp.maxAllowedWalltime, queueDefault.default_walltime);
- }
- return queueDefault.default_walltime;
-}
-
-function getValidationFeedback(properties: string): unknown {
- return utils.getProperty(validation.value, properties);
-}
-
-function getValidationState(properties: string, showValidState?: boolean): boolean | null {
- return getValidationFeedback(properties) ? false : showValidState ? true : null;
-}
-
-function applyBatchQueueResourcePolicy() {
- if (selectedQueueDefault.value) {
- data.value.total_cpu_count = Math.min(data.value.total_cpu_count, maxCPUCount.value);
- data.value.node_count = Math.min(data.value.node_count, maxNodes.value);
- data.value.wall_time_limit = Math.min(data.value.wall_time_limit, maxWalltime.value);
- }
-}
-
-function nodeCountChanged() {
- if (enableNodeCountToCpuCheck.value && selectedQueueDefault.value && selectedQueueDefault.value.cpu_per_node > 0) {
- const nodeCount = parseInt(String(data.value.node_count));
- data.value.total_cpu_count = Math.min(
- nodeCount * selectedQueueDefault.value.cpu_per_node,
- maxCPUCount.value,
- );
- }
-}
-
-function cpuCountChanged() {
- if (enableNodeCountToCpuCheck.value && selectedQueueDefault.value && selectedQueueDefault.value.cpu_per_node > 0) {
- const cpuCount = parseInt(String(data.value.total_cpu_count));
- if (cpuCount > 0) {
- data.value.node_count = Math.min(
- Math.ceil(cpuCount / selectedQueueDefault.value.cpu_per_node),
- maxNodes.value,
- );
- }
- }
-}
-
-function loadApplicationInterface() {
- services.ApplicationModuleService.getApplicationInterface({
- lookup: props.appModuleId,
- }).then((iface: unknown) => {
- applicationInterface.value = iface as ApplicationInterface;
- });
-}
-</script>
-
-<style></style>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/CreateExperimentContainer.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/CreateExperimentContainer.vue
deleted file mode 100644
index 7a0bfcaac..000000000
--- a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/CreateExperimentContainer.vue
+++ /dev/null
@@ -1,99 +0,0 @@
-<template>
- <experiment-editor
- v-if="experiment"
- :experiment="experiment"
- :app-module="appModule"
- :app-interface="appInterface"
- @saved="handleSavedExperiment"
- @saved-and-launched="handleSavedAndLaunchedExperiment"
- >
- <template #title>
- <span>Create a New Experiment</span>
- </template>
- </experiment-editor>
-</template>
-
-<script setup lang="ts">
-import { ref, onMounted } from "vue";
-import { services } from "django-airavata-api";
-import { notifications } from "django-airavata-common-ui";
-import ExperimentEditor from "../components/experiment/ExperimentEditor.vue";
-import urls from "../utils/urls";
-
-import { formatShort } from "django-airavata-common-ui/js/utils/dates.js";
-
-const props = defineProps<{
- appModuleId?: string;
- userInputValues?: Record<string, unknown> | null;
- experimentDataDir?: string | null;
-}>();
-
-// Convenience accessors
-const appModuleId = () => props.appModuleId;
-const userInputValues = () => props.userInputValues;
-const experimentDataDir = () => props.experimentDataDir;
-
-const experiment = ref<unknown>(null);
-const appModule = ref<unknown>(null);
-const appInterface = ref<unknown>(null);
-
-onMounted(() => {
- const modId = appModuleId();
- if (!modId) return;
- const loadAppModule = services.ApplicationModuleService.retrieve(
- { lookup: modId },
- { ignoreErrors: true },
- );
- const loadAppInterface = services.ApplicationModuleService.getApplicationInterface(
- { lookup: modId },
- { ignoreErrors: true },
- );
- Promise.all([loadAppModule, loadAppInterface])
- .then(([mod, iface]: [unknown, unknown]) => {
- const typedMod = mod as { app_module_name: string };
- const typedIface = iface as {
- createExperiment(): unknown;
- };
- const exp = typedIface.createExperiment() as {
- experiment_name: string;
- experiment_inputs: Array<{ name: string; value: unknown }>;
- user_configuration_data: { experiment_data_dir: string };
- };
- exp.experiment_name = typedMod.app_module_name + " on " + formatShort(new Date());
- appModule.value = mod;
- appInterface.value = iface;
- const inputValues = userInputValues();
- if (inputValues) {
- Object.keys(inputValues).forEach((k) => {
- const experimentInput = exp.experiment_inputs.find((inp) => inp.name === k);
- if (experimentInput) {
- experimentInput.value = inputValues[k];
- }
- });
- }
- const dataDir = experimentDataDir();
- if (dataDir) {
- exp.user_configuration_data.experiment_data_dir = dataDir;
- }
- experiment.value = exp;
- })
- .catch((error: unknown) => {
- notifications.NotificationList.addError(error);
- });
-});
-
-function handleSavedExperiment() {
- urls.navigateToExperimentsList("");
-}
-
-function handleSavedAndLaunchedExperiment(exp: unknown) {
- urls.navigateToViewExperiment("", exp as { experiment_id: string }, { launching: true });
-}
-</script>
-
-<style>
-/* style the containing div, in base.html template */
-.main-content-wrapper {
- background-color: #ffffff;
-}
-</style>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/EditExperimentContainer.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/EditExperimentContainer.vue
deleted file mode 100644
index a5566322c..000000000
--- a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/EditExperimentContainer.vue
+++ /dev/null
@@ -1,78 +0,0 @@
-<template>
- <experiment-editor
- v-if="appModule"
- :experiment="experiment"
- :app-module="appModule"
- :app-interface="appInterface"
- @saved="handleSavedExperiment"
- @saved-and-launched="handleSavedAndLaunchedExperiment"
- >
- <template #title>
- <span>Edit Experiment</span>
- </template>
- </experiment-editor>
-</template>
-
-<script setup lang="ts">
-import { ref, onMounted } from "vue";
-import { errors, services } from "django-airavata-api";
-import { notifications } from "django-airavata-common-ui";
-import ExperimentEditor from "../components/experiment/ExperimentEditor.vue";
-import urls from "../utils/urls";
-
-const props = defineProps<{
- experimentId: string;
-}>();
-
-const experiment = ref<unknown>(null);
-const appModule = ref<unknown>(null);
-const appInterface = ref<unknown>(null);
-
-onMounted(() => {
- services.ExperimentService.retrieve({ lookup: props.experimentId })
- .then((exp: unknown) => {
- experiment.value = exp;
- const appInterfaceId = (exp as { execution_id: string }).execution_id;
- return services.ApplicationInterfaceService.retrieve(
- { lookup: appInterfaceId },
- { ignoreErrors: true },
- );
- })
- .then((iface: unknown) => {
- appInterface.value = iface;
- const appModuleId = (iface as { application_modules: string[] }).application_modules[0];
- return services.ApplicationModuleService.retrieve({ lookup: appModuleId });
- })
- .then((mod: unknown) => {
- appModule.value = mod;
- })
- .catch((error: unknown) => {
- const exp = experiment.value as { execution_id: string } | null;
- const message = errors.ErrorUtils.isNotFoundError(error)
- ? `Application interface (${exp?.execution_id}) was not found.
- If it has been deleted then you won't be able to edit this experiment.`
- : `Unable to load application interface (${exp?.execution_id}) or module`;
- notifications.NotificationList.add(
- new notifications.Notification({
- type: "ERROR",
- message,
- }),
- );
- });
-});
-
-function handleSavedExperiment() {
- urls.navigateToExperimentsList("");
-}
-
-function handleSavedAndLaunchedExperiment(exp: unknown) {
- urls.navigateToViewExperiment("", exp as { experiment_id: string }, { launching: true });
-}
-</script>
-
-<style>
-/* style the containing div, in base.html template */
-.main-content-wrapper {
- background-color: #ffffff;
-}
-</style>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/entry-create-experiment.js b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/entry-create-experiment.js
deleted file mode 100644
index 9d0d999b4..000000000
--- a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/entry-create-experiment.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import { h } from "vue";
-import { components, entry } from "django-airavata-common-ui";
-import CreateExperimentContainer from "./containers/CreateExperimentContainer.vue";
-import "../../scss/styles.scss";
-
-entry(({ createApp }) => {
- const el = document.getElementById("create-experiment");
- const appModuleId = el ? el.dataset.appModuleId || null : null;
- const userInputValues =
- el && el.dataset.userInputValues ? JSON.parse(el.dataset.userInputValues) : null;
- const experimentDataDir = el ? el.dataset.experimentDataDir || null : null;
- const app = createApp({
- data() {
- return {
- appModuleId,
- userInputValues,
- experimentDataDir,
- };
- },
- render() {
- return h(components.MainLayout, null, {
- default: () =>
- h(CreateExperimentContainer, {
- appModuleId: this.appModuleId,
- userInputValues: this.userInputValues,
- experimentDataDir: this.experimentDataDir,
- }),
- });
- },
- });
- app.mount("#create-experiment");
-});
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/entry-edit-experiment.js b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/entry-edit-experiment.js
deleted file mode 100644
index 4332b258e..000000000
--- a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/entry-edit-experiment.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import { h } from "vue";
-import { components, entry } from "django-airavata-common-ui";
-import EditExperimentContainer from "./containers/EditExperimentContainer.vue";
-import "../../scss/styles.scss";
-
-entry(({ createApp }) => {
- const el = document.getElementById("edit-experiment");
- const experimentId = el ? el.dataset.experimentId || null : null;
- const app = createApp({
- data() {
- return {
- experimentId,
- };
- },
- render() {
- return h(components.MainLayout, null, {
- default: () =>
- h(EditExperimentContainer, {
- experimentId: this.experimentId,
- }),
- });
- },
- });
- app.mount("#edit-experiment");
-});
diff --git a/airavata-django-portal/django_airavata/apps/workspace/urls.py b/airavata-django-portal/django_airavata/apps/workspace/urls.py
index d493334b5..5e1218575 100644
--- a/airavata-django-portal/django_airavata/apps/workspace/urls.py
+++ b/airavata-django-portal/django_airavata/apps/workspace/urls.py
@@ -23,8 +23,8 @@ urlpatterns = [
),
re_path(
r"^projects/(?P<project_id>[^/]+)/experiments/(?P<experiment_id>[^/]+)/edit$",
- views.edit_experiment,
- name="edit_experiment",
+ RedirectView.as_view(url="/workspace/launch", permanent=True),
+ name="edit_experiment_redirect",
),
re_path(
r"^projects/(?P<project_id>[^/]+)/experiments/(?P<experiment_id>[^/]+)/$",
diff --git a/airavata-django-portal/django_airavata/apps/workspace/views.py b/airavata-django-portal/django_airavata/apps/workspace/views.py
index cfdf3010d..54ecdaa1a 100644
--- a/airavata-django-portal/django_airavata/apps/workspace/views.py
+++ b/airavata-django-portal/django_airavata/apps/workspace/views.py
@@ -1,22 +1,16 @@
import json
import logging
-from urllib.parse import urlparse
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
-from django.utils.module_loading import import_string
from rest_framework.renderers import JSONRenderer
-from django_airavata.apps.api import models
-from django_airavata.apps.api import user_storage as user_storage_sdk
from django_airavata.apps.api.views import (
- ApplicationModuleViewSet,
ExperimentSearchViewSet,
FullExperimentViewSet,
ProjectViewSet,
)
-from django_airavata.proto_compat import DataType
logger = logging.getLogger(__name__)
@@ -27,8 +21,6 @@ ENTRY_POINTS = {
"project-list": "static/django_airavata_workspace/js/entry-project-list.js",
"project-overview": "static/django_airavata_workspace/js/entry-project-overview.js",
"edit-project": "static/django_airavata_workspace/js/entry-edit-project.js",
- "create-experiment": "static/django_airavata_workspace/js/entry-create-experiment.js",
- "edit-experiment": "static/django_airavata_workspace/js/entry-edit-experiment.js",
"view-experiment": "static/django_airavata_workspace/js/entry-view-experiment.js",
"user-storage": "static/django_airavata_workspace/js/entry-user-storage.js",
"compute": "static/django_airavata_workspace/js/entry-compute.js",
@@ -110,19 +102,6 @@ def _create_default_project(request):
return project_id
-@login_required
-def applications(request):
- request.active_nav_item = "applications"
- return render(
- request,
- "django_airavata_workspace/base.html",
- {
- "bundle_name": "applications",
- "entry_point": ENTRY_POINTS["applications"],
- },
- )
-
-
@login_required
def new_application(request):
request.active_nav_item = "applications"
@@ -185,105 +164,6 @@ def project_overview(request, project_id):
)
-@login_required
-def create_experiment(request, app_module_id):
- request.active_nav_item = "applications"
-
- # User input files can be passed as query parameters
- # <input name>=<path/to/user_file>
- # and also as data product URIs
- # <input name>=<data product URI>
- app_interface = ApplicationModuleViewSet.as_view({"get": "application_interface"})(
- request, app_module_id=app_module_id
- )
- if app_interface.status_code != 200:
- raise Exception("Failed to load application module data: {}".format(app_interface.data["detail"]))
- user_input_values = {}
- for app_input in app_interface.data.get("application_inputs", []):
- if app_input["type"] == DataType.URI and app_input["name"] in request.GET:
- user_file_value = request.GET[app_input["name"]]
- try:
- user_file_url = urlparse(user_file_value)
- if user_file_url.scheme == "airavata-dp":
- dp_uri = user_file_value
- try:
- data_product = request.airavata_client.research.get_data_product(dp_uri)
- if user_storage_sdk.exists(request, data_product):
- user_input_values[app_input["name"]] = dp_uri
- except Exception:
- logger.exception(f"Failed checking data product uri: {dp_uri}", extra={"request": request})
- except ValueError:
- logger.exception(f"Invalid user file value: {user_file_value}", extra={"request": request})
- elif app_input["type"] == DataType.STRING and app_input["name"] in request.GET:
- name = app_input["name"]
- user_input_values[name] = request.GET[name]
- context = {
- "bundle_name": "create-experiment",
- "entry_point": ENTRY_POINTS["create-experiment"],
- "app_module_id": app_module_id,
- "user_input_values": json.dumps(user_input_values),
- }
- if "experiment-data-dir" in request.GET:
- context["experiment_data_dir"] = request.GET["experiment-data-dir"]
-
- template_path = "django_airavata_workspace/create_experiment.html"
- # Apply a custom application template if it exists
- custom_template_path, custom_context = get_custom_template(request, app_module_id)
- if custom_template_path is not None:
- logger.debug(f"Applying custom application template {custom_template_path}")
- template_path = custom_template_path
- context.update(custom_context)
-
- return render(request, template_path, context)
-
-
-@login_required
-def edit_experiment(request, project_id, experiment_id):
- request.active_nav_item = "projects"
-
- project = request.airavata_client.research.get_project(project_id)
- experiment = request.airavata_client.research.get_experiment(experiment_id)
- applicationInterface = request.airavata_client.research.get_application_interface(experiment.execution_id)
- app_module_id = applicationInterface.application_modules[0]
-
- breadcrumbs = [
- {"label": "Projects", "url": "/workspace/projects"},
- {"label": project.name, "url": f"/workspace/projects/{project_id}/"},
- {"label": "Experiments", "url": f"/workspace/projects/{project_id}/experiments"},
- {"label": "Edit Experiment", "url": None},
- ]
-
- context = {
- "bundle_name": "edit-experiment",
- "entry_point": ENTRY_POINTS["edit-experiment"],
- "experiment_id": experiment_id,
- "app_module_id": app_module_id,
- "project_id": project_id,
- "breadcrumbs_json": json.dumps(breadcrumbs),
- }
- template_path = "django_airavata_workspace/edit_experiment.html"
- custom_template_path, custom_context = get_custom_template(request, app_module_id)
- if custom_template_path is not None:
- logger.debug(f"Applying custom application template {custom_template_path}")
- template_path = custom_template_path
- context.update(custom_context)
-
- return render(request, template_path, context)
-
-
-def get_custom_template(request, app_module_id):
- template_path = None
- context = {}
- query = models.ApplicationTemplate.objects.filter(application_module_id=app_module_id)
- if query.exists():
- application_template = query.get()
- template_path = application_template.template_path
- for context_processor in application_template.context_processors.all():
- context_processor = import_string(context_processor.callable_path)
- context.update(context_processor(request))
- return template_path, context
-
-
@login_required
def view_experiment(request, project_id, experiment_id):
request.active_nav_item = "projects"
diff --git a/airavata-django-portal/django_airavata/apps/workspace/vite.config.js b/airavata-django-portal/django_airavata/apps/workspace/vite.config.js
index a73d02e6a..2d23675da 100644
--- a/airavata-django-portal/django_airavata/apps/workspace/vite.config.js
+++ b/airavata-django-portal/django_airavata/apps/workspace/vite.config.js
@@ -10,10 +10,8 @@ export default defineAppConfig({
dashboard: resolve(srcDir, "entry-dashboard.js"),
"project-list": resolve(srcDir, "entry-project-list.js"),
applications: resolve(srcDir, "entry-applications.js"),
- "create-experiment": resolve(srcDir, "entry-create-experiment.js"),
"view-experiment": resolve(srcDir, "entry-view-experiment.js"),
"experiment-list": resolve(srcDir, "entry-experiment-list.js"),
- "edit-experiment": resolve(srcDir, "entry-edit-experiment.js"),
"edit-project": resolve(srcDir, "entry-edit-project.js"),
"user-storage": resolve(srcDir, "entry-user-storage.js"),
compute: resolve(srcDir, "entry-compute.js"),
(airavata-portals) 33/34: chore(launcher): default FEATURE_GENERIC_LAUNCHER to on
Posted by ya...@apache.org.
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 f6d95cc63a43aaf887c5d83cb9219558bb94443f
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 23:27:36 2026 -0400
chore(launcher): default FEATURE_GENERIC_LAUNCHER to on
---
airavata-django-portal/django_airavata/settings.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/airavata-django-portal/django_airavata/settings.py b/airavata-django-portal/django_airavata/settings.py
index 0a1b99268..991d6ef3a 100644
--- a/airavata-django-portal/django_airavata/settings.py
+++ b/airavata-django-portal/django_airavata/settings.py
@@ -625,7 +625,7 @@ if 'GATEWAY_ID' not in dir():
# Generic experiment launcher feature flags
-FEATURE_GENERIC_LAUNCHER = os.environ.get("FEATURE_GENERIC_LAUNCHER", "False").lower() == "true"
+FEATURE_GENERIC_LAUNCHER = os.environ.get("FEATURE_GENERIC_LAUNCHER", "True").lower() == "true"
LAUNCHER_CLIENT_STUB = os.environ.get("LAUNCHER_CLIENT_STUB", "True").lower() == "true"
(airavata-portals) 23/34: feat(launcher): Tab2Runtime assembly + readouts
Posted by ya...@apache.org.
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 7e7ddf4778719a0cf5d4892fa7dc32db08bf2cde
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 22:55:06 2026 -0400
feat(launcher): Tab2Runtime assembly + readouts
---
.../js/components/launch/Tab2Runtime.vue | 47 ++++++++++++++++++-
.../unit/components/launch/Tab2Runtime.spec.ts | 52 ++++++++++++++++++++++
2 files changed, 97 insertions(+), 2 deletions(-)
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab2Runtime.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab2Runtime.vue
index 25a445eed..cb7733c2f 100644
--- a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab2Runtime.vue
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab2Runtime.vue
@@ -1,2 +1,45 @@
-<template><div /></template>
-<script setup lang="ts"></script>
+<template>
+ <div v-if="store.profile">
+ <RuntimeInputs
+ :profile="store.profile"
+ :model-value="store.draft.runtime"
+ @update:model-value="store.setRuntime($event)"
+ />
+ <p class="small text-muted mt-2">
+ Allocation <code>{{ store.profile.allocation_id }}</code> (auto from project) ·
+ <span v-if="pickedCR">
+ Resource <code>{{ pickedCR.compute_resource_id }}</code> ·
+ Compute storage <code>{{ pickedCR.mapped_storage.storage_id }}</code>
+ scratch <code>{{ pickedCR.mapped_storage.scratch_path }}</code>
+ </span>
+ </p>
+ </div>
+ <div v-else class="text-muted">Loading resource profile…</div>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, watch } from "vue";
+import { useLaunchStore } from "django-airavata-common-ui/js/stores/launch";
+import { launcherService } from "django-airavata-common-ui/js/services/launcherService";
+import RuntimeInputs from "./runtime/RuntimeInputs.vue";
+
+const store = useLaunchStore();
+
+async function refetch(projectId: string) {
+ const p = await launcherService.getProjectResourceProfile(projectId);
+ store.setProfile(p);
+}
+
+onMounted(() => {
+ if (store.draft.project_id) void refetch(store.draft.project_id);
+});
+
+watch(
+ () => store.draft.project_id,
+ (id) => { if (id) void refetch(id); else store.setProfile(null); },
+);
+
+const pickedCR = computed(() =>
+ store.profile?.compute_resources.find((c) => c.compute_resource_id === store.draft.runtime.compute_resource_id) ?? null,
+);
+</script>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/Tab2Runtime.spec.ts b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/Tab2Runtime.spec.ts
new file mode 100644
index 000000000..8d59fe6c9
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/Tab2Runtime.spec.ts
@@ -0,0 +1,52 @@
+import { mount, flushPromises } from "@vue/test-utils";
+import { createPinia, setActivePinia } from "pinia";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import Tab2Runtime from "../../../../js/components/launch/Tab2Runtime.vue";
+import { useLaunchStore } from "django-airavata-common-ui/js/stores/launch";
+
+const PROFILE = {
+ project_id: "p1", allocation_id: "NSF-1",
+ compute_resources: [{
+ compute_resource_id: "bridges-2", name: "Bridges-2",
+ mapped_storage: { storage_id: "scratch", scratch_path: "/scratch/p1" },
+ partitions: [{ name: "RM", max_walltime: "48:00:00", max_nodes: 64, cpus_per_node: 128 }],
+ }],
+};
+
+vi.mock("django-airavata-common-ui/js/services/launcherService", () => ({
+ launcherService: {
+ getProjectResourceProfile: vi.fn(),
+ },
+}));
+
+import { launcherService } from "django-airavata-common-ui/js/services/launcherService";
+
+describe("Tab2Runtime", () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ vi.mocked(launcherService.getProjectResourceProfile).mockResolvedValue(PROFILE);
+ useLaunchStore().setMeta({ name: "x", project_id: "p1", description: "" });
+ });
+
+ it("renders the readout once a CR is picked", async () => {
+ const w = mount(Tab2Runtime);
+ await flushPromises();
+ useLaunchStore().setRuntime({
+ compute_resource_id: "bridges-2", partition: "RM",
+ walltime: "01:00:00", nodes: 1, cpus_per_node: 8,
+ });
+ await flushPromises();
+ expect(w.text()).toContain("NSF-1");
+ expect(w.text()).toContain("bridges-2");
+ expect(w.text()).toContain("/scratch/p1");
+ });
+
+ it("re-fetches the profile when project changes", async () => {
+ const w = mount(Tab2Runtime);
+ await flushPromises();
+ useLaunchStore().setMeta({ name: "x", project_id: "p2", description: "" });
+ await flushPromises();
+ expect(launcherService.getProjectResourceProfile).toHaveBeenCalledWith("p2");
+ expect(w).toBeTruthy();
+ });
+});
(airavata-portals) 24/34: feat(launcher): ScriptPreview + InvocationCommand presentational components
Posted by ya...@apache.org.
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 939c109c3bc332f48993a7356ad6d8955c539dc8
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 22:56:37 2026 -0400
feat(launcher): ScriptPreview + InvocationCommand presentational components
---
.../js/components/launch/InvocationCommand.vue | 9 +++++++++
.../js/components/launch/ScriptPreview.vue | 7 +++++++
.../unit/components/launch/ScriptPreview.spec.ts | 23 ++++++++++++++++++++++
3 files changed, 39 insertions(+)
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/InvocationCommand.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/InvocationCommand.vue
new file mode 100644
index 000000000..abb857fc4
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/InvocationCommand.vue
@@ -0,0 +1,9 @@
+<template>
+ <div class="text-muted small mb-2">
+ Invocation: <code>{{ command }}</code>
+ </div>
+</template>
+
+<script setup lang="ts">
+defineProps<{ command: string }>();
+</script>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/ScriptPreview.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/ScriptPreview.vue
new file mode 100644
index 000000000..9c8bbeff2
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/ScriptPreview.vue
@@ -0,0 +1,7 @@
+<template>
+ <pre class="bg-dark text-light p-3 rounded small mb-0" style="overflow-x: auto;"><code>{{ script }}</code></pre>
+</template>
+
+<script setup lang="ts">
+defineProps<{ script: string }>();
+</script>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/ScriptPreview.spec.ts b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/ScriptPreview.spec.ts
new file mode 100644
index 000000000..c5bdf0bc6
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/ScriptPreview.spec.ts
@@ -0,0 +1,23 @@
+import { mount } from "@vue/test-utils";
+import { describe, expect, it } from "vitest";
+import ScriptPreview from "../../../../js/components/launch/ScriptPreview.vue";
+import InvocationCommand from "../../../../js/components/launch/InvocationCommand.vue";
+
+describe("ScriptPreview", () => {
+ it("renders the script in a code block", () => {
+ const w = mount(ScriptPreview, { props: { script: "#!/bin/bash\necho hi\n" } });
+ expect(w.find("pre code").text()).toContain("echo hi");
+ });
+
+ it("is read-only (no contenteditable)", () => {
+ const w = mount(ScriptPreview, { props: { script: "x" } });
+ expect(w.find("pre").attributes("contenteditable")).toBeUndefined();
+ });
+});
+
+describe("InvocationCommand", () => {
+ it("renders the command", () => {
+ const w = mount(InvocationCommand, { props: { command: "sbatch /tmp/run.sh" } });
+ expect(w.text()).toContain("sbatch /tmp/run.sh");
+ });
+});
(airavata-portals) 03/34: docs(feat/generic-launcher): implementation plan (28 tasks across 9 phases)
Posted by ya...@apache.org.
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 15edd66b82edfd08ad22eb97c74609c13f00132c
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 19:38:29 2026 -0400
docs(feat/generic-launcher): implementation plan (28 tasks across 9 phases)
---
.../2026-04-24-generic-experiment-launcher-plan.md | 4040 ++++++++++++++++++++
1 file changed, 4040 insertions(+)
diff --git a/airavata-django-portal/docs/superpowers/plans/2026-04-24-generic-experiment-launcher-plan.md b/airavata-django-portal/docs/superpowers/plans/2026-04-24-generic-experiment-launcher-plan.md
new file mode 100644
index 000000000..dc7c6ec3a
--- /dev/null
+++ b/airavata-django-portal/docs/superpowers/plans/2026-04-24-generic-experiment-launcher-plan.md
@@ -0,0 +1,4040 @@
+# Generic Experiment Launcher Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace the per-application experiment launch flow with a single `/workspace/launch` 3-tab wizard backed by new Django REST endpoints and a Pinia store.
+
+**Architecture:** Vue 3 `<script setup lang="ts">` SPA mounted at `/workspace/launch`. Tabs are dumb views over a `useLaunchStore` Pinia store that owns the experiment draft. Tab 1 owns app pick + interface pick + scalar/file inputs + file outputs (each file row carries its own storage + path). Tab 2 owns runtime (compute + partition + walltime + nodes + CPUs, profile-driven from the picked project). Tab 3 fetches a server-rendered preview script via a new dry-run RPC and submits via the [...]
+
+**Tech Stack:** Vue 3, Pinia 3, TypeScript strict, Vite 6, Vitest + jsdom + `@vue/test-utils`, Playwright, Django 5.1, DRF, Thrift Airavata client (stubbed via `launcher_client.py`).
+
+**Spec:** [`2026-04-24-generic-experiment-launcher-design.md`](../specs/2026-04-24-generic-experiment-launcher-design.md)
+
+---
+
+## File Map
+
+### Created
+
+**Backend (Python / Django)**
+- `django_airavata/apps/api/launcher_client.py` — abstraction layer over the airavata gRPC calls (real + stub).
+- `django_airavata/apps/api/launcher_serializers.py` — DRF serializers for app, interface, draft, preview.
+- `django_airavata/apps/api/launcher_views.py` — DRF views for the launcher API surface.
+- `django_airavata/apps/api/tests/test_launcher_views.py` — Django TestCase for the new views.
+- `django_airavata/apps/api/tests/test_launcher_client.py` — unit tests for the stub + serialization.
+- `django_airavata/apps/workspace/templates/django_airavata_workspace/launch.html` — minimal SPA shell.
+- `tests/contracts/experiment-draft.schema.json` — JSON schema for draft payload.
+- `tests/contracts/preview-response.schema.json` — JSON schema for preview response.
+
+**Frontend (Vue / TS)**
+- `django_airavata/apps/workspace/static/django_airavata_workspace/js/entry-launch.ts`
+- `django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/LaunchContainer.vue`
+- `django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/ExperimentMetaHeader.vue`
+- `…/js/components/launch/WizardTabs.vue`
+- `…/js/components/launch/Tab1ApplicationInputs.vue`
+- `…/js/components/launch/AppPicker.vue`
+- `…/js/components/launch/InterfacePicker.vue`
+- `…/js/components/launch/InputList.vue`
+- `…/js/components/launch/ScalarInputRow.vue`
+- `…/js/components/launch/FileInputRow.vue`
+- `…/js/components/launch/OutputList.vue`
+- `…/js/components/launch/FileOutputRow.vue`
+- `…/js/components/launch/Tab2Runtime.vue`
+- `…/js/components/launch/runtime/RuntimeInputs.vue`
+- `…/js/components/launch/Tab3ReviewLaunch.vue`
+- `…/js/components/launch/InvocationCommand.vue`
+- `…/js/components/launch/ScriptPreview.vue`
+- `django_airavata/static/common/js/stores/launch.ts` — Pinia store for the wizard.
+- `django_airavata/static/common/js/stores/launch-types.ts` — typed interfaces for Application / Interface / Draft / Preview.
+- `…/static/common/js/services/launcherService.ts` — typed fetch wrappers around the new endpoints.
+- `…/tests/unit/components/launch/*.spec.ts` — one spec per component (mirrors src tree).
+- `…/tests/unit/stores/launch.spec.ts` — Pinia store spec.
+- `…/tests/unit/integration/launch-flow.spec.ts` — full happy-path integration test with API mocked.
+- `tests/e2e/specs/launch-happy.spec.ts`
+- `tests/e2e/specs/launch-error-paths.spec.ts`
+
+### Modified
+- `django_airavata/apps/api/urls.py` — register new `launcher_views` endpoints.
+- `django_airavata/apps/workspace/views.py` — `launch(request)` view, removal of `create_experiment` + `edit_experiment` views.
+- `django_airavata/apps/workspace/urls.py` — new route + 301 redirects for old routes.
+- `django_airavata/settings.py` — `FEATURE_GENERIC_LAUNCHER` flag, `LAUNCHER_CLIENT_STUB` flag.
+- `tests/e2e/specs/smoke.spec.ts` — extend `AUTHENTICATED_PAGES` with `/workspace/launch`.
+- Repo-internal call sites of the old URL listed in Task 24.
+
+### Deleted (Task 26)
+- `django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/CreateExperimentContainer.vue`
+- `…/js/containers/EditExperimentContainer.vue`
+- `…/js/components/experiment/ExperimentEditor.vue`
+- `…/js/components/experiment/ComputationalResourceSchedulingEditor.vue`
+- `…/js/components/experiment/QueueSettingsEditor.vue`
+- `…/js/components/experiment/GroupResourceProfileSelector.vue`
+- `…/js/entry-create-experiment.js`
+- `…/js/entry-edit-experiment.js`
+
+---
+
+## Phase 1 — Backend foundations
+
+### Task 1: Settings flags + launcher_client stub layer
+
+**Files:**
+- Create: `django_airavata/apps/api/launcher_client.py`
+- Create: `django_airavata/apps/api/tests/test_launcher_client.py`
+- Modify: `django_airavata/settings.py`
+
+- [ ] **Step 1: Add settings flags**
+
+Append to `django_airavata/settings.py`:
+
+```python
+# Generic experiment launcher feature flags
+FEATURE_GENERIC_LAUNCHER = os.environ.get("FEATURE_GENERIC_LAUNCHER", "False").lower() == "true"
+LAUNCHER_CLIENT_STUB = os.environ.get("LAUNCHER_CLIENT_STUB", "True").lower() == "true"
+```
+
+`LAUNCHER_CLIENT_STUB` defaults `True` so dev + CI work before the airavata server's new-model RPCs land. Phase 9 flips it to `False` once they do.
+
+- [ ] **Step 2: Write the failing client tests**
+
+Create `django_airavata/apps/api/tests/test_launcher_client.py`:
+
+```python
+from unittest import TestCase
+
+from django.test import override_settings
+
+from django_airavata.apps.api import launcher_client
+
+
+class StubLauncherClientTest(TestCase):
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_list_applications_returns_at_least_one_app(self):
+ client = launcher_client.get_client(user_token="ignored")
+ apps = client.list_applications(category=None, search=None)
+ self.assertTrue(len(apps) >= 1)
+ first = apps[0]
+ self.assertIn("app_id", first)
+ self.assertIn("name", first)
+ self.assertIn("content", first)
+ self.assertIn("interfaces", first)
+ self.assertIsInstance(first["interfaces"], list)
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_get_resource_profile_returns_partitions(self):
+ client = launcher_client.get_client(user_token="ignored")
+ profile = client.get_project_resource_profile(project_id="proj-stub")
+ self.assertIn("compute_resources", profile)
+ self.assertIn("allocation_id", profile)
+ self.assertTrue(len(profile["compute_resources"]) >= 1)
+ self.assertIn("partitions", profile["compute_resources"][0])
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_generate_preview_returns_script_and_command(self):
+ client = launcher_client.get_client(user_token="ignored")
+ preview = client.generate_preview({
+ "name": "x",
+ "project_id": "proj-stub",
+ "app_id": "app-stub",
+ "interface_name": "run",
+ "inputs": {},
+ "outputs": {},
+ "runtime": {
+ "compute_resource_id": "cr-stub",
+ "partition": "RM",
+ "walltime": "01:00:00",
+ "nodes": 1,
+ "cpus_per_node": 8,
+ },
+ })
+ self.assertIn("invocation_command", preview)
+ self.assertIn("script_contents", preview)
+ self.assertIn("warnings", preview)
+ self.assertIsInstance(preview["warnings"], list)
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_list_projects_returns_at_least_one(self):
+ client = launcher_client.get_client(user_token="ignored")
+ projects = client.list_projects()
+ self.assertTrue(len(projects) >= 1)
+ self.assertIn("project_id", projects[0])
+ self.assertIn("name", projects[0])
+```
+
+- [ ] **Step 3: Run tests to verify they fail**
+
+Run: `python manage.py test django_airavata.apps.api.tests.test_launcher_client -v 2`
+Expected: FAIL — `ModuleNotFoundError: No module named 'django_airavata.apps.api.launcher_client'`.
+
+- [ ] **Step 4: Implement the stub client**
+
+Create `django_airavata/apps/api/launcher_client.py`:
+
+```python
+"""Abstraction layer over the airavata server's launcher RPCs.
+
+The real RPCs (new-model applications + dry-run preview) are landing in the
+Java server separately. To unblock portal development and keep CI green,
+this module ships with a stub implementation that returns plausible
+fixtures. The settings flag ``LAUNCHER_CLIENT_STUB`` selects between the
+stub and the real client. Tests should mock at the ``get_client`` boundary.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Protocol
+
+from django.conf import settings
+
+
+class LauncherClient(Protocol):
+ def list_applications(self, *, category: str | None, search: str | None) -> list[dict[str, Any]]: ...
+ def get_application(self, *, app_id: str) -> dict[str, Any]: ...
+ def get_project_resource_profile(self, *, project_id: str) -> dict[str, Any]: ...
+ def list_user_storages(self) -> list[dict[str, Any]]: ...
+ def list_projects(self) -> list[dict[str, Any]]: ...
+ def generate_preview(self, draft: dict[str, Any]) -> dict[str, Any]: ...
+ def launch_experiment(self, draft: dict[str, Any]) -> dict[str, Any]: ...
+
+
+_STUB_APPS: list[dict[str, Any]] = [
+ {
+ "app_id": "namd",
+ "name": "NAMD",
+ "category": "Molecular Dynamics",
+ "content": {"kind": "github", "url": "github.com/Illinois/namd@v3.0"},
+ "interfaces": [
+ {
+ "name": "compile",
+ "inputs": [],
+ "outputs": [{"name": "binary", "type": "file"}],
+ },
+ {
+ "name": "run",
+ "inputs": [
+ {"name": "sim_dir", "type": "dir", "required": True},
+ {"name": "force_field", "type": "file", "required": True},
+ {"name": "steps", "type": "int", "required": True},
+ ],
+ "outputs": [{"name": "trajectory", "type": "file"}],
+ },
+ ],
+ }
+]
+
+
+class _StubClient:
+ def __init__(self, user_token: str) -> None:
+ self.user_token = user_token
+
+ def list_applications(self, *, category: str | None, search: str | None) -> list[dict[str, Any]]:
+ results = list(_STUB_APPS)
+ if category:
+ results = [a for a in results if a["category"] == category]
+ if search:
+ needle = search.lower()
+ results = [a for a in results if needle in a["name"].lower()]
+ return results
+
+ def get_application(self, *, app_id: str) -> dict[str, Any]:
+ for a in _STUB_APPS:
+ if a["app_id"] == app_id:
+ return a
+ raise LookupError(f"unknown app_id {app_id!r}")
+
+ def get_project_resource_profile(self, *, project_id: str) -> dict[str, Any]:
+ return {
+ "project_id": project_id,
+ "allocation_id": "NSF-CS240042",
+ "compute_resources": [
+ {
+ "compute_resource_id": "bridges-2",
+ "name": "Bridges-2",
+ "mapped_storage": {"storage_id": "bridges2-scratch", "scratch_path": f"/scratch/{project_id}"},
+ "partitions": [
+ {"name": "RM", "max_walltime": "48:00:00", "max_nodes": 64, "cpus_per_node": 128},
+ {"name": "RM-shared", "max_walltime": "48:00:00", "max_nodes": 1, "cpus_per_node": 128},
+ {"name": "GPU", "max_walltime": "48:00:00", "max_nodes": 16, "cpus_per_node": 64},
+ ],
+ }
+ ],
+ }
+
+ def list_user_storages(self) -> list[dict[str, Any]]:
+ return [
+ {"storage_id": "my-home", "name": "My Home", "is_primary": True},
+ {"storage_id": "bridges2-scratch", "name": "Bridges-2 Scratch", "is_primary": False},
+ ]
+
+ def list_projects(self) -> list[dict[str, Any]]:
+ return [
+ {"project_id": "proj-stub", "name": "stub-lab-2026"},
+ ]
+
+ def generate_preview(self, draft: dict[str, Any]) -> dict[str, Any]:
+ compute_id = draft["runtime"]["compute_resource_id"]
+ is_slurm = "bridges" in compute_id or "expanse" in compute_id or "anvil" in compute_id
+ partition = draft["runtime"]["partition"]
+ walltime = draft["runtime"]["walltime"]
+ nodes = draft["runtime"]["nodes"]
+ cpus = draft["runtime"]["cpus_per_node"]
+ if is_slurm:
+ invocation = f"sbatch /tmp/{draft['name']}/run.sh"
+ script_lines = [
+ "#!/bin/bash",
+ f"#SBATCH --job-name={draft['name']}",
+ "#SBATCH -A NSF-CS240042",
+ f"#SBATCH -p {partition}",
+ f"#SBATCH -N {nodes}",
+ f"#SBATCH --ntasks-per-node={cpus}",
+ f"#SBATCH -t {walltime}",
+ "",
+ "# compute-service",
+ "module load openmpi/4.1.2",
+ "",
+ "# agent-service",
+ "airavata-agent --exp-id $EXP_ID &",
+ "",
+ "# research-framework",
+ f"srun {draft.get('app_id', 'app')} {draft.get('interface_name', 'run')}",
+ ]
+ else:
+ invocation = f"bash /tmp/{draft['name']}/run.sh"
+ script_lines = [
+ "#!/bin/bash",
+ "# compute-service",
+ "module load openmpi/4.1.2",
+ "",
+ "# agent-service",
+ "airavata-agent --exp-id $EXP_ID &",
+ "",
+ "# research-framework",
+ f"{draft.get('app_id', 'app')} {draft.get('interface_name', 'run')}",
+ ]
+ return {
+ "invocation_command": invocation,
+ "script_contents": "\n".join(script_lines) + "\n",
+ "warnings": [],
+ }
+
+ def launch_experiment(self, draft: dict[str, Any]) -> dict[str, Any]:
+ return {"experiment_id": f"exp-{draft['name']}-stub"}
+
+
+class _RealClient:
+ def __init__(self, user_token: str) -> None:
+ self.user_token = user_token
+
+ def list_applications(self, *, category, search):
+ raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)")
+
+ def get_application(self, *, app_id):
+ raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)")
+
+ def get_project_resource_profile(self, *, project_id):
+ raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)")
+
+ def list_user_storages(self):
+ raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)")
+
+ def list_projects(self):
+ raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)")
+
+ def generate_preview(self, draft):
+ raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)")
+
+ def launch_experiment(self, draft):
+ raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)")
+
+
+def get_client(user_token: str) -> LauncherClient:
+ if getattr(settings, "LAUNCHER_CLIENT_STUB", True):
+ return _StubClient(user_token)
+ return _RealClient(user_token)
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `python manage.py test django_airavata.apps.api.tests.test_launcher_client -v 2`
+Expected: PASS — 4 tests OK.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add django_airavata/apps/api/launcher_client.py \
+ django_airavata/apps/api/tests/test_launcher_client.py \
+ django_airavata/settings.py
+git commit -m "feat(launcher): launcher_client stub + settings flags"
+```
+
+---
+
+### Task 2: Launcher serializers + JSON contract
+
+**Files:**
+- Create: `django_airavata/apps/api/launcher_serializers.py`
+- Create: `tests/contracts/experiment-draft.schema.json`
+- Create: `tests/contracts/preview-response.schema.json`
+- Create: `django_airavata/apps/api/tests/test_launcher_serializers.py`
+
+- [ ] **Step 1: Write the contract JSON schemas**
+
+Create `tests/contracts/experiment-draft.schema.json`:
+
+```json
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "ExperimentDraft",
+ "type": "object",
+ "required": ["name", "project_id", "app_id", "interface_name", "inputs", "outputs", "runtime"],
+ "properties": {
+ "name": {"type": "string", "minLength": 1, "maxLength": 256},
+ "project_id": {"type": "string"},
+ "description": {"type": "string"},
+ "app_id": {"type": "string"},
+ "interface_name": {"type": "string"},
+ "inputs": {
+ "type": "object",
+ "additionalProperties": {
+ "oneOf": [
+ {"type": ["string", "number", "boolean"]},
+ {
+ "type": "object",
+ "required": ["storage_id", "path"],
+ "properties": {
+ "storage_id": {"type": "string"},
+ "path": {"type": "string"}
+ }
+ }
+ ]
+ }
+ },
+ "outputs": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "object",
+ "required": ["storage_id", "path"],
+ "properties": {
+ "storage_id": {"type": "string"},
+ "path": {"type": "string"}
+ }
+ }
+ },
+ "runtime": {
+ "type": "object",
+ "required": ["compute_resource_id", "partition", "walltime", "nodes", "cpus_per_node"],
+ "properties": {
+ "compute_resource_id": {"type": "string"},
+ "partition": {"type": "string"},
+ "walltime": {"type": "string", "pattern": "^\\d{1,3}:[0-5]\\d:[0-5]\\d$"},
+ "nodes": {"type": "integer", "minimum": 1},
+ "cpus_per_node": {"type": "integer", "minimum": 1}
+ }
+ }
+ }
+}
+```
+
+Create `tests/contracts/preview-response.schema.json`:
+
+```json
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "PreviewResponse",
+ "type": "object",
+ "required": ["invocation_command", "script_contents", "warnings"],
+ "properties": {
+ "invocation_command": {"type": "string", "minLength": 1},
+ "script_contents": {"type": "string", "minLength": 1},
+ "warnings": {"type": "array", "items": {"type": "string"}}
+ }
+}
+```
+
+- [ ] **Step 2: Write the failing serializer tests**
+
+Create `django_airavata/apps/api/tests/test_launcher_serializers.py`:
+
+```python
+import json
+from pathlib import Path
+from unittest import TestCase
+
+import jsonschema
+
+from django_airavata.apps.api import launcher_serializers
+
+
+CONTRACTS = Path(__file__).resolve().parents[4] / "tests" / "contracts"
+
+
+class ExperimentDraftSerializerTest(TestCase):
+ def _valid_draft(self) -> dict:
+ return {
+ "name": "test-run",
+ "project_id": "proj-1",
+ "app_id": "namd",
+ "interface_name": "run",
+ "inputs": {
+ "sim_dir": {"storage_id": "my-home", "path": "/home/x/sim"},
+ "steps": 1000,
+ },
+ "outputs": {"trajectory": {"storage_id": "my-home", "path": "/home/x/out.dcd"}},
+ "runtime": {
+ "compute_resource_id": "bridges-2",
+ "partition": "RM",
+ "walltime": "01:00:00",
+ "nodes": 1,
+ "cpus_per_node": 64,
+ },
+ }
+
+ def test_valid_draft_passes_schema(self):
+ schema = json.loads((CONTRACTS / "experiment-draft.schema.json").read_text())
+ jsonschema.validate(self._valid_draft(), schema)
+
+ def test_serializer_accepts_valid_draft(self):
+ serializer = launcher_serializers.ExperimentDraftSerializer(data=self._valid_draft())
+ self.assertTrue(serializer.is_valid(), serializer.errors)
+
+ def test_serializer_rejects_missing_runtime(self):
+ draft = self._valid_draft()
+ del draft["runtime"]
+ serializer = launcher_serializers.ExperimentDraftSerializer(data=draft)
+ self.assertFalse(serializer.is_valid())
+ self.assertIn("runtime", serializer.errors)
+
+ def test_serializer_rejects_bad_walltime(self):
+ draft = self._valid_draft()
+ draft["runtime"]["walltime"] = "not-a-time"
+ serializer = launcher_serializers.ExperimentDraftSerializer(data=draft)
+ self.assertFalse(serializer.is_valid())
+
+
+class PreviewResponseSchemaTest(TestCase):
+ def test_valid_response(self):
+ schema = json.loads((CONTRACTS / "preview-response.schema.json").read_text())
+ jsonschema.validate(
+ {"invocation_command": "bash run.sh", "script_contents": "#!/bin/bash\n", "warnings": []},
+ schema,
+ )
+```
+
+- [ ] **Step 3: Run tests to verify they fail**
+
+Run: `python manage.py test django_airavata.apps.api.tests.test_launcher_serializers -v 2`
+Expected: FAIL — `ModuleNotFoundError: No module named 'django_airavata.apps.api.launcher_serializers'`.
+
+- [ ] **Step 4: Implement the serializers**
+
+Create `django_airavata/apps/api/launcher_serializers.py`:
+
+```python
+import re
+
+from rest_framework import serializers
+
+
+WALLTIME_RE = re.compile(r"^\d{1,3}:[0-5]\d:[0-5]\d$")
+
+
+class RuntimeSerializer(serializers.Serializer):
+ compute_resource_id = serializers.CharField()
+ partition = serializers.CharField()
+ walltime = serializers.CharField()
+ nodes = serializers.IntegerField(min_value=1)
+ cpus_per_node = serializers.IntegerField(min_value=1)
+
+ def validate_walltime(self, value: str) -> str:
+ if not WALLTIME_RE.match(value):
+ raise serializers.ValidationError("walltime must match HH:MM:SS or HHH:MM:SS")
+ return value
+
+
+class StorageRefSerializer(serializers.Serializer):
+ storage_id = serializers.CharField()
+ path = serializers.CharField()
+
+
+class ExperimentDraftSerializer(serializers.Serializer):
+ name = serializers.CharField(min_length=1, max_length=256)
+ project_id = serializers.CharField()
+ description = serializers.CharField(required=False, allow_blank=True, default="")
+ app_id = serializers.CharField()
+ interface_name = serializers.CharField()
+ inputs = serializers.DictField(child=serializers.JSONField())
+ outputs = serializers.DictField(child=StorageRefSerializer())
+ runtime = RuntimeSerializer()
+
+ def validate_inputs(self, value):
+ # Each input is either a scalar (str/int/float/bool) or a {storage_id, path} object.
+ for name, v in value.items():
+ if isinstance(v, dict):
+ StorageRefSerializer(data=v).is_valid(raise_exception=True)
+ return value
+
+
+class PreviewResponseSerializer(serializers.Serializer):
+ invocation_command = serializers.CharField()
+ script_contents = serializers.CharField()
+ warnings = serializers.ListField(child=serializers.CharField(), default=list)
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `python manage.py test django_airavata.apps.api.tests.test_launcher_serializers -v 2`
+Expected: PASS — 5 tests OK. (`jsonschema` is already a transitive dep via `wagtail`; if missing, `pip install jsonschema` and add to requirements-dev.txt.)
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add django_airavata/apps/api/launcher_serializers.py \
+ django_airavata/apps/api/tests/test_launcher_serializers.py \
+ tests/contracts/experiment-draft.schema.json \
+ tests/contracts/preview-response.schema.json
+git commit -m "feat(launcher): DRF serializers + JSON contract schemas"
+```
+
+---
+
+### Task 3: Launcher views — listing, profile, storages
+
+**Files:**
+- Create: `django_airavata/apps/api/launcher_views.py`
+- Modify: `django_airavata/apps/api/urls.py`
+- Create: `django_airavata/apps/api/tests/test_launcher_views.py`
+
+- [ ] **Step 1: Write the failing view tests**
+
+Create `django_airavata/apps/api/tests/test_launcher_views.py`:
+
+```python
+from unittest.mock import patch
+
+from django.contrib.auth import get_user_model
+from django.test import override_settings
+from rest_framework.test import APITestCase
+
+
+class LauncherListingViewsTest(APITestCase):
+ def setUp(self):
+ User = get_user_model()
+ self.user = User.objects.create_user(username="alice", password="pw")
+ self.client.force_login(self.user)
+ # Stub out the session token reader the views rely on
+ session = self.client.session
+ session["ACCESS_TOKEN"] = "test-token"
+ session.save()
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_applications_list_default(self):
+ resp = self.client.get("/api/launcher/applications/")
+ self.assertEqual(resp.status_code, 200)
+ body = resp.json()
+ self.assertIn("results", body)
+ self.assertTrue(len(body["results"]) >= 1)
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_applications_list_filters_by_search(self):
+ resp = self.client.get("/api/launcher/applications/?search=namd")
+ self.assertEqual(resp.status_code, 200)
+ names = [a["name"] for a in resp.json()["results"]]
+ self.assertIn("NAMD", names)
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_application_detail(self):
+ resp = self.client.get("/api/launcher/applications/namd/")
+ self.assertEqual(resp.status_code, 200)
+ self.assertEqual(resp.json()["app_id"], "namd")
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_resource_profile_for_project(self):
+ resp = self.client.get("/api/launcher/projects/proj-1/resource-profile/")
+ self.assertEqual(resp.status_code, 200)
+ body = resp.json()
+ self.assertIn("compute_resources", body)
+ self.assertIn("allocation_id", body)
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_user_storages(self):
+ resp = self.client.get("/api/launcher/storages/")
+ self.assertEqual(resp.status_code, 200)
+ body = resp.json()
+ self.assertIn("results", body)
+ self.assertTrue(any(s.get("is_primary") for s in body["results"]))
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_projects_list(self):
+ resp = self.client.get("/api/launcher/projects/")
+ self.assertEqual(resp.status_code, 200)
+ body = resp.json()
+ self.assertIn("results", body)
+ self.assertTrue(len(body["results"]) >= 1)
+ self.assertIn("project_id", body["results"][0])
+ self.assertIn("name", body["results"][0])
+
+ def test_endpoints_require_auth(self):
+ self.client.logout()
+ for url in [
+ "/api/launcher/applications/",
+ "/api/launcher/applications/namd/",
+ "/api/launcher/projects/proj-1/resource-profile/",
+ "/api/launcher/storages/",
+ "/api/launcher/projects/",
+ ]:
+ self.assertEqual(self.client.get(url).status_code, 403)
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `python manage.py test django_airavata.apps.api.tests.test_launcher_views -v 2`
+Expected: FAIL — endpoints not registered (404), or `launcher_views` module missing.
+
+- [ ] **Step 3: Implement the listing views**
+
+Create `django_airavata/apps/api/launcher_views.py`:
+
+```python
+from rest_framework import permissions, status
+from rest_framework.decorators import api_view, permission_classes
+from rest_framework.request import Request
+from rest_framework.response import Response
+
+from django_airavata.apps.api import launcher_client
+
+
+def _client(request: Request) -> launcher_client.LauncherClient:
+ token = request.session.get("ACCESS_TOKEN", "")
+ return launcher_client.get_client(user_token=token)
+
+
+@api_view(["GET"])
+@permission_classes([permissions.IsAuthenticated])
+def applications_list(request: Request) -> Response:
+ category = request.query_params.get("category") or None
+ search = request.query_params.get("search") or None
+ results = _client(request).list_applications(category=category, search=search)
+ return Response({"results": results})
+
+
+@api_view(["GET"])
+@permission_classes([permissions.IsAuthenticated])
+def application_detail(request: Request, app_id: str) -> Response:
+ try:
+ return Response(_client(request).get_application(app_id=app_id))
+ except LookupError:
+ return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
+
+
+@api_view(["GET"])
+@permission_classes([permissions.IsAuthenticated])
+def project_resource_profile(request: Request, project_id: str) -> Response:
+ return Response(_client(request).get_project_resource_profile(project_id=project_id))
+
+
+@api_view(["GET"])
+@permission_classes([permissions.IsAuthenticated])
+def user_storages(request: Request) -> Response:
+ return Response({"results": _client(request).list_user_storages()})
+
+
+@api_view(["GET"])
+@permission_classes([permissions.IsAuthenticated])
+def projects_list(request: Request) -> Response:
+ return Response({"results": _client(request).list_projects()})
+```
+
+- [ ] **Step 4: Register the URLs**
+
+Edit `django_airavata/apps/api/urls.py`. Find the existing `urlpatterns = [` block and add these entries before the closing bracket:
+
+```python
+from . import launcher_views
+
+# ... after the existing router lines and before the urlpatterns list, add:
+urlpatterns += [
+ re_path(r"^launcher/applications/$", launcher_views.applications_list, name="launcher_applications_list"),
+ re_path(r"^launcher/applications/(?P<app_id>[^/]+)/$", launcher_views.application_detail, name="launcher_application_detail"),
+ re_path(r"^launcher/projects/(?P<project_id>[^/]+)/resource-profile/$",
+ launcher_views.project_resource_profile, name="launcher_project_resource_profile"),
+ re_path(r"^launcher/storages/$", launcher_views.user_storages, name="launcher_user_storages"),
+ re_path(r"^launcher/projects/$", launcher_views.projects_list, name="launcher_projects_list"),
+]
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `python manage.py test django_airavata.apps.api.tests.test_launcher_views -v 2`
+Expected: PASS — 7 tests OK.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add django_airavata/apps/api/launcher_views.py \
+ django_airavata/apps/api/urls.py \
+ django_airavata/apps/api/tests/test_launcher_views.py
+git commit -m "feat(launcher): listing endpoints (apps, profile, storages, projects)"
+```
+
+---
+
+### Task 4: Preview + create endpoints
+
+**Files:**
+- Modify: `django_airavata/apps/api/launcher_views.py`
+- Modify: `django_airavata/apps/api/urls.py`
+- Modify: `django_airavata/apps/api/tests/test_launcher_views.py`
+
+- [ ] **Step 1: Add the failing tests**
+
+Append to `django_airavata/apps/api/tests/test_launcher_views.py`:
+
+```python
+class LauncherWriteViewsTest(APITestCase):
+ def setUp(self):
+ User = get_user_model()
+ self.user = User.objects.create_user(username="alice", password="pw")
+ self.client.force_login(self.user)
+ session = self.client.session
+ session["ACCESS_TOKEN"] = "test-token"
+ session.save()
+ self.draft = {
+ "name": "test-run",
+ "project_id": "proj-1",
+ "app_id": "namd",
+ "interface_name": "run",
+ "inputs": {"steps": 100},
+ "outputs": {"trajectory": {"storage_id": "my-home", "path": "/x/out.dcd"}},
+ "runtime": {
+ "compute_resource_id": "bridges-2",
+ "partition": "RM",
+ "walltime": "01:00:00",
+ "nodes": 1,
+ "cpus_per_node": 64,
+ },
+ }
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_preview_returns_script_and_command(self):
+ resp = self.client.post("/api/launcher/experiment-drafts/preview/", self.draft, format="json")
+ self.assertEqual(resp.status_code, 200, resp.content)
+ body = resp.json()
+ self.assertIn("invocation_command", body)
+ self.assertIn("script_contents", body)
+ self.assertIn("warnings", body)
+ self.assertTrue(body["script_contents"].startswith("#!/bin/bash"))
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_preview_rejects_invalid_draft(self):
+ bad = dict(self.draft)
+ bad["runtime"] = {**bad["runtime"], "walltime": "garbage"}
+ resp = self.client.post("/api/launcher/experiment-drafts/preview/", bad, format="json")
+ self.assertEqual(resp.status_code, 400)
+ self.assertIn("runtime", resp.json())
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_preview_returns_502_when_client_unreachable(self):
+ with patch.object(launcher_views, "_client") as mock_client:
+ mock_client.return_value.generate_preview.side_effect = ConnectionError("airavata down")
+ resp = self.client.post("/api/launcher/experiment-drafts/preview/", self.draft, format="json")
+ self.assertEqual(resp.status_code, 502)
+ self.assertIn("message", resp.json())
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_create_returns_experiment_id(self):
+ resp = self.client.post("/api/launcher/experiment-drafts/", self.draft, format="json")
+ self.assertEqual(resp.status_code, 201, resp.content)
+ self.assertIn("experiment_id", resp.json())
+```
+
+Add to the imports at the top of the test file:
+
+```python
+from django_airavata.apps.api import launcher_views # noqa: E402 (for the patch target)
+```
+
+- [ ] **Step 2: Run to verify the new tests fail**
+
+Run: `python manage.py test django_airavata.apps.api.tests.test_launcher_views.LauncherWriteViewsTest -v 2`
+Expected: FAIL — endpoints not registered (404).
+
+- [ ] **Step 3: Implement the views**
+
+Append to `django_airavata/apps/api/launcher_views.py`:
+
+```python
+from django_airavata.apps.api import launcher_serializers
+
+
+@api_view(["POST"])
+@permission_classes([permissions.IsAuthenticated])
+def experiment_draft_preview(request: Request) -> Response:
+ serializer = launcher_serializers.ExperimentDraftSerializer(data=request.data)
+ if not serializer.is_valid():
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+ try:
+ result = _client(request).generate_preview(serializer.validated_data)
+ except ConnectionError as e:
+ return Response({"message": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
+ return Response(result)
+
+
+@api_view(["POST"])
+@permission_classes([permissions.IsAuthenticated])
+def experiment_draft_create(request: Request) -> Response:
+ serializer = launcher_serializers.ExperimentDraftSerializer(data=request.data)
+ if not serializer.is_valid():
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+ try:
+ result = _client(request).launch_experiment(serializer.validated_data)
+ except ConnectionError as e:
+ return Response({"message": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
+ return Response(result, status=status.HTTP_201_CREATED)
+```
+
+- [ ] **Step 4: Register the URLs**
+
+Append to the `urlpatterns += [...]` block in `django_airavata/apps/api/urls.py`:
+
+```python
+ re_path(r"^launcher/experiment-drafts/preview/$",
+ launcher_views.experiment_draft_preview, name="launcher_draft_preview"),
+ re_path(r"^launcher/experiment-drafts/$",
+ launcher_views.experiment_draft_create, name="launcher_draft_create"),
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `python manage.py test django_airavata.apps.api.tests.test_launcher_views -v 2`
+Expected: PASS — all tests OK (11 total).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add django_airavata/apps/api/launcher_views.py \
+ django_airavata/apps/api/urls.py \
+ django_airavata/apps/api/tests/test_launcher_views.py
+git commit -m "feat(launcher): preview + create endpoints with 502/400 handling"
+```
+
+---
+
+### Task 5: Workspace launch view + redirects
+
+**Files:**
+- Modify: `django_airavata/apps/workspace/views.py`
+- Modify: `django_airavata/apps/workspace/urls.py`
+- Create: `django_airavata/apps/workspace/templates/django_airavata_workspace/launch.html`
+- Create: `django_airavata/apps/workspace/tests/test_launch_view.py`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `django_airavata/apps/workspace/tests/test_launch_view.py` (create the `tests/__init__.py` if missing):
+
+```python
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+
+
+class LaunchViewTest(TestCase):
+ def setUp(self):
+ User = get_user_model()
+ self.user = User.objects.create_user(username="alice", password="pw")
+ self.client.force_login(self.user)
+
+ def test_launch_renders_for_authenticated_user(self):
+ resp = self.client.get("/workspace/launch")
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, 'id="launch-app"')
+
+ def test_old_create_experiment_url_redirects_to_launch(self):
+ resp = self.client.get("/workspace/applications/anything/create_experiment", follow=False)
+ self.assertEqual(resp.status_code, 301)
+ self.assertEqual(resp["Location"], "/workspace/launch")
+
+ def test_old_applications_url_redirects_to_launch(self):
+ resp = self.client.get("/workspace/applications", follow=False)
+ self.assertEqual(resp.status_code, 301)
+ self.assertEqual(resp["Location"], "/workspace/launch")
+
+ def test_launch_requires_auth(self):
+ self.client.logout()
+ resp = self.client.get("/workspace/launch", follow=False)
+ # Login redirect — exact path depends on AUTH_LOGIN_URL but we just check it's a redirect
+ self.assertIn(resp.status_code, (302, 401))
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `python manage.py test django_airavata.apps.workspace.tests.test_launch_view -v 2`
+Expected: FAIL — `/workspace/launch` returns 404 or current URL config redirects don't match.
+
+- [ ] **Step 3: Add the SPA shell template**
+
+Create `django_airavata/apps/workspace/templates/django_airavata_workspace/launch.html`:
+
+```html
+{% extends "base.html" %}
+{% load django_vite %}
+
+{% block content %}
+ <div id="launch-app" data-feature-flag="{{ feature_flag|yesno:'on,off' }}"></div>
+{% endblock %}
+
+{% block scripts %}
+ {% vite_asset "js/entry-launch.ts" app="workspace" %}
+{% endblock %}
+```
+
+- [ ] **Step 4: Add the launch view**
+
+Edit `django_airavata/apps/workspace/views.py`. Add this view (and import `settings` at the top if not already):
+
+```python
+from django.conf import settings
+from django.contrib.auth.decorators import login_required
+
+
+@login_required
+def launch(request):
+ return render(request, "django_airavata_workspace/launch.html", {
+ "feature_flag": getattr(settings, "FEATURE_GENERIC_LAUNCHER", False),
+ })
+```
+
+- [ ] **Step 5: Wire URL + redirects**
+
+Edit `django_airavata/apps/workspace/urls.py`. Replace the existing patterns for `applications`, `applications/(...)/create_experiment`, and `applications/(...)/$` with:
+
+```python
+from django.views.generic import RedirectView
+
+# ...
+urlpatterns = [
+ # ... unchanged entries ...
+ re_path(r"^launch$", views.launch, name="launch"),
+ re_path(r"^applications$", RedirectView.as_view(url="/workspace/launch", permanent=True), name="applications_redirect"),
+ re_path(r"^applications/(?P<app_module_id>[^/]+)/create_experiment$",
+ RedirectView.as_view(url="/workspace/launch", permanent=True),
+ name="create_experiment_redirect"),
+ re_path(r"^applications/(?P<app_module_id>[^/]+)/$",
+ RedirectView.as_view(url="/workspace/launch", permanent=True),
+ name="application_editor_redirect"),
+ # leave the rest of the file untouched
+]
+```
+
+- [ ] **Step 6: Run tests to verify they pass**
+
+Run: `python manage.py test django_airavata.apps.workspace.tests.test_launch_view -v 2`
+Expected: PASS — 4 tests OK.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add django_airavata/apps/workspace/views.py \
+ django_airavata/apps/workspace/urls.py \
+ django_airavata/apps/workspace/templates/django_airavata_workspace/launch.html \
+ django_airavata/apps/workspace/tests/test_launch_view.py
+git commit -m "feat(launcher): /workspace/launch view + 301 redirects from old URLs"
+```
+
+---
+
+## Phase 2 — Frontend foundation
+
+### Task 6: Pinia store + types + service
+
+**Files:**
+- Create: `django_airavata/static/common/js/stores/launch-types.ts`
+- Create: `django_airavata/static/common/js/stores/launch.ts`
+- Create: `django_airavata/static/common/js/services/launcherService.ts`
+- Create: `django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/stores/launch.spec.ts`
+
+- [ ] **Step 1: Define the types**
+
+Create `django_airavata/static/common/js/stores/launch-types.ts`:
+
+```ts
+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[];
+}
+```
+
+- [ ] **Step 2: Define the service wrapper**
+
+Create `django_airavata/static/common/js/services/launcherService.ts`:
+
+```ts
+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),
+ });
+ },
+};
+```
+
+- [ ] **Step 3: Write the failing store test**
+
+Create `django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/stores/launch.spec.ts`:
+
+```ts
+import { setActivePinia, createPinia } from "pinia";
+import { beforeEach, describe, expect, it } from "vitest";
+import { useLaunchStore } from "django-airavata-common-ui/stores/launch";
+import type { InterfaceDescriptor } from "django-airavata-common-ui/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);
+ });
+});
+```
+
+- [ ] **Step 4: Run to verify it fails**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- launch.spec`
+Expected: FAIL — module `django-airavata-common-ui/stores/launch` not resolved.
+
+- [ ] **Step 5: Implement the Pinia store**
+
+Create `django_airavata/static/common/js/stores/launch.ts`:
+
+```ts
+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,
+ };
+});
+```
+
+- [ ] **Step 6: Run to verify it passes**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- launch.spec`
+Expected: PASS — 5 tests OK.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add django_airavata/static/common/js/stores/launch-types.ts \
+ django_airavata/static/common/js/stores/launch.ts \
+ django_airavata/static/common/js/services/launcherService.ts \
+ django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/stores/launch.spec.ts
+git commit -m "feat(launcher): Pinia store + types + service wrapper"
+```
+
+---
+
+### Task 7: ExperimentMetaHeader
+
+**Files:**
+- Create: `django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/ExperimentMetaHeader.vue`
+- Create: `django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/ExperimentMetaHeader.spec.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+Create the spec file:
+
+```ts
+import { mount } from "@vue/test-utils";
+import { setActivePinia, createPinia } from "pinia";
+import { beforeEach, describe, expect, it } from "vitest";
+import ExperimentMetaHeader from "../../../../js/components/launch/ExperimentMetaHeader.vue";
+import { useLaunchStore } from "django-airavata-common-ui/stores/launch";
+
+const PROJECTS = [
+ { project_id: "p1", name: "my-lab-2026" },
+ { project_id: "p2", name: "shared" },
+];
+
+describe("ExperimentMetaHeader", () => {
+ beforeEach(() => setActivePinia(createPinia()));
+
+ it("binds name input to the store", async () => {
+ const w = mount(ExperimentMetaHeader, { props: { projects: PROJECTS } });
+ await w.find("input[data-test='exp-name']").setValue("my-run");
+ expect(useLaunchStore().draft.name).toBe("my-run");
+ });
+
+ it("binds project dropdown to the store", async () => {
+ const w = mount(ExperimentMetaHeader, { props: { projects: PROJECTS } });
+ await w.find("select[data-test='exp-project']").setValue("p2");
+ expect(useLaunchStore().draft.project_id).toBe("p2");
+ });
+
+ it("renders description as a textarea", () => {
+ const w = mount(ExperimentMetaHeader, { props: { projects: PROJECTS } });
+ expect(w.find("textarea[data-test='exp-description']").exists()).toBe(true);
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- ExperimentMetaHeader`
+Expected: FAIL — file not found.
+
+- [ ] **Step 3: Implement the component**
+
+Create `…/js/components/launch/ExperimentMetaHeader.vue`:
+
+```vue
+<template>
+ <div class="row g-2 mb-2">
+ <div class="col-md-6">
+ <input
+ data-test="exp-name"
+ class="form-control"
+ :value="store.draft.name"
+ placeholder="Experiment name"
+ maxlength="256"
+ @input="onName(($event.target as HTMLInputElement).value)"
+ />
+ </div>
+ <div class="col-md-6">
+ <select
+ data-test="exp-project"
+ class="form-select"
+ :value="store.draft.project_id ?? ''"
+ @change="onProject(($event.target as HTMLSelectElement).value)"
+ >
+ <option value="" disabled>Select a project</option>
+ <option v-for="p in projects" :key="p.project_id" :value="p.project_id">
+ {{ p.name }}
+ </option>
+ </select>
+ </div>
+ <div class="col-12">
+ <textarea
+ data-test="exp-description"
+ class="form-control"
+ rows="2"
+ :value="store.draft.description"
+ placeholder="Description (optional)"
+ @input="onDescription(($event.target as HTMLTextAreaElement).value)"
+ />
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { useLaunchStore } from "django-airavata-common-ui/stores/launch";
+
+defineProps<{ projects: Array<{ project_id: string; name: string }> }>();
+
+const store = useLaunchStore();
+
+function onName(v: string) {
+ store.setMeta({ name: v, project_id: store.draft.project_id, description: store.draft.description });
+}
+function onProject(v: string) {
+ store.setMeta({ name: store.draft.name, project_id: v || null, description: store.draft.description });
+}
+function onDescription(v: string) {
+ store.setMeta({ name: store.draft.name, project_id: store.draft.project_id, description: v });
+}
+</script>
+```
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- ExperimentMetaHeader`
+Expected: PASS — 3 tests OK.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/ExperimentMetaHeader.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/ExperimentMetaHeader.spec.ts
+git commit -m "feat(launcher): ExperimentMetaHeader (name/project/description)"
+```
+
+---
+
+### Task 8: WizardTabs (strict-forward gate)
+
+**Files:**
+- Create: `…/js/components/launch/WizardTabs.vue`
+- Create: `…/tests/unit/components/launch/WizardTabs.spec.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `WizardTabs.spec.ts`:
+
+```ts
+import { mount } from "@vue/test-utils";
+import { setActivePinia, createPinia } from "pinia";
+import { beforeEach, describe, expect, it } from "vitest";
+import WizardTabs from "../../../../js/components/launch/WizardTabs.vue";
+import { useLaunchStore } from "django-airavata-common-ui/stores/launch";
+
+describe("WizardTabs", () => {
+ beforeEach(() => setActivePinia(createPinia()));
+
+ function makeMount(active: 1 | 2 | 3 = 1) {
+ return mount(WizardTabs, { props: { active }, global: { stubs: { transition: false } } });
+ }
+
+ it("renders three tab buttons", () => {
+ const w = makeMount();
+ expect(w.findAll("button[role='tab']")).toHaveLength(3);
+ });
+
+ it("disables tabs 2 and 3 when tab 1 is invalid", () => {
+ const w = makeMount();
+ const tabs = w.findAll("button[role='tab']");
+ expect(tabs[1].attributes("disabled")).toBeDefined();
+ expect(tabs[2].attributes("disabled")).toBeDefined();
+ });
+
+ it("emits update:active when allowed tab clicked", async () => {
+ const store = useLaunchStore();
+ // Force tab 1 valid by spying on the getter
+ Object.defineProperty(store, "tab1Valid", { value: true });
+ const w = makeMount();
+ await w.findAll("button[role='tab']")[1].trigger("click");
+ expect(w.emitted("update:active")?.[0]).toEqual([2]);
+ });
+
+ it("does not emit update:active for a disabled tab", async () => {
+ const w = makeMount();
+ await w.findAll("button[role='tab']")[1].trigger("click");
+ expect(w.emitted("update:active")).toBeUndefined();
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- WizardTabs`
+Expected: FAIL — file not found.
+
+- [ ] **Step 3: Implement WizardTabs**
+
+Create `…/js/components/launch/WizardTabs.vue`:
+
+```vue
+<template>
+ <div class="nav nav-tabs mb-3" role="tablist">
+ <button
+ v-for="t in tabs"
+ :key="t.idx"
+ role="tab"
+ type="button"
+ class="nav-link"
+ :class="{ active: t.idx === active }"
+ :disabled="t.disabled"
+ @click="onClick(t.idx, t.disabled)"
+ >
+ {{ t.idx }} · {{ t.label }}
+ </button>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from "vue";
+import { useLaunchStore } from "django-airavata-common-ui/stores/launch";
+
+defineProps<{ active: 1 | 2 | 3 }>();
+const emit = defineEmits<{ "update:active": [n: 1 | 2 | 3] }>();
+
+const store = useLaunchStore();
+
+const tabs = computed(() => [
+ { idx: 1 as const, label: "Application & Inputs", disabled: false },
+ { idx: 2 as const, label: "Runtime", disabled: !store.tab1Valid },
+ { idx: 3 as const, label: "Review & Launch", disabled: !store.tab1Valid || !store.tab2Valid },
+]);
+
+function onClick(n: 1 | 2 | 3, disabled: boolean) {
+ if (disabled) return;
+ emit("update:active", n);
+}
+</script>
+```
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- WizardTabs`
+Expected: PASS — 4 tests OK.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/WizardTabs.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/WizardTabs.spec.ts
+git commit -m "feat(launcher): WizardTabs with strict-forward gating"
+```
+
+---
+
+### Task 9: LaunchContainer + entry-launch.ts
+
+**Files:**
+- Create: `…/js/containers/LaunchContainer.vue`
+- Create: `…/js/entry-launch.ts`
+- Create: `…/tests/unit/components/launch/LaunchContainer.spec.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `LaunchContainer.spec.ts`:
+
+```ts
+import { mount, flushPromises } from "@vue/test-utils";
+import { createPinia, setActivePinia } from "pinia";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import LaunchContainer from "../../../../js/containers/LaunchContainer.vue";
+
+vi.mock("django-airavata-common-ui/services/launcherService", () => ({
+ launcherService: {
+ listProjects: vi.fn().mockResolvedValue({ results: [] }),
+ },
+}));
+
+describe("LaunchContainer", () => {
+ beforeEach(() => setActivePinia(createPinia()));
+
+ it("starts on tab 1", async () => {
+ const w = mount(LaunchContainer);
+ await flushPromises();
+ expect(w.find("[data-test='active-tab']").attributes("data-active")).toBe("1");
+ });
+
+ it("renders the meta header", async () => {
+ const w = mount(LaunchContainer);
+ await flushPromises();
+ expect(w.find("input[data-test='exp-name']").exists()).toBe(true);
+ });
+
+ it("only one tab panel is shown at a time", async () => {
+ const w = mount(LaunchContainer);
+ await flushPromises();
+ const visiblePanels = w.findAll("[role='tabpanel']").filter((p) => !p.attributes("hidden"));
+ expect(visiblePanels).toHaveLength(1);
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- LaunchContainer`
+Expected: FAIL — file not found.
+
+- [ ] **Step 3: Implement LaunchContainer**
+
+Create `…/js/containers/LaunchContainer.vue`:
+
+```vue
+<template>
+ <div class="container-fluid">
+ <ExperimentMetaHeader :projects="projects" />
+ <WizardTabs :active="active" @update:active="onChangeTab" />
+ <div data-test="active-tab" :data-active="active" />
+ <section role="tabpanel" :hidden="active !== 1">
+ <Tab1ApplicationInputs v-if="active === 1" />
+ </section>
+ <section role="tabpanel" :hidden="active !== 2">
+ <Tab2Runtime v-if="active === 2" />
+ </section>
+ <section role="tabpanel" :hidden="active !== 3">
+ <Tab3ReviewLaunch v-if="active === 3" />
+ </section>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref } from "vue";
+import { launcherService } from "django-airavata-common-ui/services/launcherService";
+import ExperimentMetaHeader from "../components/launch/ExperimentMetaHeader.vue";
+import WizardTabs from "../components/launch/WizardTabs.vue";
+import Tab1ApplicationInputs from "../components/launch/Tab1ApplicationInputs.vue";
+import Tab2Runtime from "../components/launch/Tab2Runtime.vue";
+import Tab3ReviewLaunch from "../components/launch/Tab3ReviewLaunch.vue";
+
+const active = ref<1 | 2 | 3>(1);
+const projects = ref<Array<{ project_id: string; name: string }>>([]);
+
+function onChangeTab(n: 1 | 2 | 3) {
+ active.value = n;
+ const url = new URL(window.location.href);
+ url.searchParams.set("tab", String(n));
+ window.history.replaceState({}, "", url);
+}
+
+onMounted(async () => {
+ const url = new URL(window.location.href);
+ const t = Number(url.searchParams.get("tab"));
+ if (t === 1 || t === 2 || t === 3) active.value = t as 1 | 2 | 3;
+ const r = await launcherService.listProjects();
+ projects.value = r.results;
+});
+</script>
+```
+
+Until Tab1/2/3 components exist, also create empty stub components so the container compiles:
+
+```vue
+<!-- Tab1ApplicationInputs.vue, Tab2Runtime.vue, Tab3ReviewLaunch.vue -->
+<template><div /></template>
+<script setup lang="ts"></script>
+```
+
+Place those three placeholder files at `…/js/components/launch/`.
+
+- [ ] **Step 4: Implement entry-launch.ts**
+
+Create `…/js/entry-launch.ts`:
+
+```ts
+import { createApp } from "vue";
+import { createPinia } from "pinia";
+import LaunchContainer from "./containers/LaunchContainer.vue";
+
+const root = document.getElementById("launch-app");
+if (root) {
+ const app = createApp(LaunchContainer);
+ app.use(createPinia());
+ app.mount(root);
+}
+```
+
+- [ ] **Step 5: Run to verify it passes**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- LaunchContainer`
+Expected: PASS — 3 tests OK.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/LaunchContainer.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/js/entry-launch.ts \
+ django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab1ApplicationInputs.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab2Runtime.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab3ReviewLaunch.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/LaunchContainer.spec.ts
+git commit -m "feat(launcher): LaunchContainer + entry-launch.ts (tabs scaffolding)"
+```
+
+---
+
+## Phase 3 — Tab 1 components
+
+### Task 10: AppPicker (categories, search, tile grid)
+
+**Files:**
+- Create: `…/js/components/launch/AppPicker.vue`
+- Create: `…/tests/unit/components/launch/AppPicker.spec.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { mount } from "@vue/test-utils";
+import { createPinia, setActivePinia } from "pinia";
+import { beforeEach, describe, expect, it } from "vitest";
+import AppPicker from "../../../../js/components/launch/AppPicker.vue";
+import { useLaunchStore } from "django-airavata-common-ui/stores/launch";
+
+const APPS = [
+ { app_id: "namd", name: "NAMD", category: "Molecular Dynamics",
+ content: { kind: "github" as const, url: "g" }, interfaces: [] },
+ { app_id: "gromacs", name: "GROMACS", category: "Molecular Dynamics",
+ content: { kind: "tarball" as const, url: "t" }, interfaces: [] },
+ { app_id: "alphafold", name: "AlphaFold", category: "ML / AI",
+ content: { kind: "github" as const, url: "g" }, interfaces: [] },
+];
+
+describe("AppPicker", () => {
+ beforeEach(() => setActivePinia(createPinia()));
+
+ it("renders all apps when category=All", () => {
+ const w = mount(AppPicker, { props: { applications: APPS } });
+ expect(w.findAll("[data-test='app-tile']")).toHaveLength(3);
+ });
+
+ it("filters by category chip", async () => {
+ const w = mount(AppPicker, { props: { applications: APPS } });
+ await w.find("[data-test='cat-ML / AI']").trigger("click");
+ expect(w.findAll("[data-test='app-tile']")).toHaveLength(1);
+ });
+
+ it("filters by search text within current category", async () => {
+ const w = mount(AppPicker, { props: { applications: APPS } });
+ await w.find("input[data-test='app-search']").setValue("NAMD");
+ expect(w.findAll("[data-test='app-tile']")).toHaveLength(1);
+ });
+
+ it("clicking a tile picks the app via the store", async () => {
+ const w = mount(AppPicker, { props: { applications: APPS } });
+ await w.find("[data-test='app-tile-namd']").trigger("click");
+ expect(useLaunchStore().draft.app_id).toBe("namd");
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- AppPicker`
+Expected: FAIL — file not found.
+
+- [ ] **Step 3: Implement AppPicker**
+
+```vue
+<template>
+ <div>
+ <div class="d-flex flex-wrap gap-1 mb-2">
+ <button
+ v-for="cat in categories"
+ :key="cat"
+ type="button"
+ class="btn btn-sm"
+ :class="cat === activeCat ? 'btn-primary' : 'btn-light'"
+ :data-test="`cat-${cat}`"
+ @click="activeCat = cat"
+ >
+ {{ cat }}<span class="ms-1 opacity-50">{{ countByCat[cat] }}</span>
+ </button>
+ </div>
+ <input
+ v-model="search"
+ type="search"
+ class="form-control mb-2"
+ data-test="app-search"
+ placeholder="Filter…"
+ />
+ <div class="row g-2">
+ <div
+ v-for="a in filtered"
+ :key="a.app_id"
+ class="col-6 col-md-3"
+ data-test="app-tile"
+ :data-test-id="`app-tile-${a.app_id}`"
+ >
+ <button
+ type="button"
+ class="card w-100 text-start p-2"
+ :class="{ 'border-primary': store.draft.app_id === a.app_id }"
+ :data-test="`app-tile-${a.app_id}`"
+ @click="pick(a)"
+ >
+ <strong>{{ a.name }}</strong>
+ <span class="text-muted small d-block">{{ a.content.url }}</span>
+ </button>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from "vue";
+import type { Application } from "django-airavata-common-ui/stores/launch-types";
+import { useLaunchStore } from "django-airavata-common-ui/stores/launch";
+
+const props = defineProps<{ applications: Application[] }>();
+const store = useLaunchStore();
+const activeCat = ref<string>("All");
+const search = ref("");
+
+const categories = computed(() => {
+ const set = new Set<string>(["All"]);
+ for (const a of props.applications) set.add(a.category);
+ return Array.from(set);
+});
+
+const countByCat = computed<Record<string, number>>(() => {
+ const out: Record<string, number> = { All: props.applications.length };
+ for (const a of props.applications) out[a.category] = (out[a.category] ?? 0) + 1;
+ return out;
+});
+
+const filtered = computed(() => {
+ let xs = props.applications;
+ if (activeCat.value !== "All") xs = xs.filter((a) => a.category === activeCat.value);
+ if (search.value) {
+ const n = search.value.toLowerCase();
+ xs = xs.filter((a) => a.name.toLowerCase().includes(n));
+ }
+ return xs;
+});
+
+function pick(a: Application) {
+ store.pickApp(a);
+}
+</script>
+```
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- AppPicker`
+Expected: PASS — 4 tests OK.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/AppPicker.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/AppPicker.spec.ts
+git commit -m "feat(launcher): AppPicker (categories + search + tile grid)"
+```
+
+---
+
+### Task 11: InterfacePicker (verb cards)
+
+**Files:**
+- Create: `…/js/components/launch/InterfacePicker.vue`
+- Create: `…/tests/unit/components/launch/InterfacePicker.spec.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { mount } from "@vue/test-utils";
+import { createPinia, setActivePinia } from "pinia";
+import { beforeEach, describe, expect, it } from "vitest";
+import InterfacePicker from "../../../../js/components/launch/InterfacePicker.vue";
+import { useLaunchStore } from "django-airavata-common-ui/stores/launch";
+
+const APP = {
+ app_id: "namd", name: "NAMD", category: "MD",
+ content: { kind: "github" as const, url: "g" },
+ interfaces: [
+ { name: "compile", inputs: [], outputs: [] },
+ { name: "run", inputs: [{ name: "x", type: "int" as const, required: true }], outputs: [] },
+ ],
+};
+
+describe("InterfacePicker", () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ useLaunchStore().pickApp(APP);
+ });
+
+ it("renders one card per interface", () => {
+ const w = mount(InterfacePicker);
+ expect(w.findAll("[data-test='iface-card']")).toHaveLength(2);
+ });
+
+ it("clicking a card sets interface_name in the store", async () => {
+ const w = mount(InterfacePicker);
+ await w.find("[data-test='iface-card-run']").trigger("click");
+ expect(useLaunchStore().draft.interface_name).toBe("run");
+ });
+
+ it("renders an input/output signature line per card", () => {
+ const w = mount(InterfacePicker);
+ const sig = w.find("[data-test='iface-sig-run']").text();
+ expect(sig).toContain("x: int");
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- InterfacePicker`
+Expected: FAIL — file not found.
+
+- [ ] **Step 3: Implement InterfacePicker**
+
+```vue
+<template>
+ <div v-if="store.pickedApp" class="row g-2">
+ <div
+ v-for="iface in store.pickedApp.interfaces"
+ :key="iface.name"
+ class="col-6 col-md-3"
+ data-test="iface-card"
+ >
+ <button
+ type="button"
+ class="card w-100 text-start p-2"
+ :class="{ 'border-primary': store.draft.interface_name === iface.name }"
+ :data-test="`iface-card-${iface.name}`"
+ @click="store.pickInterface(iface.name)"
+ >
+ <code class="d-block fw-bold">{{ iface.name }}</code>
+ <small class="text-muted" :data-test="`iface-sig-${iface.name}`">
+ ({{ formatList(iface.inputs) }}) → {{ formatList(iface.outputs) || "void" }}
+ </small>
+ </button>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import type { IODescriptor } from "django-airavata-common-ui/stores/launch-types";
+import { useLaunchStore } from "django-airavata-common-ui/stores/launch";
+
+const store = useLaunchStore();
+
+function formatList(io: IODescriptor[]): string {
+ return io.map((x) => `${x.name}: ${x.type}`).join(", ");
+}
+</script>
+```
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- InterfacePicker`
+Expected: PASS — 3 tests OK.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/InterfacePicker.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/InterfacePicker.spec.ts
+git commit -m "feat(launcher): InterfacePicker verb cards"
+```
+
+---
+
+### Task 12: ScalarInputRow + FileInputRow
+
+**Files:**
+- Create: `…/js/components/launch/ScalarInputRow.vue`
+- Create: `…/js/components/launch/FileInputRow.vue`
+- Create: `…/tests/unit/components/launch/ScalarInputRow.spec.ts`
+- Create: `…/tests/unit/components/launch/FileInputRow.spec.ts`
+
+- [ ] **Step 1: Write failing tests for both rows**
+
+`ScalarInputRow.spec.ts`:
+
+```ts
+import { mount } from "@vue/test-utils";
+import { describe, expect, it } from "vitest";
+import ScalarInputRow from "../../../../js/components/launch/ScalarInputRow.vue";
+
+describe("ScalarInputRow", () => {
+ it("renders a number input for type=int", async () => {
+ const w = mount(ScalarInputRow, {
+ props: { descriptor: { name: "steps", type: "int", required: true }, modelValue: null },
+ });
+ const input = w.find("input[data-test='scalar-steps']");
+ expect(input.attributes("type")).toBe("number");
+ });
+
+ it("emits update:modelValue on input", async () => {
+ const w = mount(ScalarInputRow, {
+ props: { descriptor: { name: "steps", type: "int", required: true }, modelValue: null },
+ });
+ await w.find("input").setValue("42");
+ expect(w.emitted("update:modelValue")?.at(-1)).toEqual([42]);
+ });
+
+ it("renders a select for enum descriptors", () => {
+ const w = mount(ScalarInputRow, {
+ props: {
+ descriptor: { name: "mode", type: "enum", required: true, options: ["a", "b"] },
+ modelValue: null,
+ },
+ });
+ expect(w.find("select").exists()).toBe(true);
+ });
+});
+```
+
+`FileInputRow.spec.ts`:
+
+```ts
+import { mount } from "@vue/test-utils";
+import { describe, expect, it } from "vitest";
+import FileInputRow from "../../../../js/components/launch/FileInputRow.vue";
+
+const STORAGES = [
+ { storage_id: "my-home", name: "My Home", is_primary: true },
+ { storage_id: "scratch", name: "Scratch", is_primary: false },
+];
+
+describe("FileInputRow", () => {
+ it("emits update:modelValue when storage changes", async () => {
+ const w = mount(FileInputRow, {
+ props: {
+ descriptor: { name: "sim_dir", type: "dir", required: true },
+ modelValue: { storage_id: "my-home", path: "" },
+ storages: STORAGES,
+ },
+ });
+ await w.find("select").setValue("scratch");
+ expect(w.emitted("update:modelValue")?.at(-1)).toEqual([{ storage_id: "scratch", path: "" }]);
+ });
+
+ it("emits update:modelValue when path changes", async () => {
+ const w = mount(FileInputRow, {
+ props: {
+ descriptor: { name: "sim_dir", type: "dir", required: true },
+ modelValue: { storage_id: "my-home", path: "" },
+ storages: STORAGES,
+ },
+ });
+ await w.find("input[data-test='file-path-sim_dir']").setValue("/home/x/sim");
+ expect(w.emitted("update:modelValue")?.at(-1)).toEqual([{ storage_id: "my-home", path: "/home/x/sim" }]);
+ });
+
+ it("renders a stage-in badge", () => {
+ const w = mount(FileInputRow, {
+ props: {
+ descriptor: { name: "sim_dir", type: "dir", required: true },
+ modelValue: null,
+ storages: STORAGES,
+ },
+ });
+ expect(w.find("[data-test='io-badge']").text()).toMatch(/stage-in/i);
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- "ScalarInputRow|FileInputRow"`
+Expected: FAIL — files not found.
+
+- [ ] **Step 3: Implement ScalarInputRow**
+
+```vue
+<template>
+ <div class="row mb-1 align-items-center">
+ <label class="col-md-3 col-form-label" :for="`scalar-${descriptor.name}`">
+ <code>{{ descriptor.name }}</code>
+ <span class="text-muted small ms-1">{{ descriptor.type }}</span>
+ </label>
+ <div class="col-md-9">
+ <select
+ v-if="descriptor.type === 'enum'"
+ :id="`scalar-${descriptor.name}`"
+ :data-test="`scalar-${descriptor.name}`"
+ class="form-select"
+ :value="modelValue ?? ''"
+ @change="emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
+ >
+ <option value="" disabled>Choose…</option>
+ <option v-for="o in descriptor.options ?? []" :key="o" :value="o">{{ o }}</option>
+ </select>
+ <input
+ v-else
+ :id="`scalar-${descriptor.name}`"
+ :data-test="`scalar-${descriptor.name}`"
+ class="form-control"
+ :type="inputType"
+ :value="modelValue ?? ''"
+ @input="onInput(($event.target as HTMLInputElement).value)"
+ />
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from "vue";
+import type { IODescriptor, ScalarValue } from "django-airavata-common-ui/stores/launch-types";
+
+const props = defineProps<{ descriptor: IODescriptor; modelValue: ScalarValue | null }>();
+const emit = defineEmits<{ "update:modelValue": [v: ScalarValue | null] }>();
+
+const inputType = computed(() => {
+ switch (props.descriptor.type) {
+ case "int":
+ case "float":
+ return "number";
+ case "bool":
+ return "checkbox";
+ default:
+ return "text";
+ }
+});
+
+function onInput(raw: string) {
+ if (props.descriptor.type === "int") emit("update:modelValue", raw === "" ? null : Number.parseInt(raw, 10));
+ else if (props.descriptor.type === "float") emit("update:modelValue", raw === "" ? null : Number.parseFloat(raw));
+ else emit("update:modelValue", raw);
+}
+</script>
+```
+
+- [ ] **Step 4: Implement FileInputRow**
+
+```vue
+<template>
+ <div class="d-flex align-items-center gap-2 border rounded p-2 mb-1">
+ <div class="flex-shrink-0" style="width: 140px;">
+ <code>{{ descriptor.name }}</code>
+ <span class="text-muted small ms-1">{{ descriptor.type }}</span>
+ </div>
+ <select
+ class="form-select form-select-sm"
+ style="width: 160px;"
+ :value="modelValue?.storage_id ?? ''"
+ @change="onStorage(($event.target as HTMLSelectElement).value)"
+ >
+ <option value="" disabled>Storage…</option>
+ <option v-for="s in storages" :key="s.storage_id" :value="s.storage_id">{{ s.name }}</option>
+ </select>
+ <input
+ class="form-control form-control-sm flex-grow-1"
+ :data-test="`file-path-${descriptor.name}`"
+ :value="modelValue?.path ?? ''"
+ placeholder="/path/to/data"
+ @input="onPath(($event.target as HTMLInputElement).value)"
+ />
+ <span data-test="io-badge" class="badge bg-warning text-dark">stage-in</span>
+ </div>
+</template>
+
+<script setup lang="ts">
+import type { IODescriptor, StorageRef, UserStorage } from "django-airavata-common-ui/stores/launch-types";
+
+const props = defineProps<{
+ descriptor: IODescriptor;
+ modelValue: StorageRef | null;
+ storages: UserStorage[];
+}>();
+const emit = defineEmits<{ "update:modelValue": [v: StorageRef] }>();
+
+function onStorage(id: string) {
+ emit("update:modelValue", { storage_id: id, path: props.modelValue?.path ?? "" });
+}
+function onPath(p: string) {
+ emit("update:modelValue", { storage_id: props.modelValue?.storage_id ?? "", path: p });
+}
+</script>
+```
+
+- [ ] **Step 5: Run to verify they pass**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- "ScalarInputRow|FileInputRow"`
+Expected: PASS — 6 tests OK.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/ScalarInputRow.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/FileInputRow.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/ScalarInputRow.spec.ts \
+ django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/FileInputRow.spec.ts
+git commit -m "feat(launcher): ScalarInputRow + FileInputRow"
+```
+
+---
+
+### Task 13: InputList + OutputList + FileOutputRow
+
+**Files:**
+- Create: `…/js/components/launch/InputList.vue`
+- Create: `…/js/components/launch/OutputList.vue`
+- Create: `…/js/components/launch/FileOutputRow.vue`
+- Create: `…/tests/unit/components/launch/InputList.spec.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { mount } from "@vue/test-utils";
+import { createPinia, setActivePinia } from "pinia";
+import { beforeEach, describe, expect, it } from "vitest";
+import InputList from "../../../../js/components/launch/InputList.vue";
+import { useLaunchStore } from "django-airavata-common-ui/stores/launch";
+
+const APP = {
+ app_id: "namd", name: "NAMD", category: "MD",
+ content: { kind: "github" as const, url: "g" },
+ interfaces: [{
+ name: "run",
+ inputs: [
+ { name: "sim_dir", type: "dir" as const, required: true },
+ { name: "steps", type: "int" as const, required: true },
+ ],
+ outputs: [],
+ }],
+};
+
+const STORAGES = [{ storage_id: "my-home", name: "My Home", is_primary: true }];
+
+describe("InputList", () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ const s = useLaunchStore();
+ s.pickApp(APP);
+ s.pickInterface("run");
+ });
+
+ it("renders one row per declared input", () => {
+ const w = mount(InputList, { props: { storages: STORAGES } });
+ expect(w.findAll("[data-test='input-row']")).toHaveLength(2);
+ });
+
+ it("scalar input writes through to the store", async () => {
+ const w = mount(InputList, { props: { storages: STORAGES } });
+ await w.find("input[data-test='scalar-steps']").setValue("5000");
+ expect(useLaunchStore().draft.inputs.steps).toBe(5000);
+ });
+
+ it("file input writes through to the store", async () => {
+ const w = mount(InputList, { props: { storages: STORAGES } });
+ await w.find("input[data-test='file-path-sim_dir']").setValue("/data");
+ expect(useLaunchStore().draft.inputs.sim_dir).toEqual({ storage_id: "", path: "/data" });
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- InputList`
+Expected: FAIL — file not found.
+
+- [ ] **Step 3: Implement InputList**
+
+```vue
+<template>
+ <div v-if="store.pickedInterface">
+ <div
+ v-for="io in store.pickedInterface.inputs"
+ :key="io.name"
+ data-test="input-row"
+ >
+ <FileInputRow
+ v-if="io.type === 'file' || io.type === 'dir'"
+ :descriptor="io"
+ :model-value="(store.draft.inputs[io.name] as { storage_id: string; path: string } | null) ?? null"
+ :storages="storages"
+ @update:model-value="store.setInput(io.name, $event)"
+ />
+ <ScalarInputRow
+ v-else
+ :descriptor="io"
+ :model-value="(store.draft.inputs[io.name] as string | number | boolean | null) ?? null"
+ @update:model-value="store.setInput(io.name, $event)"
+ />
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import type { UserStorage } from "django-airavata-common-ui/stores/launch-types";
+import { useLaunchStore } from "django-airavata-common-ui/stores/launch";
+import FileInputRow from "./FileInputRow.vue";
+import ScalarInputRow from "./ScalarInputRow.vue";
+
+defineProps<{ storages: UserStorage[] }>();
+const store = useLaunchStore();
+</script>
+```
+
+- [ ] **Step 4: Implement FileOutputRow + OutputList**
+
+`FileOutputRow.vue`:
+
+```vue
+<template>
+ <div class="d-flex align-items-center gap-2 border rounded p-2 mb-1">
+ <div class="flex-shrink-0" style="width: 140px;">
+ <code>{{ descriptor.name }}</code>
+ <span class="text-muted small ms-1">{{ descriptor.type }}</span>
+ </div>
+ <select
+ class="form-select form-select-sm"
+ style="width: 160px;"
+ :value="modelValue?.storage_id ?? ''"
+ @change="onStorage(($event.target as HTMLSelectElement).value)"
+ >
+ <option value="" disabled>Storage…</option>
+ <option v-for="s in storages" :key="s.storage_id" :value="s.storage_id">{{ s.name }}</option>
+ </select>
+ <input
+ class="form-control form-control-sm flex-grow-1"
+ :data-test="`file-out-path-${descriptor.name}`"
+ :value="modelValue?.path ?? ''"
+ placeholder="/path/to/output"
+ @input="onPath(($event.target as HTMLInputElement).value)"
+ />
+ <span class="badge bg-info text-dark">stage-out</span>
+ </div>
+</template>
+
+<script setup lang="ts">
+import type { IODescriptor, StorageRef, UserStorage } from "django-airavata-common-ui/stores/launch-types";
+
+const props = defineProps<{
+ descriptor: IODescriptor;
+ modelValue: StorageRef | null;
+ storages: UserStorage[];
+}>();
+const emit = defineEmits<{ "update:modelValue": [v: StorageRef] }>();
+
+function onStorage(id: string) {
+ emit("update:modelValue", { storage_id: id, path: props.modelValue?.path ?? "" });
+}
+function onPath(p: string) {
+ emit("update:modelValue", { storage_id: props.modelValue?.storage_id ?? "", path: p });
+}
+</script>
+```
+
+`OutputList.vue`:
+
+```vue
+<template>
+ <div v-if="store.pickedInterface">
+ <FileOutputRow
+ v-for="io in fileOutputs"
+ :key="io.name"
+ :descriptor="io"
+ :model-value="store.draft.outputs[io.name] ?? null"
+ :storages="storages"
+ @update:model-value="store.setOutput(io.name, $event)"
+ />
+ </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from "vue";
+import type { UserStorage } from "django-airavata-common-ui/stores/launch-types";
+import { useLaunchStore } from "django-airavata-common-ui/stores/launch";
+import FileOutputRow from "./FileOutputRow.vue";
+
+defineProps<{ storages: UserStorage[] }>();
+const store = useLaunchStore();
+const fileOutputs = computed(() =>
+ (store.pickedInterface?.outputs ?? []).filter((o) => o.type === "file" || o.type === "dir"),
+);
+</script>
+```
+
+- [ ] **Step 5: Run to verify it passes**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- InputList`
+Expected: PASS — 3 tests OK.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/InputList.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/OutputList.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/FileOutputRow.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/InputList.spec.ts
+git commit -m "feat(launcher): InputList + OutputList + FileOutputRow"
+```
+
+---
+
+### Task 14: Tab1ApplicationInputs (assembly)
+
+**Files:**
+- Modify: `…/js/components/launch/Tab1ApplicationInputs.vue` (replaces stub from Task 9)
+- Create: `…/tests/unit/components/launch/Tab1ApplicationInputs.spec.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { mount, flushPromises } from "@vue/test-utils";
+import { createPinia, setActivePinia } from "pinia";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import Tab1ApplicationInputs from "../../../../js/components/launch/Tab1ApplicationInputs.vue";
+import { useLaunchStore } from "django-airavata-common-ui/stores/launch";
+
+vi.mock("django-airavata-common-ui/services/launcherService", () => ({
+ launcherService: {
+ listApplications: vi.fn().mockResolvedValue({
+ results: [{
+ app_id: "namd", name: "NAMD", category: "MD",
+ content: { kind: "github", url: "g" }, interfaces: [],
+ }],
+ }),
+ listUserStorages: vi.fn().mockResolvedValue({
+ results: [{ storage_id: "my-home", name: "My Home", is_primary: true }],
+ }),
+ },
+}));
+
+describe("Tab1ApplicationInputs", () => {
+ beforeEach(() => setActivePinia(createPinia()));
+
+ it("loads applications + storages on mount", async () => {
+ const w = mount(Tab1ApplicationInputs);
+ await flushPromises();
+ expect(w.findAll("[data-test='app-tile']")).toHaveLength(1);
+ expect(useLaunchStore().storages).toHaveLength(1);
+ });
+
+ it("does not render the inputs section before an app+interface are picked", async () => {
+ const w = mount(Tab1ApplicationInputs);
+ await flushPromises();
+ expect(w.find("[data-test='inputs-section']").exists()).toBe(false);
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- Tab1ApplicationInputs`
+Expected: FAIL — current stub doesn't load apps.
+
+- [ ] **Step 3: Implement Tab1ApplicationInputs**
+
+Replace the stub at `…/js/components/launch/Tab1ApplicationInputs.vue`:
+
+```vue
+<template>
+ <div>
+ <section class="mb-3">
+ <div class="text-uppercase text-primary small fw-bold mb-1">Application</div>
+ <AppPicker :applications="apps" />
+ </section>
+ <section v-if="store.pickedApp" class="mb-3">
+ <div class="text-uppercase text-primary small fw-bold mb-1">Interface</div>
+ <InterfacePicker />
+ </section>
+ <section v-if="store.pickedInterface" data-test="inputs-section" class="mb-3">
+ <div class="text-uppercase text-primary small fw-bold mb-1">Inputs</div>
+ <InputList :storages="store.storages" />
+ </section>
+ <section v-if="store.pickedInterface && fileOutputCount > 0" class="mb-3">
+ <div class="text-uppercase text-primary small fw-bold mb-1">Outputs</div>
+ <OutputList :storages="store.storages" />
+ </section>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, ref } from "vue";
+import { launcherService } from "django-airavata-common-ui/services/launcherService";
+import { useLaunchStore } from "django-airavata-common-ui/stores/launch";
+import type { Application } from "django-airavata-common-ui/stores/launch-types";
+import AppPicker from "./AppPicker.vue";
+import InterfacePicker from "./InterfacePicker.vue";
+import InputList from "./InputList.vue";
+import OutputList from "./OutputList.vue";
+
+const store = useLaunchStore();
+const apps = ref<Application[]>([]);
+
+onMounted(async () => {
+ const [a, s] = await Promise.all([launcherService.listApplications(), launcherService.listUserStorages()]);
+ apps.value = a.results;
+ store.storages = s.results;
+});
+
+const fileOutputCount = computed(() =>
+ (store.pickedInterface?.outputs ?? []).filter((o) => o.type === "file" || o.type === "dir").length,
+);
+</script>
+```
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- Tab1ApplicationInputs`
+Expected: PASS — 2 tests OK.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab1ApplicationInputs.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/Tab1ApplicationInputs.spec.ts
+git commit -m "feat(launcher): Tab1ApplicationInputs assembly"
+```
+
+---
+
+## Phase 4 — Tab 2 components
+
+### Task 15: RuntimeInputs (lean profile-driven editor)
+
+**Files:**
+- Create: `…/js/components/launch/runtime/RuntimeInputs.vue`
+- Create: `…/tests/unit/components/launch/runtime/RuntimeInputs.spec.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { mount } from "@vue/test-utils";
+import { describe, expect, it } from "vitest";
+import RuntimeInputs from "../../../../../js/components/launch/runtime/RuntimeInputs.vue";
+
+const PROFILE = {
+ project_id: "p1", allocation_id: "NSF-1",
+ compute_resources: [{
+ compute_resource_id: "bridges-2", name: "Bridges-2",
+ mapped_storage: { storage_id: "scratch", scratch_path: "/scratch/p1" },
+ partitions: [{ name: "RM", max_walltime: "48:00:00", max_nodes: 64, cpus_per_node: 128 }],
+ }],
+};
+
+describe("RuntimeInputs", () => {
+ it("populates compute resource dropdown from profile", () => {
+ const w = mount(RuntimeInputs, {
+ props: { profile: PROFILE, modelValue: { compute_resource_id: null, partition: null,
+ walltime: "01:00:00", nodes: 1, cpus_per_node: 1 } },
+ });
+ expect(w.find("select[data-test='cr']").findAll("option").map((o) => o.text())).toContain("Bridges-2");
+ });
+
+ it("emits update:modelValue when the resource changes", async () => {
+ const w = mount(RuntimeInputs, {
+ props: { profile: PROFILE, modelValue: { compute_resource_id: null, partition: null,
+ walltime: "01:00:00", nodes: 1, cpus_per_node: 1 } },
+ });
+ await w.find("select[data-test='cr']").setValue("bridges-2");
+ expect(w.emitted("update:modelValue")?.at(-1)).toEqual([
+ expect.objectContaining({ compute_resource_id: "bridges-2", partition: null }),
+ ]);
+ });
+
+ it("limits partition options to the chosen resource", async () => {
+ const w = mount(RuntimeInputs, {
+ props: { profile: PROFILE, modelValue: { compute_resource_id: "bridges-2", partition: null,
+ walltime: "01:00:00", nodes: 1, cpus_per_node: 1 } },
+ });
+ const opts = w.find("select[data-test='partition']").findAll("option").map((o) => o.text());
+ expect(opts).toContain("RM");
+ expect(opts).not.toContain("nonexistent");
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- RuntimeInputs`
+Expected: FAIL — file not found.
+
+- [ ] **Step 3: Implement RuntimeInputs**
+
+```vue
+<template>
+ <div class="row g-2">
+ <div class="col-md-3">
+ <label class="form-label small">Compute resource</label>
+ <select
+ data-test="cr"
+ class="form-select form-select-sm"
+ :value="modelValue.compute_resource_id ?? ''"
+ @change="onCR(($event.target as HTMLSelectElement).value)"
+ >
+ <option value="" disabled>Choose…</option>
+ <option v-for="cr in profile.compute_resources" :key="cr.compute_resource_id" :value="cr.compute_resource_id">
+ {{ cr.name }}
+ </option>
+ </select>
+ </div>
+ <div class="col-md-3">
+ <label class="form-label small">Partition</label>
+ <select
+ data-test="partition"
+ class="form-select form-select-sm"
+ :disabled="!partitions.length"
+ :value="modelValue.partition ?? ''"
+ @change="emit('update:modelValue', { ...modelValue, partition: ($event.target as HTMLSelectElement).value })"
+ >
+ <option value="" disabled>Choose…</option>
+ <option v-for="p in partitions" :key="p.name" :value="p.name">{{ p.name }}</option>
+ </select>
+ </div>
+ <div class="col-md-2">
+ <label class="form-label small">Walltime</label>
+ <input
+ data-test="walltime"
+ class="form-control form-control-sm"
+ :value="modelValue.walltime"
+ @input="emit('update:modelValue', { ...modelValue, walltime: ($event.target as HTMLInputElement).value })"
+ />
+ </div>
+ <div class="col-md-2">
+ <label class="form-label small">Nodes</label>
+ <input
+ type="number" min="1"
+ data-test="nodes"
+ class="form-control form-control-sm"
+ :value="modelValue.nodes"
+ @input="emit('update:modelValue', { ...modelValue, nodes: Number(($event.target as HTMLInputElement).value) })"
+ />
+ </div>
+ <div class="col-md-2">
+ <label class="form-label small">CPUs / node</label>
+ <input
+ type="number" min="1"
+ data-test="cpus"
+ class="form-control form-control-sm"
+ :value="modelValue.cpus_per_node"
+ @input="emit('update:modelValue', { ...modelValue, cpus_per_node: Number(($event.target as HTMLInputElement).value) })"
+ />
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from "vue";
+import type { ResourceProfile, RuntimeChoice } from "django-airavata-common-ui/stores/launch-types";
+
+const props = defineProps<{ profile: ResourceProfile; modelValue: RuntimeChoice }>();
+const emit = defineEmits<{ "update:modelValue": [v: RuntimeChoice] }>();
+
+const partitions = computed(() => {
+ if (!props.modelValue.compute_resource_id) return [];
+ const cr = props.profile.compute_resources.find(
+ (c) => c.compute_resource_id === props.modelValue.compute_resource_id,
+ );
+ return cr?.partitions ?? [];
+});
+
+function onCR(id: string) {
+ emit("update:modelValue", { ...props.modelValue, compute_resource_id: id, partition: null });
+}
+</script>
+```
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- RuntimeInputs`
+Expected: PASS — 3 tests OK.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/runtime/RuntimeInputs.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/runtime/RuntimeInputs.spec.ts
+git commit -m "feat(launcher): RuntimeInputs (profile-driven cr/partition/walltime)"
+```
+
+---
+
+### Task 16: Tab2Runtime (assembly + readouts)
+
+**Files:**
+- Modify: `…/js/components/launch/Tab2Runtime.vue` (replaces stub)
+- Create: `…/tests/unit/components/launch/Tab2Runtime.spec.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { mount, flushPromises } from "@vue/test-utils";
+import { createPinia, setActivePinia } from "pinia";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import Tab2Runtime from "../../../../js/components/launch/Tab2Runtime.vue";
+import { useLaunchStore } from "django-airavata-common-ui/stores/launch";
+
+const PROFILE = {
+ project_id: "p1", allocation_id: "NSF-1",
+ compute_resources: [{
+ compute_resource_id: "bridges-2", name: "Bridges-2",
+ mapped_storage: { storage_id: "scratch", scratch_path: "/scratch/p1" },
+ partitions: [{ name: "RM", max_walltime: "48:00:00", max_nodes: 64, cpus_per_node: 128 }],
+ }],
+};
+
+vi.mock("django-airavata-common-ui/services/launcherService", () => ({
+ launcherService: {
+ getProjectResourceProfile: vi.fn().mockResolvedValue(PROFILE),
+ },
+}));
+
+describe("Tab2Runtime", () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ useLaunchStore().setMeta({ name: "x", project_id: "p1", description: "" });
+ });
+
+ it("renders the readout once a CR is picked", async () => {
+ const w = mount(Tab2Runtime);
+ await flushPromises();
+ useLaunchStore().setRuntime({
+ compute_resource_id: "bridges-2", partition: "RM",
+ walltime: "01:00:00", nodes: 1, cpus_per_node: 8,
+ });
+ await flushPromises();
+ expect(w.text()).toContain("NSF-1");
+ expect(w.text()).toContain("bridges-2");
+ expect(w.text()).toContain("/scratch/p1");
+ });
+
+ it("re-fetches the profile when project changes", async () => {
+ const { launcherService } = await import("django-airavata-common-ui/services/launcherService");
+ const w = mount(Tab2Runtime);
+ await flushPromises();
+ useLaunchStore().setMeta({ name: "x", project_id: "p2", description: "" });
+ await flushPromises();
+ expect(launcherService.getProjectResourceProfile).toHaveBeenCalledWith("p2");
+ expect(w).toBeTruthy();
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- Tab2Runtime`
+Expected: FAIL — current stub doesn't fetch.
+
+- [ ] **Step 3: Implement Tab2Runtime**
+
+Replace `…/js/components/launch/Tab2Runtime.vue`:
+
+```vue
+<template>
+ <div v-if="store.profile">
+ <RuntimeInputs
+ :profile="store.profile"
+ :model-value="store.draft.runtime"
+ @update:model-value="store.setRuntime($event)"
+ />
+ <p class="small text-muted mt-2">
+ Allocation <code>{{ store.profile.allocation_id }}</code> (auto from project) ·
+ <span v-if="pickedCR">
+ Compute storage <code>{{ pickedCR.mapped_storage.storage_id }}</code>
+ scratch <code>{{ pickedCR.mapped_storage.scratch_path }}</code>
+ </span>
+ </p>
+ </div>
+ <div v-else class="text-muted">Loading resource profile…</div>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, watch } from "vue";
+import { useLaunchStore } from "django-airavata-common-ui/stores/launch";
+import { launcherService } from "django-airavata-common-ui/services/launcherService";
+import RuntimeInputs from "./runtime/RuntimeInputs.vue";
+
+const store = useLaunchStore();
+
+async function refetch(projectId: string) {
+ const p = await launcherService.getProjectResourceProfile(projectId);
+ store.profile = p;
+}
+
+onMounted(() => {
+ if (store.draft.project_id) void refetch(store.draft.project_id);
+});
+
+watch(
+ () => store.draft.project_id,
+ (id) => { if (id) void refetch(id); else store.profile = null; },
+);
+
+const pickedCR = computed(() =>
+ store.profile?.compute_resources.find((c) => c.compute_resource_id === store.draft.runtime.compute_resource_id) ?? null,
+);
+</script>
+```
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- Tab2Runtime`
+Expected: PASS — 2 tests OK.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab2Runtime.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/Tab2Runtime.spec.ts
+git commit -m "feat(launcher): Tab2Runtime assembly + readouts"
+```
+
+---
+
+## Phase 5 — Tab 3 components
+
+### Task 17: ScriptPreview + InvocationCommand
+
+**Files:**
+- Create: `…/js/components/launch/ScriptPreview.vue`
+- Create: `…/js/components/launch/InvocationCommand.vue`
+- Create: `…/tests/unit/components/launch/ScriptPreview.spec.ts`
+
+- [ ] **Step 1: Write the failing tests**
+
+```ts
+import { mount } from "@vue/test-utils";
+import { describe, expect, it } from "vitest";
+import ScriptPreview from "../../../../js/components/launch/ScriptPreview.vue";
+import InvocationCommand from "../../../../js/components/launch/InvocationCommand.vue";
+
+describe("ScriptPreview", () => {
+ it("renders the script in a code block", () => {
+ const w = mount(ScriptPreview, { props: { script: "#!/bin/bash\necho hi\n" } });
+ expect(w.find("pre code").text()).toContain("echo hi");
+ });
+
+ it("is read-only (no contenteditable)", () => {
+ const w = mount(ScriptPreview, { props: { script: "x" } });
+ expect(w.find("pre").attributes("contenteditable")).toBeUndefined();
+ });
+});
+
+describe("InvocationCommand", () => {
+ it("renders the command", () => {
+ const w = mount(InvocationCommand, { props: { command: "sbatch /tmp/run.sh" } });
+ expect(w.text()).toContain("sbatch /tmp/run.sh");
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- "ScriptPreview|InvocationCommand"`
+Expected: FAIL — files not found.
+
+- [ ] **Step 3: Implement both components**
+
+`ScriptPreview.vue`:
+
+```vue
+<template>
+ <pre class="bg-dark text-light p-3 rounded small mb-0" style="overflow-x: auto;"><code>{{ script }}</code></pre>
+</template>
+
+<script setup lang="ts">
+defineProps<{ script: string }>();
+</script>
+```
+
+`InvocationCommand.vue`:
+
+```vue
+<template>
+ <div class="text-muted small mb-2">
+ Invocation: <code>{{ command }}</code>
+ </div>
+</template>
+
+<script setup lang="ts">
+defineProps<{ command: string }>();
+</script>
+```
+
+- [ ] **Step 4: Run to verify they pass**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- "ScriptPreview|InvocationCommand"`
+Expected: PASS — 3 tests OK.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/ScriptPreview.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/InvocationCommand.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/ScriptPreview.spec.ts
+git commit -m "feat(launcher): ScriptPreview + InvocationCommand presentational components"
+```
+
+---
+
+### Task 18: Tab3ReviewLaunch (preview fetch + launch)
+
+**Files:**
+- Modify: `…/js/components/launch/Tab3ReviewLaunch.vue` (replaces stub)
+- Create: `…/tests/unit/components/launch/Tab3ReviewLaunch.spec.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { mount, flushPromises } from "@vue/test-utils";
+import { createPinia, setActivePinia } from "pinia";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import Tab3ReviewLaunch from "../../../../js/components/launch/Tab3ReviewLaunch.vue";
+import { useLaunchStore } from "django-airavata-common-ui/stores/launch";
+
+vi.mock("django-airavata-common-ui/services/launcherService", () => ({
+ launcherService: {
+ generatePreview: vi.fn().mockResolvedValue({
+ invocation_command: "sbatch /tmp/run.sh",
+ script_contents: "#!/bin/bash\necho hi\n",
+ warnings: ["check walltime"],
+ }),
+ launchExperiment: vi.fn().mockResolvedValue({ experiment_id: "exp-42" }),
+ },
+}));
+
+const NAV = vi.fn();
+Object.defineProperty(window, "location", { value: { href: "/", assign: NAV }, writable: true });
+
+describe("Tab3ReviewLaunch", () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ NAV.mockClear();
+ });
+
+ it("renders the preview after fetch", async () => {
+ const w = mount(Tab3ReviewLaunch);
+ await flushPromises();
+ expect(w.text()).toContain("sbatch /tmp/run.sh");
+ expect(w.text()).toContain("echo hi");
+ });
+
+ it("renders warnings as a list", async () => {
+ const w = mount(Tab3ReviewLaunch);
+ await flushPromises();
+ expect(w.find("[data-test='warnings']").text()).toContain("check walltime");
+ });
+
+ it("disables launch when preview failed", async () => {
+ const { launcherService } = await import("django-airavata-common-ui/services/launcherService");
+ (launcherService.generatePreview as unknown as { mockRejectedValueOnce: Function }).mockRejectedValueOnce(new Error("nope"));
+ const w = mount(Tab3ReviewLaunch);
+ await flushPromises();
+ expect(w.find("button[data-test='launch']").attributes("disabled")).toBeDefined();
+ });
+
+ it("redirects on successful launch", async () => {
+ const w = mount(Tab3ReviewLaunch);
+ await flushPromises();
+ await w.find("button[data-test='launch']").trigger("click");
+ await flushPromises();
+ expect(window.location.href).toBe("/workspace/experiments/exp-42");
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- Tab3ReviewLaunch`
+Expected: FAIL — current stub doesn't fetch.
+
+- [ ] **Step 3: Implement Tab3ReviewLaunch**
+
+Replace `…/js/components/launch/Tab3ReviewLaunch.vue`:
+
+```vue
+<template>
+ <div>
+ <div v-if="loading" class="text-muted">Generating preview…</div>
+ <div v-else-if="error" class="alert alert-danger">
+ <div>{{ error }}</div>
+ <button class="btn btn-sm btn-outline-light mt-2" @click="refresh">Retry</button>
+ </div>
+ <div v-else-if="store.preview">
+ <ul v-if="store.preview.warnings.length" class="alert alert-warning small" data-test="warnings">
+ <li v-for="w in store.preview.warnings" :key="w">{{ w }}</li>
+ </ul>
+ <InvocationCommand :command="store.preview.invocation_command" />
+ <ScriptPreview :script="store.preview.script_contents" />
+ <div v-if="launchError" class="alert alert-danger mt-2">
+ {{ launchError }}
+ <button class="btn btn-sm btn-outline-light ms-2" @click="onLaunch">Try again</button>
+ </div>
+ <div class="d-flex justify-content-end mt-3">
+ <button
+ class="btn btn-primary"
+ data-test="launch"
+ :disabled="!store.preview || loading"
+ @click="onLaunch"
+ >
+ Launch experiment
+ </button>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref } from "vue";
+import { useLaunchStore } from "django-airavata-common-ui/stores/launch";
+import { launcherService } from "django-airavata-common-ui/services/launcherService";
+import InvocationCommand from "./InvocationCommand.vue";
+import ScriptPreview from "./ScriptPreview.vue";
+
+const store = useLaunchStore();
+const loading = ref(false);
+const error = ref<string | null>(null);
+const launchError = ref<string | null>(null);
+let abort: AbortController | null = null;
+
+async function refresh() {
+ if (store.preview && store.lastPreviewedHash === store.draftHash) return;
+ loading.value = true;
+ error.value = null;
+ abort?.abort();
+ abort = new AbortController();
+ try {
+ const r = await launcherService.generatePreview(store.draft, abort.signal);
+ store.preview = r;
+ store.lastPreviewedHash = store.draftHash;
+ } catch (e) {
+ error.value = (e as Error).message;
+ store.preview = null;
+ } finally {
+ loading.value = false;
+ }
+}
+
+async function onLaunch() {
+ launchError.value = null;
+ try {
+ const { experiment_id } = await launcherService.launchExperiment(store.draft);
+ window.location.href = `/workspace/experiments/${experiment_id}`;
+ } catch (e) {
+ launchError.value = (e as Error).message;
+ }
+}
+
+onMounted(() => { void refresh(); });
+</script>
+```
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- Tab3ReviewLaunch`
+Expected: PASS — 4 tests OK.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab3ReviewLaunch.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/Tab3ReviewLaunch.spec.ts
+git commit -m "feat(launcher): Tab3ReviewLaunch preview fetch + launch flow"
+```
+
+---
+
+## Phase 6 — Edge cases
+
+### Task 19: localStorage draft persistence
+
+**Files:**
+- Modify: `django_airavata/static/common/js/stores/launch.ts`
+- Modify: `…/tests/unit/stores/launch.spec.ts`
+
+- [ ] **Step 1: Add the failing test**
+
+Append to `launch.spec.ts`:
+
+```ts
+describe("draft persistence", () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ localStorage.clear();
+ });
+
+ it("saves draft to localStorage on every change", () => {
+ const s = useLaunchStore();
+ s.setMeta({ name: "abc", project_id: "p1", description: "" });
+ const stored = localStorage.getItem("launch-draft");
+ expect(stored).not.toBeNull();
+ expect(JSON.parse(stored!).name).toBe("abc");
+ });
+
+ it("restores draft from localStorage on hydrate()", () => {
+ localStorage.setItem("launch-draft", JSON.stringify({
+ name: "restored", project_id: "p1", description: "",
+ app_id: null, interface_name: null, inputs: {}, outputs: {},
+ runtime: { compute_resource_id: null, partition: null,
+ walltime: "01:00:00", nodes: 1, cpus_per_node: 1 },
+ }));
+ const s = useLaunchStore();
+ s.hydrate();
+ expect(s.draft.name).toBe("restored");
+ });
+
+ it("reset() clears localStorage", () => {
+ const s = useLaunchStore();
+ s.setMeta({ name: "abc", project_id: "p1", description: "" });
+ s.reset();
+ expect(localStorage.getItem("launch-draft")).toBeNull();
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- launch.spec`
+Expected: FAIL — `hydrate()` not defined; localStorage write not triggered.
+
+- [ ] **Step 3: Implement persistence**
+
+Edit `django_airavata/static/common/js/stores/launch.ts`. After the `useLaunchStore` setup completes, add a `watch` for autosave and a `hydrate()` method. Insert before the `reset` function:
+
+```ts
+import { watch } from "vue";
+
+const STORAGE_KEY = "launch-draft";
+
+function persist(draft: ExperimentDraft) {
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(draft)); } catch { /* ignore quota */ }
+}
+
+function clearPersisted() {
+ try { localStorage.removeItem(STORAGE_KEY); } catch { /* noop */ }
+}
+
+function hydrate() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return;
+ const parsed = JSON.parse(raw) as Partial<ExperimentDraft>;
+ Object.assign(draft, makeDraft(), parsed);
+ } catch {
+ /* ignore corrupted draft */
+ }
+}
+
+watch(draft, (d) => persist(d), { deep: true });
+```
+
+Update `reset()` to call `clearPersisted()`:
+
+```ts
+function reset() {
+ Object.assign(draft, makeDraft());
+ pickedApp.value = null;
+ profile.value = null;
+ preview.value = null;
+ previewError.value = null;
+ lastPreviewedHash.value = null;
+ clearPersisted();
+}
+```
+
+Export `hydrate` in the store's return object.
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- launch.spec`
+Expected: PASS — 8 tests OK.
+
+- [ ] **Step 5: Wire `hydrate()` from LaunchContainer**
+
+Edit `…/js/containers/LaunchContainer.vue`. Inside the `<script setup>` block, replace `onMounted(...)` with:
+
+```ts
+onMounted(() => {
+ store.hydrate();
+ const url = new URL(window.location.href);
+ const t = Number(url.searchParams.get("tab"));
+ if (t === 1 || t === 2 || t === 3) active.value = t as 1 | 2 | 3;
+});
+```
+
+Add `import { useLaunchStore } from "django-airavata-common-ui/stores/launch";` at the top, and `const store = useLaunchStore();` next to `active`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add django_airavata/static/common/js/stores/launch.ts \
+ django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/LaunchContainer.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/stores/launch.spec.ts
+git commit -m "feat(launcher): localStorage draft persistence + hydrate()"
+```
+
+---
+
+### Task 20: Change-warning toasts (project / app / interface)
+
+**Files:**
+- Modify: `…/js/components/launch/ExperimentMetaHeader.vue`
+- Modify: `…/js/components/launch/AppPicker.vue`
+- Modify: `…/js/components/launch/InterfacePicker.vue`
+- Create: `…/js/composables/useConfirmReset.ts`
+- Create: `…/tests/unit/composables/useConfirmReset.spec.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { describe, expect, it, vi } from "vitest";
+import { useConfirmReset } from "../../../js/composables/useConfirmReset";
+
+describe("useConfirmReset", () => {
+ it("calls onConfirm when window.confirm returns true", () => {
+ vi.spyOn(window, "confirm").mockReturnValueOnce(true);
+ const fn = vi.fn();
+ const guarded = useConfirmReset("ok?", fn);
+ guarded();
+ expect(fn).toHaveBeenCalledTimes(1);
+ });
+
+ it("skips onConfirm when window.confirm returns false", () => {
+ vi.spyOn(window, "confirm").mockReturnValueOnce(false);
+ const fn = vi.fn();
+ useConfirmReset("ok?", fn)();
+ expect(fn).not.toHaveBeenCalled();
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- useConfirmReset`
+Expected: FAIL — file not found.
+
+- [ ] **Step 3: Implement the composable**
+
+Create `…/js/composables/useConfirmReset.ts`:
+
+```ts
+export function useConfirmReset<TArgs extends unknown[]>(message: string, fn: (...a: TArgs) => void) {
+ return (...args: TArgs) => {
+ if (window.confirm(message)) fn(...args);
+ };
+}
+```
+
+- [ ] **Step 4: Wire confirm prompts at the call sites**
+
+In `AppPicker.vue` `pick` function:
+
+```ts
+import { useConfirmReset } from "../../composables/useConfirmReset";
+const guardedPick = useConfirmReset("Switching app clears interface, inputs, and outputs. Continue?", (a: Application) => store.pickApp(a));
+function pick(a: Application) {
+ if (store.draft.app_id && store.draft.app_id !== a.app_id) guardedPick(a);
+ else store.pickApp(a);
+}
+```
+
+In `InterfacePicker.vue`, wrap the `pickInterface` button click:
+
+```ts
+import { useConfirmReset } from "../../composables/useConfirmReset";
+const guarded = useConfirmReset("Switching interface clears inputs and outputs. Continue?", (n: string) => store.pickInterface(n));
+function onPick(n: string) {
+ if (store.draft.interface_name && store.draft.interface_name !== n) guarded(n);
+ else store.pickInterface(n);
+}
+```
+
+Replace `@click="store.pickInterface(iface.name)"` with `@click="onPick(iface.name)"`.
+
+In `ExperimentMetaHeader.vue` `onProject`:
+
+```ts
+import { useConfirmReset } from "../../composables/useConfirmReset";
+const guardedProject = useConfirmReset("Switching project clears your runtime selections. Continue?", (id: string | null) => {
+ store.setMeta({ name: store.draft.name, project_id: id, description: store.draft.description });
+});
+function onProject(v: string) {
+ const next = v || null;
+ if (store.draft.project_id && store.draft.project_id !== next) guardedProject(next);
+ else store.setMeta({ name: store.draft.name, project_id: next, description: store.draft.description });
+}
+```
+
+- [ ] **Step 5: Run all related tests to ensure no regressions**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- "AppPicker|InterfacePicker|ExperimentMetaHeader|useConfirmReset"`
+Expected: PASS — all OK. (The component tests don't trigger the confirm path because they always start with no prior selection.)
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add django_airavata/apps/workspace/static/django_airavata_workspace/js/composables/useConfirmReset.ts \
+ django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/AppPicker.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/InterfacePicker.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/ExperimentMetaHeader.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/composables/useConfirmReset.spec.ts
+git commit -m "feat(launcher): confirm prompts on app/interface/project change"
+```
+
+---
+
+## Phase 7 — Integration + E2E tests
+
+### Task 21: Vitest integration test (full happy path)
+
+**Files:**
+- Create: `…/tests/unit/integration/launch-flow.spec.ts`
+
+- [ ] **Step 1: Write the integration spec**
+
+```ts
+import { mount, flushPromises } from "@vue/test-utils";
+import { createPinia, setActivePinia } from "pinia";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import LaunchContainer from "../../../js/containers/LaunchContainer.vue";
+
+const APP = {
+ app_id: "namd", name: "NAMD", category: "MD",
+ content: { kind: "github" as const, url: "g" },
+ interfaces: [{
+ name: "run",
+ inputs: [
+ { name: "sim_dir", type: "dir" as const, required: true },
+ { name: "steps", type: "int" as const, required: true },
+ ],
+ outputs: [{ name: "trajectory", type: "file" as const }],
+ }],
+};
+
+const PROFILE = {
+ project_id: "p1", allocation_id: "NSF-1",
+ compute_resources: [{
+ compute_resource_id: "bridges-2", name: "Bridges-2",
+ mapped_storage: { storage_id: "scratch", scratch_path: "/scratch/p1" },
+ partitions: [{ name: "RM", max_walltime: "48:00:00", max_nodes: 64, cpus_per_node: 128 }],
+ }],
+};
+
+vi.mock("django-airavata-common-ui/services/launcherService", () => ({
+ launcherService: {
+ listApplications: vi.fn().mockResolvedValue({ results: [APP] }),
+ listUserStorages: vi.fn().mockResolvedValue({ results: [{ storage_id: "my-home", name: "Home", is_primary: true }] }),
+ listProjects: vi.fn().mockResolvedValue({ results: [{ project_id: "p1", name: "lab" }] }),
+ getProjectResourceProfile: vi.fn().mockResolvedValue(PROFILE),
+ generatePreview: vi.fn().mockResolvedValue({
+ invocation_command: "sbatch /tmp/run.sh",
+ script_contents: "#!/bin/bash\necho hi",
+ warnings: [],
+ }),
+ launchExperiment: vi.fn().mockResolvedValue({ experiment_id: "exp-7" }),
+ },
+}));
+
+describe("launch flow integration", () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ localStorage.clear();
+ Object.defineProperty(window, "location", {
+ value: { href: "/", assign: vi.fn() }, writable: true,
+ });
+ });
+
+ it("walks tab 1 → tab 2 → tab 3 → launch", async () => {
+ const w = mount(LaunchContainer);
+ await flushPromises();
+
+ // Meta
+ await w.find("input[data-test='exp-name']").setValue("test-run");
+ await w.find("select[data-test='exp-project']").setValue("p1");
+
+ // App + interface
+ await w.find("[data-test='app-tile-namd']").trigger("click");
+ await flushPromises();
+ await w.find("[data-test='iface-card-run']").trigger("click");
+
+ // Inputs + output
+ await w.find("input[data-test='file-path-sim_dir']").setValue("/x");
+ await w.find("input[data-test='scalar-steps']").setValue("100");
+ const outSelect = w.findAll("select").find((s) => s.element.parentElement?.outerHTML.includes("trajectory"))!;
+ await outSelect.setValue("my-home");
+ await w.find("input[data-test='file-out-path-trajectory']").setValue("/y");
+
+ // Tab 2
+ await w.findAll("button[role='tab']")[1].trigger("click");
+ await flushPromises();
+ await w.find("select[data-test='cr']").setValue("bridges-2");
+ await w.find("select[data-test='partition']").setValue("RM");
+
+ // Tab 3
+ await w.findAll("button[role='tab']")[2].trigger("click");
+ await flushPromises();
+ expect(w.text()).toContain("sbatch /tmp/run.sh");
+ await w.find("button[data-test='launch']").trigger("click");
+ await flushPromises();
+ expect(window.location.href).toBe("/workspace/experiments/exp-7");
+ });
+});
+```
+
+- [ ] **Step 2: Run it**
+
+Run: `cd django_airavata/apps/workspace && npm run test -- launch-flow`
+Expected: PASS — 1 test OK. If failing, the most common cause is selector drift after Phase 3-5 changes — fix selectors in this spec or in components, but keep the spec's expected behaviors.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/integration/launch-flow.spec.ts
+git commit -m "test(launcher): full happy-path integration spec (mocked API)"
+```
+
+---
+
+### Task 22: Playwright e2e — happy path + smoke
+
+**Files:**
+- Create: `tests/e2e/specs/launch-happy.spec.ts`
+- Modify: `tests/e2e/specs/smoke.spec.ts`
+
+- [ ] **Step 1: Write the failing happy-path spec**
+
+```ts
+import { test, expect } from "../fixtures/auth";
+
+test("launch a stub experiment via /workspace/launch", async ({ page }) => {
+ await page.goto("/workspace/launch");
+ await expect(page.getByPlaceholder(/Experiment name/i)).toBeVisible();
+
+ await page.getByPlaceholder(/Experiment name/i).fill("e2e-stub");
+ // Project: pick whatever's available
+ const projectSelect = page.locator("select[data-test='exp-project']");
+ await projectSelect.selectOption({ index: 1 });
+
+ // App + interface (stub returns NAMD)
+ await page.locator("[data-test='app-tile-namd']").click();
+ await page.locator("[data-test='iface-card-run']").click();
+
+ // Inputs
+ await page.locator("input[data-test='file-path-sim_dir']").fill("/home/x/sim");
+ await page.locator("input[data-test='scalar-steps']").fill("100");
+ // Output
+ await page.locator("input[data-test='file-out-path-trajectory']").fill("/home/x/out.dcd");
+
+ // Tab 2
+ await page.locator("button[role='tab']").nth(1).click();
+ await page.locator("select[data-test='cr']").selectOption("bridges-2");
+ await page.locator("select[data-test='partition']").selectOption("RM");
+
+ // Tab 3
+ await page.locator("button[role='tab']").nth(2).click();
+ await expect(page.locator("pre code")).toContainText("#!/bin/bash");
+ await page.locator("button[data-test='launch']").click();
+
+ await page.waitForURL(/\/workspace\/experiments\//);
+});
+```
+
+- [ ] **Step 2: Add `/workspace/launch` to smoke**
+
+Edit `tests/e2e/specs/smoke.spec.ts`. Update the `AUTHENTICATED_PAGES` array — replace the existing entry (or insert after `/workspace/home`):
+
+```ts
+const AUTHENTICATED_PAGES = [
+ "/workspace/home",
+ "/workspace/launch",
+ "/workspace/projects",
+ // … rest unchanged
+];
+```
+
+- [ ] **Step 3: Run e2e**
+
+Prereq: airavata-django-portal must be running (`tilt up`) with `LAUNCHER_CLIENT_STUB=True` and `FEATURE_GENERIC_LAUNCHER=True`. Then:
+
+Run: `npx playwright test tests/e2e/specs/launch-happy.spec.ts tests/e2e/specs/smoke.spec.ts -c tooling/playwright.config.ts`
+Expected: PASS — happy path completes; all smoke pages load.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add tests/e2e/specs/launch-happy.spec.ts tests/e2e/specs/smoke.spec.ts
+git commit -m "test(launcher): Playwright happy path + add /workspace/launch to smoke"
+```
+
+---
+
+### Task 23: Playwright e2e — error paths
+
+**Files:**
+- Create: `tests/e2e/specs/launch-error-paths.spec.ts`
+
+- [ ] **Step 1: Write the spec**
+
+```ts
+import { test, expect } from "../fixtures/auth";
+
+test("strict-forward gate: cannot click tab 2 before tab 1 is valid", async ({ page }) => {
+ await page.goto("/workspace/launch");
+ await expect(page.locator("button[role='tab']").nth(1)).toBeDisabled();
+});
+
+test("preview failure shows error and disables launch", async ({ page }) => {
+ // Intercept the preview endpoint and return 502
+ await page.route("**/api/launcher/experiment-drafts/preview/", (route) =>
+ route.fulfill({ status: 502, body: JSON.stringify({ message: "airavata down" }) }),
+ );
+
+ await page.goto("/workspace/launch");
+ await page.getByPlaceholder(/Experiment name/i).fill("err-stub");
+ await page.locator("select[data-test='exp-project']").selectOption({ index: 1 });
+ await page.locator("[data-test='app-tile-namd']").click();
+ await page.locator("[data-test='iface-card-run']").click();
+ await page.locator("input[data-test='file-path-sim_dir']").fill("/x");
+ await page.locator("input[data-test='scalar-steps']").fill("1");
+ await page.locator("input[data-test='file-out-path-trajectory']").fill("/y");
+ await page.locator("button[role='tab']").nth(1).click();
+ await page.locator("select[data-test='cr']").selectOption("bridges-2");
+ await page.locator("select[data-test='partition']").selectOption("RM");
+ await page.locator("button[role='tab']").nth(2).click();
+
+ await expect(page.locator(".alert-danger")).toContainText("airavata down");
+ await expect(page.locator("button[data-test='launch']")).toBeDisabled();
+});
+
+test("project change clears runtime selections", async ({ page }) => {
+ await page.goto("/workspace/launch");
+ await page.getByPlaceholder(/Experiment name/i).fill("proj-change");
+ await page.locator("select[data-test='exp-project']").selectOption({ index: 1 });
+
+ // The confirm dialog is what we're testing — accept it when prompted
+ page.on("dialog", (d) => d.accept());
+
+ await page.locator("[data-test='app-tile-namd']").click();
+ await page.locator("[data-test='iface-card-run']").click();
+ await page.locator("input[data-test='file-path-sim_dir']").fill("/x");
+ await page.locator("input[data-test='scalar-steps']").fill("1");
+ await page.locator("input[data-test='file-out-path-trajectory']").fill("/y");
+ await page.locator("button[role='tab']").nth(1).click();
+ await page.locator("select[data-test='cr']").selectOption("bridges-2");
+
+ // Switch project — the dropdown only has projects from session data; pick the second.
+ // If only one project exists, this test no-ops and we just assert the disabled state below.
+ const opts = await page.locator("select[data-test='exp-project'] option").allTextContents();
+ if (opts.length > 2) {
+ await page.locator("select[data-test='exp-project']").selectOption({ index: 2 });
+ await expect(page.locator("select[data-test='cr']")).toHaveValue("");
+ }
+});
+```
+
+- [ ] **Step 2: Run it**
+
+Run: `npx playwright test tests/e2e/specs/launch-error-paths.spec.ts -c tooling/playwright.config.ts`
+Expected: PASS — 3 tests OK.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add tests/e2e/specs/launch-error-paths.spec.ts
+git commit -m "test(launcher): Playwright error-path coverage (gate, preview fail, project change)"
+```
+
+---
+
+## Phase 8 — Migration + cleanup
+
+### Task 24: Update repo-internal call sites of the old URLs
+
+**Files modified** (search results from spec — `git grep -n "/workspace/applications" -- '*.vue' '*.py' '*.ts'`):
+- `django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/WorkspaceDashboardContainer.vue`
+- `…/js/containers/DashboardContainer.vue`
+- `…/js/containers/ProjectOverviewContainer.vue`
+- `…/js/containers/ExperimentListContainer.vue`
+- `…/js/containers/ApplicationEditorContainer.vue` (only the launch link, lines 726, 995, 1037, 1051)
+- `django_airavata/apps/dataparsers/static/django_airavata_dataparsers/**/*.vue` (any `applications/...create_experiment` references)
+
+- [ ] **Step 1: Find every call site**
+
+Run: `git grep -n "/workspace/applications" -- '*.vue' '*.py' '*.ts' '*.js' '*.html' | tee /tmp/call-sites.txt`
+
+- [ ] **Step 2: Edit each one**
+
+For each line in `/tmp/call-sites.txt`, replace the URL. Two cases:
+- `/workspace/applications` (the discovery list) → `/workspace/launch`
+- `/workspace/applications/<id>/create_experiment` → `/workspace/launch` (drop the `id` — under the new model the launcher always starts from no app)
+- `/workspace/applications/<id>/` (app-editor route) — **DO NOT TOUCH** if this points at the admin app-definition editor (`ApplicationEditorContainer`); that surface stays for now (separate track).
+
+Specific edits to make (other repos may have additional call sites — fix any that show up):
+
+1. `WorkspaceDashboardContainer.vue` line 40: `href="/workspace/applications?action=launch"` → `href="/workspace/launch"`.
+2. `DashboardContainer.vue` line 10: `href="/workspace/applications/new"` — keep (app-creation flow stays). Lines 209/217: the per-tile launch link → change to `/workspace/launch`.
+3. `ProjectOverviewContainer.vue` line 238: `const newExperimentUrl = "/workspace/applications";` → `const newExperimentUrl = "/workspace/launch";`.
+4. `ExperimentListContainer.vue` lines 10/97: `href="/workspace/applications"` → `href="/workspace/launch"`.
+
+- [ ] **Step 3: Verify no untouched references**
+
+Run: `git grep -n "applications/.*/create_experiment\|workspace/applications\b\(?!/new\)" -- '*.vue' '*.ts' '*.js' '*.html' '*.py'`
+Expected: no matches that target launch.
+
+- [ ] **Step 4: Run frontend tests**
+
+Run: `cd django_airavata/apps/workspace && npm run test`
+Expected: all pre-existing tests still PASS (call-site changes are link-only, no logic).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A
+git commit -m "refactor(launcher): retarget repo-internal links from old apps URL to /workspace/launch"
+```
+
+---
+
+### Task 25: SDK deprecation shim + update callers
+
+**Files:**
+- Modify: `airavata-django-portal-sdk/airavata_django_portal_sdk/experiment_util.py` (or wherever the deep-link helper lives — verify)
+- Modify: any in-repo callers of the SDK helper
+
+This task may be a no-op depending on what the SDK currently exposes. The check:
+
+- [ ] **Step 1: Audit the SDK**
+
+Run: `grep -rn "create_experiment\|/workspace/applications" ../airavata-django-portal-sdk/ 2>/dev/null | head`
+- If matches: proceed.
+- If no matches: skip to step 4 with the commit message "no SDK changes required".
+
+- [ ] **Step 2: Add a deprecation shim**
+
+If the SDK has a helper like `build_experiment_url(app_module_id, …)`, add a deprecation warning and have it return `/workspace/launch`:
+
+```python
+import warnings
+
+
+def build_experiment_url(app_module_id: str | None = None, **kwargs) -> str:
+ if app_module_id is not None:
+ warnings.warn(
+ "build_experiment_url(app_module_id=...) is deprecated; the generic launcher does not deep-link to apps anymore.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ return "/workspace/launch"
+```
+
+- [ ] **Step 3: Update repo-internal callers** that rely on the old behavior — they should drop the `app_module_id` argument.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add -A
+git commit -m "refactor(launcher): SDK deep-link helper now returns /workspace/launch (deprecation shim)"
+```
+
+---
+
+### Task 26: Delete old experiment-editor surface
+
+**Files:**
+- Delete the files listed in the File Map → Deleted section.
+
+- [ ] **Step 1: Verify nothing imports them**
+
+Run:
+
+```bash
+git grep -nE "CreateExperimentContainer|EditExperimentContainer|ExperimentEditor\.vue|ComputationalResourceSchedulingEditor|QueueSettingsEditor|GroupResourceProfileSelector|entry-create-experiment|entry-edit-experiment" -- '*.vue' '*.ts' '*.js' '*.py' '*.html'
+```
+
+Expected: only matches inside the files being deleted themselves.
+
+- [ ] **Step 2: Delete the files**
+
+```bash
+rm django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/CreateExperimentContainer.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/EditExperimentContainer.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/ExperimentEditor.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/ComputationalResourceSchedulingEditor.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/QueueSettingsEditor.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/GroupResourceProfileSelector.vue \
+ django_airavata/apps/workspace/static/django_airavata_workspace/js/entry-create-experiment.js \
+ django_airavata/apps/workspace/static/django_airavata_workspace/js/entry-edit-experiment.js
+```
+
+- [ ] **Step 3: Remove the now-dead views in `workspace/views.py`**
+
+Delete the `create_experiment`, `edit_experiment`, and `applications` view functions. Their URL routes were already replaced with redirects in Task 5.
+
+- [ ] **Step 4: Remove old entries from any vite/build manifest**
+
+Edit `django_airavata/apps/workspace/vite.config.*` (or whichever build config drives bundling) — drop entries for `entry-create-experiment` and `entry-edit-experiment` if explicitly listed.
+
+- [ ] **Step 5: Run full frontend + backend tests**
+
+```bash
+cd django_airavata/apps/workspace && npm run test
+cd ../../../.. && python manage.py test
+```
+
+Expected: all PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add -A
+git commit -m "refactor(launcher): delete old experiment-editor surface"
+```
+
+---
+
+## Phase 9 — Final wiring
+
+### Task 27: Flag-on bake + final manual checklist
+
+**Files:**
+- Modify: `django_airavata/settings.py` (default flag on after verification)
+- Modify: PR description
+
+- [ ] **Step 1: Verify against a live dev stack**
+
+Bring up the stack:
+
+```bash
+cd ../../airavata && tilt up
+cd ../airavata-portals/airavata-django-portal && FEATURE_GENERIC_LAUNCHER=True tilt up
+```
+
+Manually walk through:
+- Open `/workspace/launch`
+- Pick an app, an interface, fill inputs (each I/O type at least once across runs)
+- Switch project mid-flow → verify confirm prompt + tab 2 reset
+- Switch app mid-flow → verify confirm prompt + downstream reset
+- Let session expire on tab 3 (sleep > Keycloak token TTL) → verify return-to-login + draft restore
+- Click launch → verify redirect to `/workspace/experiments/<id>`
+
+- [ ] **Step 2: When manual run passes, flip default**
+
+Edit `django_airavata/settings.py`:
+
+```python
+FEATURE_GENERIC_LAUNCHER = os.environ.get("FEATURE_GENERIC_LAUNCHER", "True").lower() == "true"
+```
+
+- [ ] **Step 3: Run full test suites once more**
+
+```bash
+cd django_airavata/apps/workspace && npm run test
+cd ../../../.. && python manage.py test
+npx playwright test -c tooling/playwright.config.ts
+```
+
+Expected: all PASS.
+
+- [ ] **Step 4: Open PR**
+
+```bash
+git push -u origin feat/generic-experiment-launcher
+gh pr create --base modernization --title "feat: generic experiment launcher" --body "$(cat <<'EOF'
+## Summary
+- Replaces per-app launch flow with `/workspace/launch` 3-tab wizard
+- New Django REST API surface under `/api/launcher/`
+- New Pinia store + Vue 3 SPA mounted by `entry-launch.ts`
+- Old per-app URLs 301 to `/workspace/launch`
+- Stub airavata client behind `LAUNCHER_CLIENT_STUB` until upstream RPCs land
+
+## Spec / plan
+- Spec: `airavata-django-portal/docs/superpowers/specs/2026-04-24-generic-experiment-launcher-design.md`
+- Plan: `airavata-django-portal/docs/superpowers/plans/2026-04-24-generic-experiment-launcher-plan.md`
+
+## Test plan
+- [ ] Unit tests pass (Vitest)
+- [ ] Integration test passes (Vitest)
+- [ ] Backend tests pass (Django TestCase)
+- [ ] Playwright happy path passes
+- [ ] Playwright error paths pass
+- [ ] Smoke covers `/workspace/launch`
+- [ ] Manual: each I/O type exercised once in a live run
+- [ ] Manual: project / app / interface change confirm dialogs fire
+- [ ] Manual: session expiry mid-flow restores draft
+
+## Out of scope (separate tracks)
+- Airavata Java server: new app-model RPCs + `GenerateExperimentSubmissionScript` dry-run
+- App definition UI under the new model
+- Experiment edit flow
+EOF
+)"
+```
+
+- [ ] **Step 5: Final commit (post-flag-flip)**
+
+If the flag flip required an actual settings change:
+
+```bash
+git add django_airavata/settings.py
+git commit -m "chore(launcher): default FEATURE_GENERIC_LAUNCHER to on"
+```
+
+---
+
+### Task 28: Swap the stub for the real client
+
+**Trigger:** When the airavata Java server merges (1) the new application-model RPCs and (2) `GenerateExperimentSubmissionScript`. Until then this task does not run.
+
+**Files:**
+- Modify: `django_airavata/apps/api/launcher_client.py`
+- Modify: `django_airavata/settings.py`
+- Modify: `django_airavata/apps/api/tests/test_launcher_client.py`
+
+- [ ] **Step 1: Implement the real client**
+
+Replace `_RealClient` in `launcher_client.py` with calls into the Airavata Thrift API client. The portal already has a Thrift wrapper used by the existing experiment views; reuse it. Each method maps directly to the new gRPC/Thrift call. The data shapes returned must match the stub's exactly (so the rest of the pipeline doesn't need to change).
+
+- [ ] **Step 2: Add tests for the real client (mocked at the Thrift boundary)**
+
+Append to `test_launcher_client.py`:
+
+```python
+@override_settings(LAUNCHER_CLIENT_STUB=False)
+def test_real_client_calls_thrift_listApplications(self):
+ with patch("django_airavata.apps.api.launcher_client.airavata_client") as ac:
+ ac.list_applications.return_value = []
+ client = launcher_client.get_client(user_token="t")
+ client.list_applications(category=None, search=None)
+ ac.list_applications.assert_called_once()
+```
+
+(Replace `airavata_client` with the actual import name — TBD when implementing.)
+
+- [ ] **Step 3: Flip default to real**
+
+```python
+LAUNCHER_CLIENT_STUB = os.environ.get("LAUNCHER_CLIENT_STUB", "False").lower() == "true"
+```
+
+- [ ] **Step 4: Run full suites**
+
+```bash
+python manage.py test
+cd django_airavata/apps/workspace && npm run test
+npx playwright test -c tooling/playwright.config.ts
+```
+
+Expected: all PASS against a live airavata stack.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A
+git commit -m "feat(launcher): swap stub for real airavata client (LAUNCHER_CLIENT_STUB default off)"
+```
+
+---
+
+## Self-review checklist
+
+Run before marking the plan ready:
+
+- [ ] Every spec section maps to at least one task (URL/scope → Tasks 5+24, frontend arch → Tasks 6-9, API surface → Tasks 1-4, tab specs → Tasks 10-18, edge cases → Tasks 19-20, testing → Tasks 21-23, migration → Tasks 24-26).
+- [ ] No "TBD" / "TODO" placeholders in any task body.
+- [ ] Type names match across tasks: `ExperimentDraft`, `RuntimeChoice`, `StorageRef`, `Application`, `InterfaceDescriptor`, `IODescriptor`, `ResourceProfile`.
+- [ ] Function names are stable: `pickApp`, `pickInterface`, `setMeta`, `setInput`, `setOutput`, `setRuntime`, `hydrate`, `reset`, `tab1Valid`, `tab2Valid`, `draftHash`.
+- [ ] All commit messages use the same prefix style (`feat(launcher):`, `test(launcher):`, `refactor(launcher):`, `chore(launcher):`, `docs(feat/generic-launcher):`).
(airavata-portals) 13/34: fix(launcher): add setStorages/setProfile + honor optional outputs + reset previewLoading
Posted by ya...@apache.org.
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 5a3db3915a4e380fd9a8f8543b52c3db2555b179
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 22:16:40 2026 -0400
fix(launcher): add setStorages/setProfile + honor optional outputs + reset previewLoading
---
.../tests/unit/stores/launch.spec.ts | 38 ++++++++++++++++++++++
.../static/common/js/stores/launch.ts | 17 +++++++++-
2 files changed, 54 insertions(+), 1 deletion(-)
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
index 9046aacea..b064e646f 100644
--- 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
@@ -64,3 +64,41 @@ describe("useLaunchStore", () => {
expect(s.draftHash).toBe(h2);
});
});
+
+describe("setters and reset", () => {
+ beforeEach(() => setActivePinia(createPinia()));
+
+ it("setStorages and setProfile populate state", () => {
+ const s = useLaunchStore();
+ s.setStorages([{ storage_id: "x", name: "X", is_primary: true }]);
+ s.setProfile({ project_id: "p1", allocation_id: "A1", compute_resources: [] });
+ expect(s.storages).toHaveLength(1);
+ expect(s.profile?.allocation_id).toBe("A1");
+ });
+
+ it("reset clears all state including previewLoading", () => {
+ const s = useLaunchStore();
+ s.setMeta({ name: "x", project_id: "p1", description: "" });
+ s.previewLoading = true;
+ s.reset();
+ expect(s.draft.name).toBe("");
+ expect(s.draft.project_id).toBeNull();
+ expect(s.previewLoading).toBe(false);
+ });
+
+ it("optional outputs do not block tab1 validity", () => {
+ const s = useLaunchStore();
+ const iface = {
+ name: "run",
+ inputs: [{ name: "x", type: "int" as const, required: true }],
+ outputs: [{ name: "log", type: "file" as const, required: false }],
+ };
+ s.setMeta({ name: "x", project_id: "p1", description: "" });
+ s.pickApp({ app_id: "a", name: "A", category: "C",
+ content: { kind: "github", url: "g" }, interfaces: [iface] });
+ s.pickInterface("run");
+ s.setInput("x", 1);
+ // No output set, but it's not required
+ expect(s.tab1Valid).toBe(true);
+ });
+});
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
index e249d41ab..5e34c91b8 100644
--- a/airavata-django-portal/django_airavata/static/common/js/stores/launch.ts
+++ b/airavata-django-portal/django_airavata/static/common/js/stores/launch.ts
@@ -109,7 +109,11 @@ export const useLaunchStore = defineStore("launch", () => {
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;
+ if (!v) {
+ if (io.required) return false;
+ continue;
+ }
+ if (!v.path || !v.storage_id) return false;
}
return true;
});
@@ -130,12 +134,21 @@ export const useLaunchStore = defineStore("launch", () => {
return (h >>> 0).toString(16);
});
+ function setStorages(s: UserStorage[]) {
+ storages.value = s;
+ }
+
+ function setProfile(p: ResourceProfile | null) {
+ profile.value = p;
+ }
+
function reset() {
Object.assign(draft, makeDraft());
pickedApp.value = null;
profile.value = null;
preview.value = null;
previewError.value = null;
+ previewLoading.value = false;
lastPreviewedHash.value = null;
}
@@ -158,6 +171,8 @@ export const useLaunchStore = defineStore("launch", () => {
setInput,
setOutput,
setRuntime,
+ setStorages,
+ setProfile,
reset,
};
});
(airavata-portals) 25/34: feat(launcher): Tab3ReviewLaunch preview fetch + launch flow
Posted by ya...@apache.org.
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 dc6303902c043f45021188fd05b05eebca3607d8
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 22:58:38 2026 -0400
feat(launcher): Tab3ReviewLaunch preview fetch + launch flow
---
.../js/components/launch/Tab3ReviewLaunch.vue | 76 +++++++++++++++++++++-
.../components/launch/Tab3ReviewLaunch.spec.ts | 59 +++++++++++++++++
2 files changed, 133 insertions(+), 2 deletions(-)
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab3ReviewLaunch.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab3ReviewLaunch.vue
index 25a445eed..9fe0c97c7 100644
--- a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab3ReviewLaunch.vue
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab3ReviewLaunch.vue
@@ -1,2 +1,74 @@
-<template><div /></template>
-<script setup lang="ts"></script>
+<template>
+ <div>
+ <div v-if="loading" class="text-muted">Generating preview…</div>
+ <div v-else-if="error" class="alert alert-danger">
+ <div>{{ error }}</div>
+ <button class="btn btn-sm btn-outline-light mt-2" @click="refresh">Retry</button>
+ </div>
+ <div v-else-if="store.preview">
+ <ul v-if="store.preview.warnings.length" class="alert alert-warning small" data-test="warnings">
+ <li v-for="w in store.preview.warnings" :key="w">{{ w }}</li>
+ </ul>
+ <InvocationCommand :command="store.preview.invocation_command" />
+ <ScriptPreview :script="store.preview.script_contents" />
+ <div v-if="launchError" class="alert alert-danger mt-2">
+ {{ launchError }}
+ <button class="btn btn-sm btn-outline-light ms-2" @click="onLaunch">Try again</button>
+ </div>
+ </div>
+ <div class="d-flex justify-content-end mt-3">
+ <button
+ class="btn btn-primary"
+ data-test="launch"
+ :disabled="!store.preview || loading"
+ @click="onLaunch"
+ >
+ Launch experiment
+ </button>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref } from "vue";
+import { useLaunchStore } from "django-airavata-common-ui/js/stores/launch";
+import { launcherService } from "django-airavata-common-ui/js/services/launcherService";
+import InvocationCommand from "./InvocationCommand.vue";
+import ScriptPreview from "./ScriptPreview.vue";
+
+const store = useLaunchStore();
+const loading = ref(false);
+const error = ref<string | null>(null);
+const launchError = ref<string | null>(null);
+let abort: AbortController | null = null;
+
+async function refresh() {
+ if (store.preview && store.lastPreviewedHash === store.draftHash) return;
+ loading.value = true;
+ error.value = null;
+ abort?.abort();
+ abort = new AbortController();
+ try {
+ const r = await launcherService.generatePreview(store.draft, abort.signal);
+ store.preview = r;
+ store.lastPreviewedHash = store.draftHash;
+ } catch (e) {
+ error.value = (e as Error).message;
+ store.preview = null;
+ } finally {
+ loading.value = false;
+ }
+}
+
+async function onLaunch() {
+ launchError.value = null;
+ try {
+ const { experiment_id } = await launcherService.launchExperiment(store.draft);
+ window.location.href = `/workspace/experiments/${experiment_id}`;
+ } catch (e) {
+ launchError.value = (e as Error).message;
+ }
+}
+
+onMounted(() => { void refresh(); });
+</script>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/Tab3ReviewLaunch.spec.ts b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/Tab3ReviewLaunch.spec.ts
new file mode 100644
index 000000000..cbd6fae19
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/Tab3ReviewLaunch.spec.ts
@@ -0,0 +1,59 @@
+import { mount, flushPromises } from "@vue/test-utils";
+import { createPinia, setActivePinia } from "pinia";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import Tab3ReviewLaunch from "../../../../js/components/launch/Tab3ReviewLaunch.vue";
+import { useLaunchStore } from "django-airavata-common-ui/js/stores/launch";
+
+vi.mock("django-airavata-common-ui/js/services/launcherService", () => ({
+ launcherService: {
+ generatePreview: vi.fn(),
+ launchExperiment: vi.fn(),
+ },
+}));
+
+import { launcherService } from "django-airavata-common-ui/js/services/launcherService";
+
+const NAV = vi.fn();
+Object.defineProperty(window, "location", { value: { href: "/", assign: NAV }, writable: true });
+
+describe("Tab3ReviewLaunch", () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ vi.mocked(launcherService.generatePreview).mockResolvedValue({
+ invocation_command: "sbatch /tmp/run.sh",
+ script_contents: "#!/bin/bash\necho hi\n",
+ warnings: ["check walltime"],
+ });
+ vi.mocked(launcherService.launchExperiment).mockResolvedValue({ experiment_id: "exp-42" });
+ NAV.mockClear();
+ window.location.href = "/";
+ });
+
+ it("renders the preview after fetch", async () => {
+ const w = mount(Tab3ReviewLaunch);
+ await flushPromises();
+ expect(w.text()).toContain("sbatch /tmp/run.sh");
+ expect(w.text()).toContain("echo hi");
+ });
+
+ it("renders warnings as a list", async () => {
+ const w = mount(Tab3ReviewLaunch);
+ await flushPromises();
+ expect(w.find("[data-test='warnings']").text()).toContain("check walltime");
+ });
+
+ it("disables launch when preview failed", async () => {
+ vi.mocked(launcherService.generatePreview).mockRejectedValueOnce(new Error("nope"));
+ const w = mount(Tab3ReviewLaunch);
+ await flushPromises();
+ expect(w.find("button[data-test='launch']").attributes("disabled")).toBeDefined();
+ });
+
+ it("redirects on successful launch", async () => {
+ const w = mount(Tab3ReviewLaunch);
+ await flushPromises();
+ await w.find("button[data-test='launch']").trigger("click");
+ await flushPromises();
+ expect(window.location.href).toBe("/workspace/experiments/exp-42");
+ });
+});
(airavata-portals) 12/34: feat(launcher): Pinia store + types + service wrapper
Posted by ya...@apache.org.
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,
+ };
+});
(airavata-portals) 05/34: feat(launcher): DRF serializers + JSON contract schemas
Posted by ya...@apache.org.
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 d0c3e210cb90400736a432da72ca086720945d86
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 21:21:56 2026 -0400
feat(launcher): DRF serializers + JSON contract schemas
---
.../apps/api/launcher_serializers.py | 48 +++++++++++++++++
.../apps/api/tests/test_launcher_serializers.py | 62 ++++++++++++++++++++++
.../tests/contracts/experiment-draft.schema.json | 51 ++++++++++++++++++
.../tests/contracts/preview-response.schema.json | 11 ++++
4 files changed, 172 insertions(+)
diff --git a/airavata-django-portal/django_airavata/apps/api/launcher_serializers.py b/airavata-django-portal/django_airavata/apps/api/launcher_serializers.py
new file mode 100644
index 000000000..af186fc4d
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/api/launcher_serializers.py
@@ -0,0 +1,48 @@
+import re
+
+from rest_framework import serializers
+
+
+WALLTIME_RE = re.compile(r"^\d{1,3}:[0-5]\d:[0-5]\d$")
+
+
+class RuntimeSerializer(serializers.Serializer):
+ compute_resource_id = serializers.CharField()
+ partition = serializers.CharField()
+ walltime = serializers.CharField()
+ nodes = serializers.IntegerField(min_value=1)
+ cpus_per_node = serializers.IntegerField(min_value=1)
+
+ def validate_walltime(self, value: str) -> str:
+ if not WALLTIME_RE.match(value):
+ raise serializers.ValidationError("walltime must match HH:MM:SS or HHH:MM:SS")
+ return value
+
+
+class StorageRefSerializer(serializers.Serializer):
+ storage_id = serializers.CharField()
+ path = serializers.CharField()
+
+
+class ExperimentDraftSerializer(serializers.Serializer):
+ name = serializers.CharField(min_length=1, max_length=256)
+ project_id = serializers.CharField()
+ description = serializers.CharField(required=False, allow_blank=True, default="")
+ app_id = serializers.CharField()
+ interface_name = serializers.CharField()
+ inputs = serializers.DictField(child=serializers.JSONField())
+ outputs = serializers.DictField(child=StorageRefSerializer())
+ runtime = RuntimeSerializer()
+
+ def validate_inputs(self, value):
+ # Each input is either a scalar (str/int/float/bool) or a {storage_id, path} object.
+ for name, v in value.items():
+ if isinstance(v, dict):
+ StorageRefSerializer(data=v).is_valid(raise_exception=True)
+ return value
+
+
+class PreviewResponseSerializer(serializers.Serializer):
+ invocation_command = serializers.CharField()
+ script_contents = serializers.CharField()
+ warnings = serializers.ListField(child=serializers.CharField(), default=list)
diff --git a/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_serializers.py b/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_serializers.py
new file mode 100644
index 000000000..b1101d277
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_serializers.py
@@ -0,0 +1,62 @@
+import json
+from pathlib import Path
+from unittest import TestCase
+
+import jsonschema
+
+from django_airavata.apps.api import launcher_serializers
+
+
+CONTRACTS = Path(__file__).resolve().parents[4] / "tests" / "contracts"
+
+
+class ExperimentDraftSerializerTest(TestCase):
+ def _valid_draft(self) -> dict:
+ return {
+ "name": "test-run",
+ "project_id": "proj-1",
+ "app_id": "namd",
+ "interface_name": "run",
+ "inputs": {
+ "sim_dir": {"storage_id": "my-home", "path": "/home/x/sim"},
+ "steps": 1000,
+ },
+ "outputs": {"trajectory": {"storage_id": "my-home", "path": "/home/x/out.dcd"}},
+ "runtime": {
+ "compute_resource_id": "bridges-2",
+ "partition": "RM",
+ "walltime": "01:00:00",
+ "nodes": 1,
+ "cpus_per_node": 64,
+ },
+ }
+
+ def test_valid_draft_passes_schema(self):
+ schema = json.loads((CONTRACTS / "experiment-draft.schema.json").read_text())
+ jsonschema.validate(self._valid_draft(), schema)
+
+ def test_serializer_accepts_valid_draft(self):
+ serializer = launcher_serializers.ExperimentDraftSerializer(data=self._valid_draft())
+ self.assertTrue(serializer.is_valid(), serializer.errors)
+
+ def test_serializer_rejects_missing_runtime(self):
+ draft = self._valid_draft()
+ del draft["runtime"]
+ serializer = launcher_serializers.ExperimentDraftSerializer(data=draft)
+ self.assertFalse(serializer.is_valid())
+ self.assertIn("runtime", serializer.errors)
+
+ def test_serializer_rejects_bad_walltime(self):
+ draft = self._valid_draft()
+ draft["runtime"]["walltime"] = "not-a-time"
+ serializer = launcher_serializers.ExperimentDraftSerializer(data=draft)
+ self.assertFalse(serializer.is_valid())
+
+
+class PreviewResponseSchemaTest(TestCase):
+ def test_valid_response(self):
+ schema = json.loads((CONTRACTS / "preview-response.schema.json").read_text())
+ jsonschema.validate(
+ {"invocation_command": "bash run.sh", "script_contents": "#!/bin/bash\n", "warnings": []},
+ schema,
+ )
diff --git a/airavata-django-portal/tests/contracts/experiment-draft.schema.json b/airavata-django-portal/tests/contracts/experiment-draft.schema.json
new file mode 100644
index 000000000..0007626c3
--- /dev/null
+++ b/airavata-django-portal/tests/contracts/experiment-draft.schema.json
@@ -0,0 +1,51 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "ExperimentDraft",
+ "type": "object",
+ "required": ["name", "project_id", "app_id", "interface_name", "inputs", "outputs", "runtime"],
+ "properties": {
+ "name": {"type": "string", "minLength": 1, "maxLength": 256},
+ "project_id": {"type": "string"},
+ "description": {"type": "string"},
+ "app_id": {"type": "string"},
+ "interface_name": {"type": "string"},
+ "inputs": {
+ "type": "object",
+ "additionalProperties": {
+ "oneOf": [
+ {"type": ["string", "number", "boolean"]},
+ {
+ "type": "object",
+ "required": ["storage_id", "path"],
+ "properties": {
+ "storage_id": {"type": "string"},
+ "path": {"type": "string"}
+ }
+ }
+ ]
+ }
+ },
+ "outputs": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "object",
+ "required": ["storage_id", "path"],
+ "properties": {
+ "storage_id": {"type": "string"},
+ "path": {"type": "string"}
+ }
+ }
+ },
+ "runtime": {
+ "type": "object",
+ "required": ["compute_resource_id", "partition", "walltime", "nodes", "cpus_per_node"],
+ "properties": {
+ "compute_resource_id": {"type": "string"},
+ "partition": {"type": "string"},
+ "walltime": {"type": "string", "pattern": "^\\d{1,3}:[0-5]\\d:[0-5]\\d$"},
+ "nodes": {"type": "integer", "minimum": 1},
+ "cpus_per_node": {"type": "integer", "minimum": 1}
+ }
+ }
+ }
+}
diff --git a/airavata-django-portal/tests/contracts/preview-response.schema.json b/airavata-django-portal/tests/contracts/preview-response.schema.json
new file mode 100644
index 000000000..cb734f59c
--- /dev/null
+++ b/airavata-django-portal/tests/contracts/preview-response.schema.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "PreviewResponse",
+ "type": "object",
+ "required": ["invocation_command", "script_contents", "warnings"],
+ "properties": {
+ "invocation_command": {"type": "string", "minLength": 1},
+ "script_contents": {"type": "string", "minLength": 1},
+ "warnings": {"type": "array", "items": {"type": "string"}}
+ }
+}
(airavata-portals) 20/34: feat(launcher): InputList + OutputList + FileOutputRow
Posted by ya...@apache.org.
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 fad7719f0e83693f706d9a2b89f90f82652d149c
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 22:43:40 2026 -0400
feat(launcher): InputList + OutputList + FileOutputRow
---
.../js/components/launch/FileOutputRow.vue | 43 ++++++++++++++++++++
.../js/components/launch/InputList.vue | 33 ++++++++++++++++
.../js/components/launch/OutputList.vue | 25 ++++++++++++
.../tests/unit/components/launch/InputList.spec.ts | 46 ++++++++++++++++++++++
4 files changed, 147 insertions(+)
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/FileOutputRow.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/FileOutputRow.vue
new file mode 100644
index 000000000..338add1f0
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/FileOutputRow.vue
@@ -0,0 +1,43 @@
+<template>
+ <div class="d-flex align-items-center gap-2 border rounded p-2 mb-1">
+ <div class="flex-shrink-0" style="width: 140px;">
+ <code>{{ descriptor.name }}</code>
+ <span class="text-muted small ms-1">{{ descriptor.type }}</span>
+ </div>
+ <select
+ class="form-select form-select-sm"
+ style="width: 160px;"
+ :value="modelValue?.storage_id ?? ''"
+ @change="onStorage(($event.target as HTMLSelectElement).value)"
+ >
+ <option value="" disabled>Storage…</option>
+ <option v-for="s in storages" :key="s.storage_id" :value="s.storage_id">{{ s.name }}</option>
+ </select>
+ <input
+ class="form-control form-control-sm flex-grow-1"
+ :data-test="`file-out-path-${descriptor.name}`"
+ :value="modelValue?.path ?? ''"
+ placeholder="/path/to/output"
+ @input="onPath(($event.target as HTMLInputElement).value)"
+ />
+ <span class="badge bg-info text-dark">stage-out</span>
+ </div>
+</template>
+
+<script setup lang="ts">
+import type { IODescriptor, StorageRef, UserStorage } from "django-airavata-common-ui/js/stores/launch-types";
+
+const props = defineProps<{
+ descriptor: IODescriptor;
+ modelValue: StorageRef | null;
+ storages: UserStorage[];
+}>();
+const emit = defineEmits<{ "update:modelValue": [v: StorageRef] }>();
+
+function onStorage(id: string) {
+ emit("update:modelValue", { storage_id: id, path: props.modelValue?.path ?? "" });
+}
+function onPath(p: string) {
+ emit("update:modelValue", { storage_id: props.modelValue?.storage_id ?? "", path: p });
+}
+</script>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/InputList.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/InputList.vue
new file mode 100644
index 000000000..8768a604d
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/InputList.vue
@@ -0,0 +1,33 @@
+<template>
+ <div v-if="store.pickedInterface">
+ <div
+ v-for="io in store.pickedInterface.inputs"
+ :key="io.name"
+ data-test="input-row"
+ >
+ <FileInputRow
+ v-if="io.type === 'file' || io.type === 'dir'"
+ :descriptor="io"
+ :model-value="(store.draft.inputs[io.name] as { storage_id: string; path: string } | null) ?? null"
+ :storages="storages"
+ @update:model-value="store.setInput(io.name, $event)"
+ />
+ <ScalarInputRow
+ v-else
+ :descriptor="io"
+ :model-value="(store.draft.inputs[io.name] as string | number | boolean | null) ?? null"
+ @update:model-value="store.setInput(io.name, $event)"
+ />
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import type { UserStorage } from "django-airavata-common-ui/js/stores/launch-types";
+import { useLaunchStore } from "django-airavata-common-ui/js/stores/launch";
+import FileInputRow from "./FileInputRow.vue";
+import ScalarInputRow from "./ScalarInputRow.vue";
+
+defineProps<{ storages: UserStorage[] }>();
+const store = useLaunchStore();
+</script>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/OutputList.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/OutputList.vue
new file mode 100644
index 000000000..a74e31d28
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/OutputList.vue
@@ -0,0 +1,25 @@
+<template>
+ <div v-if="store.pickedInterface">
+ <FileOutputRow
+ v-for="io in fileOutputs"
+ :key="io.name"
+ :descriptor="io"
+ :model-value="store.draft.outputs[io.name] ?? null"
+ :storages="storages"
+ @update:model-value="store.setOutput(io.name, $event)"
+ />
+ </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from "vue";
+import type { UserStorage } from "django-airavata-common-ui/js/stores/launch-types";
+import { useLaunchStore } from "django-airavata-common-ui/js/stores/launch";
+import FileOutputRow from "./FileOutputRow.vue";
+
+defineProps<{ storages: UserStorage[] }>();
+const store = useLaunchStore();
+const fileOutputs = computed(() =>
+ (store.pickedInterface?.outputs ?? []).filter((o) => o.type === "file" || o.type === "dir"),
+);
+</script>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/InputList.spec.ts b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/InputList.spec.ts
new file mode 100644
index 000000000..2354fe1be
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/InputList.spec.ts
@@ -0,0 +1,46 @@
+import { mount } from "@vue/test-utils";
+import { createPinia, setActivePinia } from "pinia";
+import { beforeEach, describe, expect, it } from "vitest";
+import InputList from "../../../../js/components/launch/InputList.vue";
+import { useLaunchStore } from "django-airavata-common-ui/js/stores/launch";
+
+const APP = {
+ app_id: "namd", name: "NAMD", category: "MD",
+ content: { kind: "github" as const, url: "g" },
+ interfaces: [{
+ name: "run",
+ inputs: [
+ { name: "sim_dir", type: "dir" as const, required: true },
+ { name: "steps", type: "int" as const, required: true },
+ ],
+ outputs: [],
+ }],
+};
+
+const STORAGES = [{ storage_id: "my-home", name: "My Home", is_primary: true }];
+
+describe("InputList", () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ const s = useLaunchStore();
+ s.pickApp(APP);
+ s.pickInterface("run");
+ });
+
+ it("renders one row per declared input", () => {
+ const w = mount(InputList, { props: { storages: STORAGES } });
+ expect(w.findAll("[data-test='input-row']")).toHaveLength(2);
+ });
+
+ it("scalar input writes through to the store", async () => {
+ const w = mount(InputList, { props: { storages: STORAGES } });
+ await w.find("input[data-test='scalar-steps']").setValue("5000");
+ expect(useLaunchStore().draft.inputs.steps).toBe(5000);
+ });
+
+ it("file input writes through to the store", async () => {
+ const w = mount(InputList, { props: { storages: STORAGES } });
+ await w.find("input[data-test='file-path-sim_dir']").setValue("/data");
+ expect(useLaunchStore().draft.inputs.sim_dir).toEqual({ storage_id: "", path: "/data" });
+ });
+});
(airavata-portals) 18/34: feat(launcher): InterfacePicker verb cards
Posted by ya...@apache.org.
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 91509ab9cf18d22be88ef0115c9d1ab96ac14c1a
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 22:39:31 2026 -0400
feat(launcher): InterfacePicker verb cards
---
.../js/components/launch/InterfacePicker.vue | 34 +++++++++++++++++++
.../unit/components/launch/InterfacePicker.spec.ts | 38 ++++++++++++++++++++++
2 files changed, 72 insertions(+)
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/InterfacePicker.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/InterfacePicker.vue
new file mode 100644
index 000000000..5216b013a
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/InterfacePicker.vue
@@ -0,0 +1,34 @@
+<template>
+ <div v-if="store.pickedApp" class="row g-2">
+ <div
+ v-for="iface in store.pickedApp.interfaces"
+ :key="iface.name"
+ class="col-6 col-md-3"
+ data-test="iface-card"
+ >
+ <button
+ type="button"
+ class="card w-100 text-start p-2"
+ :class="{ 'border-primary': store.draft.interface_name === iface.name }"
+ :data-test="`iface-card-${iface.name}`"
+ @click="store.pickInterface(iface.name)"
+ >
+ <code class="d-block fw-bold">{{ iface.name }}</code>
+ <small class="text-muted" :data-test="`iface-sig-${iface.name}`">
+ ({{ formatList(iface.inputs) }}) → {{ formatList(iface.outputs) || "void" }}
+ </small>
+ </button>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import type { IODescriptor } from "django-airavata-common-ui/js/stores/launch-types";
+import { useLaunchStore } from "django-airavata-common-ui/js/stores/launch";
+
+const store = useLaunchStore();
+
+function formatList(io: IODescriptor[]): string {
+ return io.map((x) => `${x.name}: ${x.type}`).join(", ");
+}
+</script>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/InterfacePicker.spec.ts b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/InterfacePicker.spec.ts
new file mode 100644
index 000000000..201db1b9a
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/InterfacePicker.spec.ts
@@ -0,0 +1,38 @@
+import { mount } from "@vue/test-utils";
+import { createPinia, setActivePinia } from "pinia";
+import { beforeEach, describe, expect, it } from "vitest";
+import InterfacePicker from "../../../../js/components/launch/InterfacePicker.vue";
+import { useLaunchStore } from "django-airavata-common-ui/js/stores/launch";
+
+const APP = {
+ app_id: "namd", name: "NAMD", category: "MD",
+ content: { kind: "github" as const, url: "g" },
+ interfaces: [
+ { name: "compile", inputs: [], outputs: [] },
+ { name: "run", inputs: [{ name: "x", type: "int" as const, required: true }], outputs: [] },
+ ],
+};
+
+describe("InterfacePicker", () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ useLaunchStore().pickApp(APP);
+ });
+
+ it("renders one card per interface", () => {
+ const w = mount(InterfacePicker);
+ expect(w.findAll("[data-test='iface-card']")).toHaveLength(2);
+ });
+
+ it("clicking a card sets interface_name in the store", async () => {
+ const w = mount(InterfacePicker);
+ await w.find("[data-test='iface-card-run']").trigger("click");
+ expect(useLaunchStore().draft.interface_name).toBe("run");
+ });
+
+ it("renders an input/output signature line per card", () => {
+ const w = mount(InterfacePicker);
+ const sig = w.find("[data-test='iface-sig-run']").text();
+ expect(sig).toContain("x: int");
+ });
+});
(airavata-portals) 29/34: test(launcher): Playwright happy path + add /workspace/launch to smoke
Posted by ya...@apache.org.
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 618e40d28e1975e246d19bcc11258797d0db168d
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 23:14:27 2026 -0400
test(launcher): Playwright happy path + add /workspace/launch to smoke
---
.../tests/e2e/specs/launch-happy.spec.ts | 38 ++++++++++++++++++++++
.../tests/e2e/specs/smoke.spec.ts | 1 +
2 files changed, 39 insertions(+)
diff --git a/airavata-django-portal/tests/e2e/specs/launch-happy.spec.ts b/airavata-django-portal/tests/e2e/specs/launch-happy.spec.ts
new file mode 100644
index 000000000..f3fbc8a6a
--- /dev/null
+++ b/airavata-django-portal/tests/e2e/specs/launch-happy.spec.ts
@@ -0,0 +1,38 @@
+import { test, expect } from "../fixtures/auth";
+
+test("launch a stub experiment via /workspace/launch", async ({ page }) => {
+ await page.goto("/workspace/launch");
+ await expect(page.getByPlaceholder(/Experiment name/i)).toBeVisible();
+
+ await page.getByPlaceholder(/Experiment name/i).fill("e2e-stub");
+
+ // Project picker — pick whatever's available (index 1 = first real option)
+ const projectSelect = page.locator("select[data-test='exp-project']");
+ await projectSelect.selectOption({ index: 1 });
+
+ // App + interface (stub returns NAMD)
+ await page.locator("[data-test='app-tile-namd']").click();
+ await page.locator("[data-test='iface-card-run']").click();
+
+ // Inputs
+ // sim_dir is a dir; pick a storage and a path
+ const storageSelects = page.locator("select").filter({ hasText: /My Home|Bridges/ });
+ await storageSelects.first().selectOption({ index: 1 });
+ await page.locator("input[data-test='file-path-sim_dir']").fill("/home/x/sim");
+ await page.locator("input[data-test='scalar-steps']").fill("100");
+
+ // Output: trajectory
+ await page.locator("input[data-test='file-out-path-trajectory']").fill("/home/x/out.dcd");
+
+ // Tab 2
+ await page.locator("button[role='tab']").nth(1).click();
+ await page.locator("select[data-test='cr']").selectOption("bridges-2");
+ await page.locator("select[data-test='partition']").selectOption("RM");
+
+ // Tab 3
+ await page.locator("button[role='tab']").nth(2).click();
+ await expect(page.locator("pre code")).toContainText("#!/bin/bash");
+ await page.locator("button[data-test='launch']").click();
+
+ await page.waitForURL(/\/workspace\/experiments\//);
+});
diff --git a/airavata-django-portal/tests/e2e/specs/smoke.spec.ts b/airavata-django-portal/tests/e2e/specs/smoke.spec.ts
index e6a0de4da..d2f2b07aa 100644
--- a/airavata-django-portal/tests/e2e/specs/smoke.spec.ts
+++ b/airavata-django-portal/tests/e2e/specs/smoke.spec.ts
@@ -2,6 +2,7 @@ import { test, expect } from "../fixtures/auth";
const AUTHENTICATED_PAGES = [
"/workspace/home",
+ "/workspace/launch",
"/workspace/projects",
"/workspace/applications",
"/workspace/datasets",
(airavata-portals) 01/34: docs(feat/generic-launcher): generic experiment launcher design spec
Posted by ya...@apache.org.
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 42950c6477bde6db3c5a389615aab094a568a5bd
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 16:17:17 2026 -0400
docs(feat/generic-launcher): generic experiment launcher design spec
---
...026-04-24-generic-experiment-launcher-design.md | 181 +++++++++++++++++++++
1 file changed, 181 insertions(+)
diff --git a/airavata-django-portal/docs/superpowers/specs/2026-04-24-generic-experiment-launcher-design.md b/airavata-django-portal/docs/superpowers/specs/2026-04-24-generic-experiment-launcher-design.md
new file mode 100644
index 000000000..f34125276
--- /dev/null
+++ b/airavata-django-portal/docs/superpowers/specs/2026-04-24-generic-experiment-launcher-design.md
@@ -0,0 +1,181 @@
+# Generic Experiment Launcher Design Spec
+
+**Date:** 2026-04-24
+**Branch:** `feat/generic-experiment-launcher` (off `modernization`)
+**Scope:** Replace the per-application experiment launch flow in `airavata-django-portal` with a single generic launcher page.
+
+---
+
+## Goal
+
+One URL that handles the whole launch flow: pick application → pick interface → give inputs (scalar + file I/O with storage pickers) → choose runtime → preview the job submission script → launch. Three-tab single-page wizard with strict forward gating.
+
+## Assumed Upstream (tracked separately)
+
+This spec assumes two backend changes have landed in the Airavata Java server before the portal work merges:
+
+1. **New application model** — apps consist of (a) content reference (tarball or GitHub URL) and (b) user-defined interfaces with typed input/output signatures. No per-app queue defaults, walltime suggestions, or compute-resource deployment pins.
+2. **Dry-run RPC** — `GenerateExperimentSubmissionScript(draft) → { invocation_command, script_contents, warnings[] }`. Runs the compute-service → agent-service → research-framework chain in dry-run mode; same code path as launch but stops before the scheduler call.
+
+Script generation is layered: compute-service renders the Groovy-based base with scheduler directives (e.g., `#SBATCH`) and module loads; agent-service appends the agent sidecar startup; research-framework appends stage-in, command invocation, and stage-out. On SLURM resources the script runs via `sbatch script.sh`; elsewhere via `bash script.sh`, and `#SBATCH` directives are omitted.
+
+The portal cannot ship without both upstream pieces. Jira/airavata ticket references belong in the PR header when opened.
+
+## Scope & URL Structure
+
+- **New URL:** `/workspace/launch` — single Django view rendering a Vue mount point.
+- **Removed:** `/workspace/applications/<app_module_id>/create_experiment` (old per-app launch) and `/workspace/applications` (app tile grid). App discovery now lives inside Tab 1.
+- **Redirects:** both old URLs 301 to `/workspace/launch`. `app_module_id` is not preserved — it referenced the pre-restructure application model and is no longer usable.
+- **Repo-internal call sites updated in this PR:** `WorkspaceDashboardContainer`, `DashboardContainer`, `ProjectOverviewContainer`, `ExperimentListContainer`, `ApplicationEditorContainer`, dataparsers output views, and the SDK's `experiment_util` helper (with a deprecation shim for downstream gateway consumers).
+
+## Frontend Architecture
+
+- **Entry point:** `django_airavata/apps/workspace/static/django_airavata_workspace/js/entry-launch.ts`. Vue 3 `<script setup lang="ts">` throughout (matches Track A).
+- **Component tree:**
+ ```
+ LaunchContainer
+ ExperimentMetaHeader (name + project + description)
+ WizardTabs (3 tabs, strict-forward gating)
+ Tab1ApplicationInputs
+ AppPicker (category chips + search + tile grid)
+ InterfacePicker (verb cards for the selected app)
+ InputList (scalar + file, mixed; file rows have storage + path + stage-in badge)
+ OutputList (file outputs: target storage + path + stage-out badge)
+ Tab2Runtime (compute / partition / walltime / nodes / CPUs / allocation readout)
+ Tab3ReviewLaunch (invocation command + read-only script + launch button)
+ ```
+- **State:** Pinia store `stores/launch.ts` (`useLaunchStore`) owns the entire draft — metadata, picked app, picked interface, inputs map, outputs map, runtime selections, preview result, per-tab validation (derived getter). Tabs are dumb views.
+- **In-page nav:** URL query param `?tab=1|2|3`. Browser back/forward works inside the wizard. Strict forward gate implemented in `WizardTabs` by checking per-tab validity from the store.
+- **Draft persistence:** localStorage keyed by `user_id + draft_uuid`. Restored on mount, cleared on successful launch. Server-side drafts are out of scope for v1.
+- **Reuse:** the core input widgets from `ComputationalResourceSchedulingEditor` and `QueueSettingsEditor` get extracted into leaner `<script setup>` components under `components/launch/runtime/`. The original editor components (used only by the dying ExperimentEditor) go away with it.
+- **Deleted:** `CreateExperimentContainer.vue`, `EditExperimentContainer.vue`, `ExperimentEditor.vue`, `ComputationalResourceSchedulingEditor.vue`, `QueueSettingsEditor.vue`, `GroupResourceProfileSelector.vue`, `ApplicationListContainer` (if present), `entry-create-experiment.js`, `entry-edit-experiment.js`. The existing-experiment detail page stays; editing of in-flight experiments is dropped (the old draft shape is not meaningful under the new app model).
+
+## Backend API Surface
+
+**New Django REST endpoints** (in `django_airavata/apps/api/`):
+
+| Method | Path | Purpose |
+|--------|------|---------|
+| GET | `/api/applications/?category=&search=` | list apps with content ref + declared interfaces + category |
+| GET | `/api/applications/<app_id>/` | single app detail (same shape) |
+| GET | `/api/projects/<project_id>/resource-profile/` | resolved profile: allowed compute resources, partitions per resource, allocation id |
+| GET | `/api/user-storages/` | storages the current user can access |
+| POST | `/api/experiments/preview/` | body: experiment draft. Returns `{ invocation_command, script_contents, warnings[] }`. Thin proxy to the airavata dry-run RPC. |
+| POST | `/api/experiments/` | existing endpoint, updated to accept the new draft shape. Launches (no separate submit call). |
+
+**Experiment draft schema** (REST JSON + matching Thrift):
+
+```json
+{
+ "name": "string (≤256)",
+ "project_id": "string",
+ "description": "string (optional)",
+ "app_id": "string",
+ "interface_name": "string",
+ "inputs": { "<name>": "<scalar> | { \"storage_id\": \"…\", \"path\": \"…\" }" },
+ "outputs": { "<name>": { "storage_id": "…", "path": "…" } },
+ "runtime": {
+ "compute_resource_id": "string",
+ "partition": "string",
+ "walltime": "HH:MM:SS",
+ "nodes": 1,
+ "cpus_per_node": 1
+ }
+}
+```
+
+Interim storage is not in the schema. The server derives it from `compute_resource_id` + `project_id` (every compute resource has a 1-1 mapped storage resource; the project gets a scratch subdir under that storage).
+
+Resource profile resolution: each project has a resource profile attached (configured by the project admin). Switching projects at launch time switches the allowed compute resources, partitions, and allocation id. There is no user-level aggregation.
+
+**Error shapes:**
+
+- `400 { field: [msgs] }` — server-side validation failures. The portal renders them inline on the offending row or field.
+- `502 { message }` — airavata unreachable. Preview shows error banner; launch button disabled.
+- `409 { message, field }` — referenced storage path inaccessible to user. Surfaces on the offending input row.
+
+## Tab-by-Tab Specification
+
+### Top strip (persistent across tabs)
+- Experiment name — required, ≤ 256 characters.
+- Project — required dropdown from user's project list.
+- Description — optional multiline.
+- Changing Project invalidates Tab 2's compute resource, partition, and allocation readout. If user is on Tab 2 when this happens, a warning toast fires.
+
+### Tab 1 — Application & Inputs
+Progressive disclosure, all in one tab:
+
+1. **Application** — category chip row with counts (chips from `/api/applications/` grouped); "All" default. Search box narrows within the active chip. Tile grid is server-paginated (50 per page). Selecting an app collapses the grid to a compact summary with a "change" link.
+2. **Interface** — card row showing the selected app's user-defined verbs with I/O signatures (e.g., `run(sim_dir, force_field, steps:int) → trajectory`). Required pick; first interface auto-selected.
+3. **Inputs** — one row per declared input:
+ - Scalar (`int`, `float`, `string`, `bool`, `enum`, `multi-string`): existing `input-editors/*.vue` widgets reused, typed via the signature.
+ - File/dir: `[name+type-tag] [storage select] [path input with browse-tree modal] [stage-in badge]`. Storage select default = user's primary storage.
+4. **Outputs** — one row per file/dir output: `[name+type-tag] [target storage select] [path input] [stage-out badge]`. Scalar outputs get no row (returned in the job result).
+
+**Tab 1 validity:** name + project set, app picked, interface picked, every required input has a value, every file I/O row has storage + path.
+
+Changing app clears interface + inputs + outputs (warned). Changing interface clears inputs + outputs (warned).
+
+### Tab 2 — Runtime
+- Compute resource — dropdown from the selected project's resolved resource profile.
+- Partition — dropdown from the compute resource's profile entry.
+- Walltime — `HH:MM:SS` input, validated against partition max walltime.
+- Nodes — integer, validated against partition max nodes.
+- CPUs per node — integer, validated against partition spec.
+- Allocation ID — read-only badge, auto-filled from project's profile. For SLURM resources this is the value for `-A`.
+- Compute storage — read-only badge showing the compute resource's 1-1 mapped storage resource and the project scratch path (where interim storage lives for this run).
+
+**Tab 2 validity:** all five inputs set and within partition limits.
+
+### Tab 3 — Review & Launch
+- On entry: hash the draft; if hash matches the last-rendered one, serve cached preview. Otherwise fire `POST /api/experiments/preview/` with loading skeleton (~1-3s expected).
+- Success: invocation command banner (`sbatch <path>` or `bash <path>`) + syntax-highlighted read-only script. `warnings[]` shown above the script as a yellow banner list (non-blocking). Launch button enabled.
+- Failure: error banner with retry button. Launch button disabled.
+- Launch: `POST /api/experiments/` with same draft. On success, redirect to `/workspace/experiments/<id>`. On failure, red banner with the server error + try-again button; stays on Tab 3. Draft is not cleared from localStorage until launch actually succeeds.
+
+## Error & Edge-Case Handling
+
+- **Preview freshness:** draft-hash cache prevents re-rendering when user bounces Tab 3 ↔ Tab 1 without editing.
+- **Preview in-flight + nav-away:** pending request cancelled via `AbortController` when user leaves Tab 3.
+- **Launch failure:** stays on Tab 3 with retry; draft preserved in localStorage.
+- **Session expiry:** 401 on any API call → redirect to `/auth/login?next=/workspace/launch`. On return, `LaunchContainer` hydrates from localStorage.
+- **Network offline:** API layer retries once with backoff; if still offline, tab-level error with "Can't reach the portal — check your connection"; launch disabled.
+- **Partial dependency load:** storages fail / apps succeed → storage dropdowns show "Can't load storages — retry"; rest of Tab 1 usable. Apps fail → whole tab shows error; no progress.
+- **Draft reuse across devices:** localStorage is per-device. V1 accepts this.
+- **Project / app / interface change mid-flow:** downstream tab state cleared with a warning toast naming what's being reset.
+
+## Testing Strategy
+
+- **Unit (Vitest):** one spec per component with logic — `AppPicker` (filter + search), `InterfacePicker` (selection + signature propagation), `InputList` (scalar vs file row routing), `Tab2Runtime` (profile-driven dropdowns, partition validation), `LaunchContainer` (tab-gating derivation). Separate spec for `useLaunchStore` (draft hash, tab-validity getters, interim-storage derivation, project-change reset).
+- **Integration (Vitest + `@vue/test-utils` + `happy-dom`):** full-flow spec with API layer mocked — happy path, project-change invalidation, interface-change clearing, preview failure → retry. No network.
+- **E2E (Playwright):** extends `tests/e2e/specs/`:
+ - `launch-happy.spec.ts` — login, navigate, pick app + interface, fill inputs, set runtime, preview, launch, assert redirect to experiment detail.
+ - `launch-error-paths.spec.ts` — preview failure (mock 502), strict-forward gate (click blocked tab), project change invalidates tab 2.
+ - `smoke.spec.ts` gets `/workspace/launch` added to `AUTHENTICATED_PAGES`.
+- **Backend (Django):** one `TestCase` per new DRF view — happy path + auth + 4xx shapes. `preview` view tested with the Thrift client mocked (live Java dry-run is out of scope for portal CI).
+- **Contract tests:** JSON schemas under `tests/contracts/` for the experiment-draft payload and preview response. Both Vitest and Django tests import them so contract drift is caught.
+- **Manual checklist in PR template:** each I/O type exercised once, project swap mid-flow, app swap mid-flow, session expiry on Tab 3, live dry-run against a running dev stack.
+
+## Migration & Rollout
+
+1. Airavata server merges new app model + dry-run RPC (tracked separately).
+2. Portal branch consumes the new APIs, gated behind a `FEATURE_GENERIC_LAUNCHER` flag in `settings.py` (default off).
+3. Verified end-to-end against a live dev stack.
+4. Flag removed in a single commit that flips it globally on.
+5. Playwright smoke expanded to cover `/workspace/launch`; failing smoke blocks merge.
+
+**Deleted code (same PR):** see Frontend Architecture → Deleted.
+
+**Added code (same PR):** `entry-launch.ts`, `containers/LaunchContainer.vue`, `components/launch/**`, `stores/launch.ts`, new DRF views + serializers in `apps/api/views.py` and `apps/api/serializers.py`, new URL entries in `apps/api/urls.py`, new `launch(request)` view and `/workspace/launch` route in `apps/workspace/views.py` and `urls.py`, 301 redirects for the old routes.
+
+## Out of Scope (explicit)
+
+- App definition UI for the new model (admins register apps with content + interfaces). Separate track.
+- Experiment edit flow under the new model.
+- Server-side drafts.
+- Per-output multi-file globs (outputs are single file/dir pointers).
+
+## Open Questions / Coordination
+
+- Exact naming and Thrift signature of the dry-run RPC needs to match what the airavata team commits to. Portal implementation blocks on that contract.
+- Resource profile attached to project: the existing project model already carries a GroupResourceProfile reference; the new "resource profile" is expected to supersede or extend that. Portal should confirm the field name on `Project` that resolves to this new profile before implementing the `GET /api/projects/<id>/resource-profile/` view.
(airavata-portals) 26/34: feat(launcher): localStorage draft persistence + hydrate()
Posted by ya...@apache.org.
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 2e5af0f95cc738b8f418dc97e19efca7ff178597
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 23:04:43 2026 -0400
feat(launcher): localStorage draft persistence + hydrate()
---
.../js/containers/LaunchContainer.vue | 3 ++
.../tests/unit/stores/launch.spec.ts | 34 ++++++++++++++++++++++
.../static/common/js/stores/launch.ts | 27 ++++++++++++++++-
airavata-django-portal/tooling/vitest-setup.ts | 16 ++++++++++
airavata-django-portal/tooling/vitest.config.js | 1 +
5 files changed, 80 insertions(+), 1 deletion(-)
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/LaunchContainer.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/LaunchContainer.vue
index d9a1d0809..2904afa27 100644
--- a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/LaunchContainer.vue
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/LaunchContainer.vue
@@ -18,6 +18,7 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { launcherService } from "django-airavata-common-ui/js/services/launcherService";
+import { useLaunchStore } from "django-airavata-common-ui/js/stores/launch";
import ExperimentMetaHeader from "../components/launch/ExperimentMetaHeader.vue";
import WizardTabs from "../components/launch/WizardTabs.vue";
import Tab1ApplicationInputs from "../components/launch/Tab1ApplicationInputs.vue";
@@ -26,6 +27,7 @@ import Tab3ReviewLaunch from "../components/launch/Tab3ReviewLaunch.vue";
const active = ref<1 | 2 | 3>(1);
const projects = ref<Array<{ project_id: string; name: string }>>([]);
+const store = useLaunchStore();
function onChangeTab(n: 1 | 2 | 3) {
active.value = n;
@@ -35,6 +37,7 @@ function onChangeTab(n: 1 | 2 | 3) {
}
onMounted(async () => {
+ store.hydrate();
const url = new URL(window.location.href);
const t = Number(url.searchParams.get("tab"));
if (t === 1 || t === 2 || t === 3) active.value = t as 1 | 2 | 3;
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
index b064e646f..ad073e075 100644
--- 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
@@ -102,3 +102,37 @@ describe("setters and reset", () => {
expect(s.tab1Valid).toBe(true);
});
});
+
+describe("draft persistence", () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ localStorage.clear();
+ });
+
+ it("saves draft to localStorage on every change", () => {
+ const s = useLaunchStore();
+ s.setMeta({ name: "abc", project_id: "p1", description: "" });
+ const stored = localStorage.getItem("launch-draft");
+ expect(stored).not.toBeNull();
+ expect(JSON.parse(stored!).name).toBe("abc");
+ });
+
+ it("restores draft from localStorage on hydrate()", () => {
+ localStorage.setItem("launch-draft", JSON.stringify({
+ name: "restored", project_id: "p1", description: "",
+ app_id: null, interface_name: null, inputs: {}, outputs: {},
+ runtime: { compute_resource_id: null, partition: null,
+ walltime: "01:00:00", nodes: 1, cpus_per_node: 1 },
+ }));
+ const s = useLaunchStore();
+ s.hydrate();
+ expect(s.draft.name).toBe("restored");
+ });
+
+ it("reset() clears localStorage", () => {
+ const s = useLaunchStore();
+ s.setMeta({ name: "abc", project_id: "p1", description: "" });
+ s.reset();
+ expect(localStorage.getItem("launch-draft")).toBeNull();
+ });
+});
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
index 5e34c91b8..d769e0017 100644
--- a/airavata-django-portal/django_airavata/static/common/js/stores/launch.ts
+++ b/airavata-django-portal/django_airavata/static/common/js/stores/launch.ts
@@ -1,5 +1,5 @@
import { defineStore } from "pinia";
-import { computed, reactive, ref } from "vue";
+import { computed, reactive, ref, watch } from "vue";
import type {
Application,
ExperimentDraft,
@@ -142,6 +142,29 @@ export const useLaunchStore = defineStore("launch", () => {
profile.value = p;
}
+ const STORAGE_KEY = "launch-draft";
+
+ function persist(draftValue: ExperimentDraft) {
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(draftValue)); } catch { /* ignore quota */ }
+ }
+
+ function clearPersisted() {
+ try { localStorage.removeItem(STORAGE_KEY); } catch { /* noop */ }
+ }
+
+ function hydrate() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return;
+ const parsed = JSON.parse(raw) as Partial<ExperimentDraft>;
+ Object.assign(draft, makeDraft(), parsed);
+ } catch {
+ /* ignore corrupted draft */
+ }
+ }
+
+ watch(draft, (d) => persist(d), { deep: true, flush: "sync" });
+
function reset() {
Object.assign(draft, makeDraft());
pickedApp.value = null;
@@ -150,6 +173,7 @@ export const useLaunchStore = defineStore("launch", () => {
previewError.value = null;
previewLoading.value = false;
lastPreviewedHash.value = null;
+ clearPersisted();
}
return {
@@ -173,6 +197,7 @@ export const useLaunchStore = defineStore("launch", () => {
setRuntime,
setStorages,
setProfile,
+ hydrate,
reset,
};
});
diff --git a/airavata-django-portal/tooling/vitest-setup.ts b/airavata-django-portal/tooling/vitest-setup.ts
index 4b4e4ece3..a553a6d4a 100644
--- a/airavata-django-portal/tooling/vitest-setup.ts
+++ b/airavata-django-portal/tooling/vitest-setup.ts
@@ -17,3 +17,19 @@ if (typeof globalThis.ResizeObserver === "undefined") {
disconnect() {}
};
}
+
+// Node 25 ships a built-in `localStorage` stub that lacks the full Storage API
+// (e.g. `.clear()` is missing). Replace it with a simple in-memory
+// implementation so tests that rely on localStorage work correctly in jsdom.
+(function installLocalStorage() {
+ const store: Record<string, string> = {};
+ const impl = {
+ get length() { return Object.keys(store).length; },
+ getItem(k: string) { return Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null; },
+ setItem(k: string, v: string) { store[k] = String(v); },
+ removeItem(k: string) { delete store[k]; },
+ clear() { for (const k of Object.keys(store)) delete store[k]; },
+ key(n: number) { return Object.keys(store)[n] ?? null; },
+ };
+ Object.defineProperty(globalThis, "localStorage", { value: impl, writable: true, configurable: true });
+})();
diff --git a/airavata-django-portal/tooling/vitest.config.js b/airavata-django-portal/tooling/vitest.config.js
index c2dab779e..04a6e7d7e 100644
--- a/airavata-django-portal/tooling/vitest.config.js
+++ b/airavata-django-portal/tooling/vitest.config.js
@@ -19,6 +19,7 @@ export function defineVitestConfig({ srcDir, overrides = {} }) {
test: {
globals: true,
environment: "jsdom",
+ environmentOptions: { jsdom: { url: "http://localhost/" } },
setupFiles: [resolve(import.meta.dirname, "./vitest-setup.ts")],
include: ["**/*.{test,spec}.{js,ts,mjs}", "**/tests/**/*.{test,spec}.{js,ts}"],
exclude: ["**/node_modules/**", "**/dist/**", "**/tests/e2e/**"],
(airavata-portals) 09/34: feat(launcher): preview + create endpoints with 502/400 handling
Posted by ya...@apache.org.
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 cf5be4ed515bc2924fa65cadb5a41ed4452b0908
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 21:43:51 2026 -0400
feat(launcher): preview + create endpoints with 502/400 handling
---
.../django_airavata/apps/api/launcher_views.py | 30 ++++++++++-
.../apps/api/tests/test_launcher_views.py | 58 ++++++++++++++++++++++
.../django_airavata/apps/api/urls.py | 4 ++
3 files changed, 90 insertions(+), 2 deletions(-)
diff --git a/airavata-django-portal/django_airavata/apps/api/launcher_views.py b/airavata-django-portal/django_airavata/apps/api/launcher_views.py
index f0b39ba3a..82fe88346 100644
--- a/airavata-django-portal/django_airavata/apps/api/launcher_views.py
+++ b/airavata-django-portal/django_airavata/apps/api/launcher_views.py
@@ -1,10 +1,10 @@
-from rest_framework import permissions
+from rest_framework import permissions, status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.exceptions import NotFound
from rest_framework.request import Request
from rest_framework.response import Response
-from django_airavata.apps.api import launcher_client
+from django_airavata.apps.api import launcher_client, launcher_serializers
def _client(request: Request) -> launcher_client.LauncherClient:
@@ -46,3 +46,29 @@ def user_storages(request: Request) -> Response:
@permission_classes([permissions.IsAuthenticated])
def projects_list(request: Request) -> Response:
return Response({"results": _client(request).list_projects()})
+
+
+@api_view(["POST"])
+@permission_classes([permissions.IsAuthenticated])
+def experiment_draft_preview(request: Request) -> Response:
+ serializer = launcher_serializers.ExperimentDraftSerializer(data=request.data)
+ if not serializer.is_valid():
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+ try:
+ result = _client(request).generate_preview(serializer.validated_data)
+ except ConnectionError as e:
+ return Response({"message": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
+ return Response(result)
+
+
+@api_view(["POST"])
+@permission_classes([permissions.IsAuthenticated])
+def experiment_draft_create(request: Request) -> Response:
+ serializer = launcher_serializers.ExperimentDraftSerializer(data=request.data)
+ if not serializer.is_valid():
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+ try:
+ result = _client(request).launch_experiment(serializer.validated_data)
+ except ConnectionError as e:
+ return Response({"message": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
+ return Response(result, status=status.HTTP_201_CREATED)
diff --git a/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_views.py b/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_views.py
index e328b936a..f167e63b6 100644
--- a/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_views.py
+++ b/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_views.py
@@ -1,7 +1,11 @@
+from unittest.mock import patch
+
from django.contrib.auth import get_user_model
from django.test import override_settings
from rest_framework.test import APITestCase
+from django_airavata.apps.api import launcher_views # for the patch target
+
class LauncherListingViewsTest(APITestCase):
def setUp(self):
@@ -79,3 +83,57 @@ class LauncherListingViewsTest(APITestCase):
"/api/launcher/projects/",
]:
self.assertEqual(self.client.get(url).status_code, 403)
+
+
+class LauncherWriteViewsTest(APITestCase):
+ def setUp(self):
+ User = get_user_model()
+ self.user = User.objects.create_user(username="alice", password="pw")
+ self.client.force_authenticate(user=self.user)
+ self.draft = {
+ "name": "test-run",
+ "project_id": "proj-1",
+ "app_id": "namd",
+ "interface_name": "run",
+ "inputs": {"steps": 100},
+ "outputs": {"trajectory": {"storage_id": "my-home", "path": "/x/out.dcd"}},
+ "runtime": {
+ "compute_resource_id": "bridges-2",
+ "partition": "RM",
+ "walltime": "01:00:00",
+ "nodes": 1,
+ "cpus_per_node": 64,
+ },
+ }
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_preview_returns_script_and_command(self):
+ resp = self.client.post("/api/launcher/experiment-drafts/preview/", self.draft, format="json")
+ self.assertEqual(resp.status_code, 200, resp.content)
+ body = resp.json()
+ self.assertIn("invocation_command", body)
+ self.assertIn("script_contents", body)
+ self.assertIn("warnings", body)
+ self.assertTrue(body["script_contents"].startswith("#!/bin/bash"))
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_preview_rejects_invalid_draft(self):
+ bad = dict(self.draft)
+ bad["runtime"] = {**bad["runtime"], "walltime": "garbage"}
+ resp = self.client.post("/api/launcher/experiment-drafts/preview/", bad, format="json")
+ self.assertEqual(resp.status_code, 400)
+ self.assertIn("runtime", resp.json())
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_preview_returns_502_when_client_unreachable(self):
+ with patch.object(launcher_views, "_client") as mock_client:
+ mock_client.return_value.generate_preview.side_effect = ConnectionError("airavata down")
+ resp = self.client.post("/api/launcher/experiment-drafts/preview/", self.draft, format="json")
+ self.assertEqual(resp.status_code, 502)
+ self.assertIn("message", resp.json())
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_create_returns_experiment_id(self):
+ resp = self.client.post("/api/launcher/experiment-drafts/", self.draft, format="json")
+ self.assertEqual(resp.status_code, 201, resp.content)
+ self.assertIn("experiment_id", resp.json())
diff --git a/airavata-django-portal/django_airavata/apps/api/urls.py b/airavata-django-portal/django_airavata/apps/api/urls.py
index 3997fca61..c41e0c235 100644
--- a/airavata-django-portal/django_airavata/apps/api/urls.py
+++ b/airavata-django-portal/django_airavata/apps/api/urls.py
@@ -109,6 +109,10 @@ urlpatterns += [
),
re_path(r"^launcher/storages/$", launcher_views.user_storages, name="launcher_user_storages"),
re_path(r"^launcher/projects/$", launcher_views.projects_list, name="launcher_projects_list"),
+ re_path(r"^launcher/experiment-drafts/preview/$",
+ launcher_views.experiment_draft_preview, name="launcher_draft_preview"),
+ re_path(r"^launcher/experiment-drafts/$",
+ launcher_views.experiment_draft_create, name="launcher_draft_create"),
]
if logger.isEnabledFor(logging.DEBUG):
(airavata-portals) 27/34: feat(launcher): confirm prompts on app/interface/project change
Posted by ya...@apache.org.
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 e80fff597ece8c59972f1a725f26373b9c578343
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 23:06:56 2026 -0400
feat(launcher): confirm prompts on app/interface/project change
---
.../js/components/launch/AppPicker.vue | 12 +++++++++++-
.../js/components/launch/ExperimentMetaHeader.vue | 17 ++++++++++++++++-
.../js/components/launch/InterfacePicker.vue | 16 +++++++++++++++-
.../js/composables/useConfirmReset.ts | 5 +++++
.../tests/unit/composables/useConfirmReset.spec.ts | 19 +++++++++++++++++++
5 files changed, 66 insertions(+), 3 deletions(-)
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/AppPicker.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/AppPicker.vue
index 7df9ef1ad..b14af9bf0 100644
--- a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/AppPicker.vue
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/AppPicker.vue
@@ -46,6 +46,7 @@
import { computed, ref } from "vue";
import type { Application } from "django-airavata-common-ui/js/stores/launch-types";
import { useLaunchStore } from "django-airavata-common-ui/js/stores/launch";
+import { useConfirmReset } from "../../composables/useConfirmReset";
const props = defineProps<{ applications: Application[] }>();
const store = useLaunchStore();
@@ -74,7 +75,16 @@ const filtered = computed(() => {
return xs;
});
+const guardedPick = useConfirmReset(
+ "Switching app clears interface, inputs, and outputs. Continue?",
+ (a: Application) => store.pickApp(a),
+);
+
function pick(a: Application) {
- store.pickApp(a);
+ if (store.draft.app_id && store.draft.app_id !== a.app_id) {
+ guardedPick(a);
+ } else {
+ store.pickApp(a);
+ }
}
</script>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/ExperimentMetaHeader.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/ExperimentMetaHeader.vue
index 52fddd29b..2e9e030e2 100644
--- a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/ExperimentMetaHeader.vue
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/ExperimentMetaHeader.vue
@@ -38,6 +38,7 @@
<script setup lang="ts">
import { useLaunchStore } from "django-airavata-common-ui/js/stores/launch";
+import { useConfirmReset } from "../../composables/useConfirmReset";
defineProps<{ projects: Array<{ project_id: string; name: string }> }>();
@@ -46,9 +47,23 @@ const store = useLaunchStore();
function onName(v: string) {
store.setMeta({ name: v, project_id: store.draft.project_id, description: store.draft.description });
}
+
+const guardedProject = useConfirmReset(
+ "Switching project clears your runtime selections. Continue?",
+ (id: string | null) => {
+ store.setMeta({ name: store.draft.name, project_id: id, description: store.draft.description });
+ },
+);
+
function onProject(v: string) {
- store.setMeta({ name: store.draft.name, project_id: v || null, description: store.draft.description });
+ const next = v || null;
+ if (store.draft.project_id && store.draft.project_id !== next) {
+ guardedProject(next);
+ } else {
+ store.setMeta({ name: store.draft.name, project_id: next, description: store.draft.description });
+ }
}
+
function onDescription(v: string) {
store.setMeta({ name: store.draft.name, project_id: store.draft.project_id, description: v });
}
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/InterfacePicker.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/InterfacePicker.vue
index 5216b013a..e56aa94ac 100644
--- a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/InterfacePicker.vue
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/InterfacePicker.vue
@@ -11,7 +11,7 @@
class="card w-100 text-start p-2"
:class="{ 'border-primary': store.draft.interface_name === iface.name }"
:data-test="`iface-card-${iface.name}`"
- @click="store.pickInterface(iface.name)"
+ @click="onPick(iface.name)"
>
<code class="d-block fw-bold">{{ iface.name }}</code>
<small class="text-muted" :data-test="`iface-sig-${iface.name}`">
@@ -25,10 +25,24 @@
<script setup lang="ts">
import type { IODescriptor } from "django-airavata-common-ui/js/stores/launch-types";
import { useLaunchStore } from "django-airavata-common-ui/js/stores/launch";
+import { useConfirmReset } from "../../composables/useConfirmReset";
const store = useLaunchStore();
function formatList(io: IODescriptor[]): string {
return io.map((x) => `${x.name}: ${x.type}`).join(", ");
}
+
+const guarded = useConfirmReset(
+ "Switching interface clears inputs and outputs. Continue?",
+ (n: string) => store.pickInterface(n),
+);
+
+function onPick(n: string) {
+ if (store.draft.interface_name && store.draft.interface_name !== n) {
+ guarded(n);
+ } else {
+ store.pickInterface(n);
+ }
+}
</script>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/composables/useConfirmReset.ts b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/composables/useConfirmReset.ts
new file mode 100644
index 000000000..0f0285e66
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/composables/useConfirmReset.ts
@@ -0,0 +1,5 @@
+export function useConfirmReset<TArgs extends unknown[]>(message: string, fn: (...a: TArgs) => void) {
+ return (...args: TArgs) => {
+ if (window.confirm(message)) fn(...args);
+ };
+}
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/composables/useConfirmReset.spec.ts b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/composables/useConfirmReset.spec.ts
new file mode 100644
index 000000000..a462cce2c
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/composables/useConfirmReset.spec.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it, vi } from "vitest";
+import { useConfirmReset } from "../../../js/composables/useConfirmReset";
+
+describe("useConfirmReset", () => {
+ it("calls onConfirm when window.confirm returns true", () => {
+ vi.spyOn(window, "confirm").mockReturnValueOnce(true);
+ const fn = vi.fn();
+ const guarded = useConfirmReset("ok?", fn);
+ guarded();
+ expect(fn).toHaveBeenCalledTimes(1);
+ });
+
+ it("skips onConfirm when window.confirm returns false", () => {
+ vi.spyOn(window, "confirm").mockReturnValueOnce(false);
+ const fn = vi.fn();
+ useConfirmReset("ok?", fn)();
+ expect(fn).not.toHaveBeenCalled();
+ });
+});
(airavata-portals) 30/34: test(launcher): Playwright error-path coverage (gate, preview fail, project change)
Posted by ya...@apache.org.
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 0064d23c67e88e86608bc72b04d0068e6739e1cc
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 23:17:10 2026 -0400
test(launcher): Playwright error-path coverage (gate, preview fail, project change)
---
.../tests/e2e/specs/launch-error-paths.spec.ts | 60 ++++++++++++++++++++++
1 file changed, 60 insertions(+)
diff --git a/airavata-django-portal/tests/e2e/specs/launch-error-paths.spec.ts b/airavata-django-portal/tests/e2e/specs/launch-error-paths.spec.ts
new file mode 100644
index 000000000..6d50c17a4
--- /dev/null
+++ b/airavata-django-portal/tests/e2e/specs/launch-error-paths.spec.ts
@@ -0,0 +1,60 @@
+import { test, expect } from "../fixtures/auth";
+
+test("strict-forward gate: cannot click tab 2 before tab 1 is valid", async ({ page }) => {
+ await page.goto("/workspace/launch");
+ await expect(page.locator("button[role='tab']").nth(1)).toBeDisabled();
+});
+
+test("preview failure shows error and disables launch", async ({ page }) => {
+ // Intercept the preview endpoint and return 502
+ await page.route("**/api/launcher/experiment-drafts/preview/", (route) =>
+ route.fulfill({ status: 502, body: JSON.stringify({ message: "airavata down" }) }),
+ );
+
+ await page.goto("/workspace/launch");
+ await page.getByPlaceholder(/Experiment name/i).fill("err-stub");
+ await page.locator("select[data-test='exp-project']").selectOption({ index: 1 });
+ await page.locator("[data-test='app-tile-namd']").click();
+ await page.locator("[data-test='iface-card-run']").click();
+ // Pick a storage for sim_dir and fill its path
+ const storageSelects = page.locator("select").filter({ hasText: /My Home|Bridges/ });
+ await storageSelects.first().selectOption({ index: 1 });
+ await page.locator("input[data-test='file-path-sim_dir']").fill("/x");
+ await page.locator("input[data-test='scalar-steps']").fill("1");
+ await page.locator("input[data-test='file-out-path-trajectory']").fill("/y");
+ await page.locator("button[role='tab']").nth(1).click();
+ await page.locator("select[data-test='cr']").selectOption("bridges-2");
+ await page.locator("select[data-test='partition']").selectOption("RM");
+ await page.locator("button[role='tab']").nth(2).click();
+
+ await expect(page.locator(".alert-danger")).toContainText("airavata down");
+ await expect(page.locator("button[data-test='launch']")).toBeDisabled();
+});
+
+test("project change clears runtime selections", async ({ page }) => {
+ await page.goto("/workspace/launch");
+ await page.getByPlaceholder(/Experiment name/i).fill("proj-change");
+ await page.locator("select[data-test='exp-project']").selectOption({ index: 1 });
+
+ // The confirm dialog is what we're testing — accept it when prompted
+ page.on("dialog", (d) => d.accept());
+
+ await page.locator("[data-test='app-tile-namd']").click();
+ await page.locator("[data-test='iface-card-run']").click();
+ // Storage + path for sim_dir
+ const storageSelects = page.locator("select").filter({ hasText: /My Home|Bridges/ });
+ await storageSelects.first().selectOption({ index: 1 });
+ await page.locator("input[data-test='file-path-sim_dir']").fill("/x");
+ await page.locator("input[data-test='scalar-steps']").fill("1");
+ await page.locator("input[data-test='file-out-path-trajectory']").fill("/y");
+ await page.locator("button[role='tab']").nth(1).click();
+ await page.locator("select[data-test='cr']").selectOption("bridges-2");
+
+ // Switch project — the dropdown only has projects from session data; pick the second.
+ // If only one project exists, this test no-ops on the assertion below.
+ const opts = await page.locator("select[data-test='exp-project'] option").allTextContents();
+ if (opts.length > 2) {
+ await page.locator("select[data-test='exp-project']").selectOption({ index: 2 });
+ await expect(page.locator("select[data-test='cr']")).toHaveValue("");
+ }
+});
(airavata-portals) 10/34: feat(launcher): /workspace/launch view + 301 redirects from old URLs
Posted by ya...@apache.org.
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 3b79918d2b063891e77c4ba75d3cac44d439c322
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 21:55:06 2026 -0400
feat(launcher): /workspace/launch view + 301 redirects from old URLs
Adds the SPA shell view at /workspace/launch and wires 301 redirects from
the old /workspace/applications, /applications/<id>/create_experiment, and
/applications/<id>/ URLs. Updates all internal reverse() calls and nav config
to point to the new launch URL. Includes failing-first TDD test suite with
signal-handler and Vite-loader patches to make tests self-contained.
---
.../django_airavata/apps/admin/views.py | 2 +-
.../django_airavata/apps/auth/signals.py | 4 +-
.../django_airavata/apps/workspace/apps.py | 4 +-
.../django_airavata_workspace/launch.html | 10 +++
.../apps/workspace/tests/__init__.py | 0
.../apps/workspace/tests/test_launch_view.py | 96 ++++++++++++++++++++++
.../django_airavata/apps/workspace/urls.py | 12 +--
.../django_airavata/apps/workspace/views.py | 7 ++
airavata-django-portal/django_airavata/settings.py | 2 +-
9 files changed, 126 insertions(+), 11 deletions(-)
diff --git a/airavata-django-portal/django_airavata/apps/admin/views.py b/airavata-django-portal/django_airavata/apps/admin/views.py
index 5468b7776..927d98b80 100644
--- a/airavata-django-portal/django_airavata/apps/admin/views.py
+++ b/airavata-django-portal/django_airavata/apps/admin/views.py
@@ -14,7 +14,7 @@ def app_catalog(request):
# sub-routes (new, edit) still render the admin SPA editor.
path = request.path.rstrip("/")
if path == "/admin/applications":
- return redirect(reverse("django_airavata_workspace:applications"))
+ return redirect(reverse("django_airavata_workspace:launch"))
request.active_nav_item = "app_catalog"
return render(request, "admin/admin_base.html")
diff --git a/airavata-django-portal/django_airavata/apps/auth/signals.py b/airavata-django-portal/django_airavata/apps/auth/signals.py
index 143a78a32..12f20f53f 100644
--- a/airavata-django-portal/django_airavata/apps/auth/signals.py
+++ b/airavata-django-portal/django_airavata/apps/auth/signals.py
@@ -26,8 +26,8 @@ def email_user_added_to_group(sender, user, groups, request, **kwargs):
"last_name": user.lastName,
"username": user.userId,
"portal_title": settings.PORTAL_TITLE,
- "dashboard_url": request.build_absolute_uri(reverse("django_airavata_workspace:applications")),
- "experiments_url": request.build_absolute_uri(reverse("django_airavata_workspace:applications")),
+ "dashboard_url": request.build_absolute_uri(reverse("django_airavata_workspace:launch")),
+ "experiments_url": request.build_absolute_uri(reverse("django_airavata_workspace:launch")),
"group_names": [g.name for g in groups],
}
)
diff --git a/airavata-django-portal/django_airavata/apps/workspace/apps.py b/airavata-django-portal/django_airavata/apps/workspace/apps.py
index ed13e11d3..15991acc8 100644
--- a/airavata-django-portal/django_airavata/apps/workspace/apps.py
+++ b/airavata-django-portal/django_airavata/apps/workspace/apps.py
@@ -21,8 +21,8 @@ class WorkspaceConfig(AiravataAppConfig):
{
"label": "Applications",
"icon": "fa fa-cubes",
- "url": "django_airavata_workspace:applications",
- "active_prefixes": ["applications"],
+ "url": "django_airavata_workspace:launch",
+ "active_prefixes": ["applications", "launch"],
},
{
"label": "Datasets",
diff --git a/airavata-django-portal/django_airavata/apps/workspace/templates/django_airavata_workspace/launch.html b/airavata-django-portal/django_airavata/apps/workspace/templates/django_airavata_workspace/launch.html
new file mode 100644
index 000000000..5048ab712
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/templates/django_airavata_workspace/launch.html
@@ -0,0 +1,10 @@
+{% extends "base.html" %}
+{% load django_vite %}
+
+{% block content %}
+ <div id="launch-app" data-feature-flag="{{ feature_flag|yesno:'on,off' }}"></div>
+{% endblock %}
+
+{% block scripts %}
+ {% vite_asset "js/entry-launch.ts" app="WORKSPACE" %}
+{% endblock %}
diff --git a/airavata-django-portal/django_airavata/apps/workspace/tests/__init__.py b/airavata-django-portal/django_airavata/apps/workspace/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/airavata-django-portal/django_airavata/apps/workspace/tests/test_launch_view.py b/airavata-django-portal/django_airavata/apps/workspace/tests/test_launch_view.py
new file mode 100644
index 000000000..eb7065622
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/tests/test_launch_view.py
@@ -0,0 +1,96 @@
+from unittest.mock import patch
+
+from django.contrib.auth import get_user_model
+from django.contrib.auth.signals import user_logged_in
+from django.test import TestCase
+
+# Fake authz token returned by the patched get_authz_token so that
+# authz_token_middleware doesn't log out the test user.
+_FAKE_AUTHZ_TOKEN = {
+ "accessToken": "test.eyJ1c2VybmFtZSI6ImFsaWNlIn0.sig",
+ "gatewayID": "test-gateway",
+ "userName": "alice",
+}
+
+
+class LaunchViewTest(TestCase):
+ def setUp(self):
+ # Disconnect portal signal handlers that require request.authz_token,
+ # which is not present on plain Django test requests.
+ user_logged_in.disconnect(dispatch_uid="auth_initialize_user_profile")
+ user_logged_in.disconnect(dispatch_uid="auth_project_auto_provisioning")
+
+ User = get_user_model()
+ self.user = User.objects.create_user(username="alice", password="pw")
+ self.client.force_login(self.user)
+
+ # authz_token_middleware calls get_authz_token and logs out the user if
+ # it returns None (because there's no real Keycloak session in tests).
+ # Patch it to return a fake token so the test user stays logged in.
+ self._authz_patcher = patch(
+ "django_airavata.apps.auth.middleware.utils.get_authz_token",
+ return_value=_FAKE_AUTHZ_TOKEN,
+ )
+ self._authz_patcher.start()
+
+ # {% vite_asset %} requires a built manifest entry for js/entry-launch.ts,
+ # which won't exist until Task 9 builds it. Patch the tag to return an
+ # empty string so template rendering doesn't error out in unit tests.
+ self._vite_patcher = patch(
+ "django_vite.templatetags.django_vite.DjangoViteAssetLoader.instance",
+ return_value=_FakeViteLoader(),
+ )
+ self._vite_patcher.start()
+
+ def tearDown(self):
+ self._authz_patcher.stop()
+ self._vite_patcher.stop()
+
+ # Re-connect the signal handlers so other test suites are not affected.
+ from django_airavata.apps.auth.signals import initialize_user_profile, provision_user_projects
+
+ user_logged_in.connect(
+ initialize_user_profile,
+ dispatch_uid="auth_initialize_user_profile",
+ )
+ user_logged_in.connect(
+ provision_user_projects,
+ dispatch_uid="auth_project_auto_provisioning",
+ )
+
+ def test_launch_renders_for_authenticated_user(self):
+ resp = self.client.get("/workspace/launch")
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, 'id="launch-app"')
+
+ def test_old_create_experiment_url_redirects_to_launch(self):
+ resp = self.client.get("/workspace/applications/anything/create_experiment", follow=False)
+ self.assertEqual(resp.status_code, 301)
+ self.assertEqual(resp["Location"], "/workspace/launch")
+
+ def test_old_applications_url_redirects_to_launch(self):
+ resp = self.client.get("/workspace/applications", follow=False)
+ self.assertEqual(resp.status_code, 301)
+ self.assertEqual(resp["Location"], "/workspace/launch")
+
+ def test_launch_requires_auth(self):
+ self.client.logout()
+ self._authz_patcher.stop()
+ self._authz_patcher = patch(
+ "django_airavata.apps.auth.middleware.utils.get_authz_token",
+ return_value=None,
+ )
+ self._authz_patcher.start()
+ resp = self.client.get("/workspace/launch", follow=False)
+ # Login redirect — exact path depends on AUTH_LOGIN_URL but we check redirect or 401
+ self.assertIn(resp.status_code, (302, 401))
+
+
+class _FakeViteLoader:
+ """Minimal stub for DjangoViteAssetLoader used in tests only."""
+
+ def generate_vite_asset(self, path, app=None, **kwargs):
+ return ""
+
+ def generate_vite_asset_url(self, path, app=None, **kwargs):
+ return ""
diff --git a/airavata-django-portal/django_airavata/apps/workspace/urls.py b/airavata-django-portal/django_airavata/apps/workspace/urls.py
index a4669f115..d493334b5 100644
--- a/airavata-django-portal/django_airavata/apps/workspace/urls.py
+++ b/airavata-django-portal/django_airavata/apps/workspace/urls.py
@@ -39,19 +39,21 @@ urlpatterns = [
),
# Project edit
re_path(r"^projects/(?P<project_id>[^/]+)/edit$", views.edit_project, name="edit_project"),
+ # Generic experiment launcher (Task 5)
+ re_path(r"^launch$", views.launch, name="launch"),
# Applications (gateway-wide)
re_path(r"^applications/new$", views.new_application, name="new_application"),
re_path(
r"^applications/(?P<app_module_id>[^/]+)/create_experiment$",
- views.create_experiment,
- name="create_experiment",
+ RedirectView.as_view(url="/workspace/launch", permanent=True),
+ name="create_experiment_redirect",
),
re_path(
r"^applications/(?P<app_module_id>[^/]+)/$",
- views.edit_application,
- name="edit_application",
+ RedirectView.as_view(url="/workspace/launch", permanent=True),
+ name="application_editor_redirect",
),
- re_path(r"^applications$", views.applications, name="applications"),
+ re_path(r"^applications$", RedirectView.as_view(url="/workspace/launch", permanent=True), name="applications_redirect"),
# Resources — storage
re_path(
r"^storage/(?P<storage_resource_id>[^/]+)/tree(?:/(?P<path>.*))?$",
diff --git a/airavata-django-portal/django_airavata/apps/workspace/views.py b/airavata-django-portal/django_airavata/apps/workspace/views.py
index a5e945602..34258b59c 100644
--- a/airavata-django-portal/django_airavata/apps/workspace/views.py
+++ b/airavata-django-portal/django_airavata/apps/workspace/views.py
@@ -451,6 +451,13 @@ def datasets_list(request):
)
+@login_required
+def launch(request):
+ return render(request, "django_airavata_workspace/launch.html", {
+ "feature_flag": getattr(settings, "FEATURE_GENERIC_LAUNCHER", False),
+ })
+
+
@login_required
def experiments_list(request, project_id):
request.active_nav_item = "projects"
diff --git a/airavata-django-portal/django_airavata/settings.py b/airavata-django-portal/django_airavata/settings.py
index 493b1875f..0a1b99268 100644
--- a/airavata-django-portal/django_airavata/settings.py
+++ b/airavata-django-portal/django_airavata/settings.py
@@ -253,7 +253,7 @@ WAGTAILIMAGES_JPEG_QUALITY = 100
DATA_UPLOAD_MAX_NUMBER_FIELDS = 10000
LOGIN_URL = "django_airavata_auth:login"
-LOGIN_REDIRECT_URL = "django_airavata_workspace:applications"
+LOGIN_REDIRECT_URL = "django_airavata_workspace:launch"
LOGOUT_REDIRECT_URL = "/"
AUTHENTICATION_OPTIONS = {
(airavata-portals) 22/34: feat(launcher): RuntimeInputs (profile-driven cr/partition/walltime)
Posted by ya...@apache.org.
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 67680b5da7ea056c9f1deb8abe8e9f692a4470ed
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 22:53:08 2026 -0400
feat(launcher): RuntimeInputs (profile-driven cr/partition/walltime)
---
.../js/components/launch/runtime/RuntimeInputs.vue | 80 ++++++++++++++++++++++
.../launch/runtime/RuntimeInputs.spec.ts | 43 ++++++++++++
2 files changed, 123 insertions(+)
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/runtime/RuntimeInputs.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/runtime/RuntimeInputs.vue
new file mode 100644
index 000000000..df1880417
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/runtime/RuntimeInputs.vue
@@ -0,0 +1,80 @@
+<template>
+ <div class="row g-2">
+ <div class="col-md-3">
+ <label class="form-label small">Compute resource</label>
+ <select
+ data-test="cr"
+ class="form-select form-select-sm"
+ :value="modelValue.compute_resource_id ?? ''"
+ @change="onCR(($event.target as HTMLSelectElement).value)"
+ >
+ <option value="" disabled>Choose…</option>
+ <option v-for="cr in profile.compute_resources" :key="cr.compute_resource_id" :value="cr.compute_resource_id">
+ {{ cr.name }}
+ </option>
+ </select>
+ </div>
+ <div class="col-md-3">
+ <label class="form-label small">Partition</label>
+ <select
+ data-test="partition"
+ class="form-select form-select-sm"
+ :disabled="!partitions.length"
+ :value="modelValue.partition ?? ''"
+ @change="emit('update:modelValue', { ...modelValue, partition: ($event.target as HTMLSelectElement).value })"
+ >
+ <option value="" disabled>Choose…</option>
+ <option v-for="p in partitions" :key="p.name" :value="p.name">{{ p.name }}</option>
+ </select>
+ </div>
+ <div class="col-md-2">
+ <label class="form-label small">Walltime</label>
+ <input
+ data-test="walltime"
+ class="form-control form-control-sm"
+ :value="modelValue.walltime"
+ @input="emit('update:modelValue', { ...modelValue, walltime: ($event.target as HTMLInputElement).value })"
+ />
+ </div>
+ <div class="col-md-2">
+ <label class="form-label small">Nodes</label>
+ <input
+ type="number" min="1"
+ data-test="nodes"
+ class="form-control form-control-sm"
+ :value="modelValue.nodes"
+ @input="emit('update:modelValue', { ...modelValue, nodes: Number(($event.target as HTMLInputElement).value) })"
+ />
+ </div>
+ <div class="col-md-2">
+ <label class="form-label small">CPUs / node</label>
+ <input
+ type="number" min="1"
+ data-test="cpus"
+ class="form-control form-control-sm"
+ :value="modelValue.cpus_per_node"
+ @input="emit('update:modelValue', { ...modelValue, cpus_per_node: Number(($event.target as HTMLInputElement).value) })"
+ />
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from "vue";
+import type { ResourceProfile, RuntimeChoice } from "django-airavata-common-ui/js/stores/launch-types";
+
+const props = defineProps<{ profile: ResourceProfile; modelValue: RuntimeChoice }>();
+const emit = defineEmits<{ "update:modelValue": [v: RuntimeChoice] }>();
+
+const partitions = computed(() => {
+ if (!props.modelValue.compute_resource_id) return [];
+ const cr = props.profile.compute_resources.find(
+ (c) => c.compute_resource_id === props.modelValue.compute_resource_id,
+ );
+ return cr?.partitions ?? [];
+});
+
+function onCR(id: string) {
+ emit("update:modelValue", { ...props.modelValue, compute_resource_id: id, partition: null });
+}
+</script>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/runtime/RuntimeInputs.spec.ts b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/runtime/RuntimeInputs.spec.ts
new file mode 100644
index 000000000..e38534a4f
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/runtime/RuntimeInputs.spec.ts
@@ -0,0 +1,43 @@
+import { mount } from "@vue/test-utils";
+import { describe, expect, it } from "vitest";
+import RuntimeInputs from "../../../../../js/components/launch/runtime/RuntimeInputs.vue";
+
+const PROFILE = {
+ project_id: "p1", allocation_id: "NSF-1",
+ compute_resources: [{
+ compute_resource_id: "bridges-2", name: "Bridges-2",
+ mapped_storage: { storage_id: "scratch", scratch_path: "/scratch/p1" },
+ partitions: [{ name: "RM", max_walltime: "48:00:00", max_nodes: 64, cpus_per_node: 128 }],
+ }],
+};
+
+describe("RuntimeInputs", () => {
+ it("populates compute resource dropdown from profile", () => {
+ const w = mount(RuntimeInputs, {
+ props: { profile: PROFILE, modelValue: { compute_resource_id: null, partition: null,
+ walltime: "01:00:00", nodes: 1, cpus_per_node: 1 } },
+ });
+ expect(w.find("select[data-test='cr']").findAll("option").map((o) => o.text())).toContain("Bridges-2");
+ });
+
+ it("emits update:modelValue when the resource changes", async () => {
+ const w = mount(RuntimeInputs, {
+ props: { profile: PROFILE, modelValue: { compute_resource_id: null, partition: null,
+ walltime: "01:00:00", nodes: 1, cpus_per_node: 1 } },
+ });
+ await w.find("select[data-test='cr']").setValue("bridges-2");
+ expect(w.emitted("update:modelValue")?.at(-1)).toEqual([
+ expect.objectContaining({ compute_resource_id: "bridges-2", partition: null }),
+ ]);
+ });
+
+ it("limits partition options to the chosen resource", async () => {
+ const w = mount(RuntimeInputs, {
+ props: { profile: PROFILE, modelValue: { compute_resource_id: "bridges-2", partition: null,
+ walltime: "01:00:00", nodes: 1, cpus_per_node: 1 } },
+ });
+ const opts = w.find("select[data-test='partition']").findAll("option").map((o) => o.text());
+ expect(opts).toContain("RM");
+ expect(opts).not.toContain("nonexistent");
+ });
+});
(airavata-portals) 21/34: feat(launcher): Tab1ApplicationInputs assembly
Posted by ya...@apache.org.
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 5be9e50237c4075dccd770dcb26e7a24c9f86d67
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 22:51:17 2026 -0400
feat(launcher): Tab1ApplicationInputs assembly
Replaces the empty stub with the full Tab1 component: AppPicker →
InterfacePicker → InputList → OutputList with progressive disclosure.
Adds 2 tests; also fixes LaunchContainer mock to stub the new service
methods so no unhandled rejections surface from the child component.
---
.../js/components/launch/Tab1ApplicationInputs.vue | 46 +++++++++++++++++++++-
.../unit/components/launch/LaunchContainer.spec.ts | 5 ++-
.../launch/Tab1ApplicationInputs.spec.ts | 43 ++++++++++++++++++++
3 files changed, 91 insertions(+), 3 deletions(-)
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab1ApplicationInputs.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab1ApplicationInputs.vue
index 25a445eed..dc2e24282 100644
--- a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab1ApplicationInputs.vue
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab1ApplicationInputs.vue
@@ -1,2 +1,44 @@
-<template><div /></template>
-<script setup lang="ts"></script>
+<template>
+ <div>
+ <section class="mb-3">
+ <div class="text-uppercase text-primary small fw-bold mb-1">Application</div>
+ <AppPicker :applications="apps" />
+ </section>
+ <section v-if="store.pickedApp" class="mb-3">
+ <div class="text-uppercase text-primary small fw-bold mb-1">Interface</div>
+ <InterfacePicker />
+ </section>
+ <section v-if="store.pickedInterface" data-test="inputs-section" class="mb-3">
+ <div class="text-uppercase text-primary small fw-bold mb-1">Inputs</div>
+ <InputList :storages="store.storages" />
+ </section>
+ <section v-if="store.pickedInterface && fileOutputCount > 0" class="mb-3">
+ <div class="text-uppercase text-primary small fw-bold mb-1">Outputs</div>
+ <OutputList :storages="store.storages" />
+ </section>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, ref } from "vue";
+import { launcherService } from "django-airavata-common-ui/js/services/launcherService";
+import { useLaunchStore } from "django-airavata-common-ui/js/stores/launch";
+import type { Application } from "django-airavata-common-ui/js/stores/launch-types";
+import AppPicker from "./AppPicker.vue";
+import InterfacePicker from "./InterfacePicker.vue";
+import InputList from "./InputList.vue";
+import OutputList from "./OutputList.vue";
+
+const store = useLaunchStore();
+const apps = ref<Application[]>([]);
+
+onMounted(async () => {
+ const [a, s] = await Promise.all([launcherService.listApplications(), launcherService.listUserStorages()]);
+ apps.value = a?.results ?? [];
+ store.setStorages(s?.results ?? []);
+});
+
+const fileOutputCount = computed(() =>
+ (store.pickedInterface?.outputs ?? []).filter((o) => o.type === "file" || o.type === "dir").length,
+);
+</script>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/LaunchContainer.spec.ts b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/LaunchContainer.spec.ts
index 44d8f5e46..22cced971 100644
--- a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/LaunchContainer.spec.ts
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/LaunchContainer.spec.ts
@@ -5,10 +5,13 @@ import LaunchContainer from "../../../../js/containers/LaunchContainer.vue";
vi.mock("django-airavata-common-ui/js/services/launcherService", () => ({
launcherService: {
- listProjects: vi.fn().mockResolvedValue({ results: [] }),
+ listProjects: vi.fn(),
+ listApplications: vi.fn(),
+ listUserStorages: vi.fn(),
},
}));
+
describe("LaunchContainer", () => {
beforeEach(() => setActivePinia(createPinia()));
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/Tab1ApplicationInputs.spec.ts b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/Tab1ApplicationInputs.spec.ts
new file mode 100644
index 000000000..b90b43ecb
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/Tab1ApplicationInputs.spec.ts
@@ -0,0 +1,43 @@
+import { mount, flushPromises } from "@vue/test-utils";
+import { createPinia, setActivePinia } from "pinia";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import Tab1ApplicationInputs from "../../../../js/components/launch/Tab1ApplicationInputs.vue";
+import { useLaunchStore } from "django-airavata-common-ui/js/stores/launch";
+
+vi.mock("django-airavata-common-ui/js/services/launcherService", () => ({
+ launcherService: {
+ listApplications: vi.fn(),
+ listUserStorages: vi.fn(),
+ },
+}));
+
+describe("Tab1ApplicationInputs", () => {
+ beforeEach(async () => {
+ setActivePinia(createPinia());
+ const { launcherService } = await import(
+ "django-airavata-common-ui/js/services/launcherService"
+ );
+ vi.mocked(launcherService.listApplications).mockResolvedValue({
+ results: [{
+ app_id: "namd", name: "NAMD", category: "MD",
+ content: { kind: "github", url: "g" }, interfaces: [],
+ }],
+ });
+ vi.mocked(launcherService.listUserStorages).mockResolvedValue({
+ results: [{ storage_id: "my-home", name: "My Home", is_primary: true }],
+ });
+ });
+
+ it("loads applications + storages on mount", async () => {
+ const w = mount(Tab1ApplicationInputs);
+ await flushPromises();
+ expect(w.findAll("[data-test='app-tile']")).toHaveLength(1);
+ expect(useLaunchStore().storages).toHaveLength(1);
+ });
+
+ it("does not render the inputs section before an app+interface are picked", async () => {
+ const w = mount(Tab1ApplicationInputs);
+ await flushPromises();
+ expect(w.find("[data-test='inputs-section']").exists()).toBe(false);
+ });
+});
(airavata-portals) 11/34: fix(launcher): active_nav_item + workspace base template + third redirect test
Posted by ya...@apache.org.
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 d557d002b42e688df7aa198c1e50716819baa8b3
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 22:05:35 2026 -0400
fix(launcher): active_nav_item + workspace base template + third redirect test
---
.../apps/workspace/templates/django_airavata_workspace/launch.html | 2 +-
.../django_airavata/apps/workspace/tests/test_launch_view.py | 5 +++++
airavata-django-portal/django_airavata/apps/workspace/views.py | 1 +
3 files changed, 7 insertions(+), 1 deletion(-)
diff --git a/airavata-django-portal/django_airavata/apps/workspace/templates/django_airavata_workspace/launch.html b/airavata-django-portal/django_airavata/apps/workspace/templates/django_airavata_workspace/launch.html
index 5048ab712..9c9fb7d4f 100644
--- a/airavata-django-portal/django_airavata/apps/workspace/templates/django_airavata_workspace/launch.html
+++ b/airavata-django-portal/django_airavata/apps/workspace/templates/django_airavata_workspace/launch.html
@@ -1,4 +1,4 @@
-{% extends "base.html" %}
+{% extends "./base.html" %}
{% load django_vite %}
{% block content %}
diff --git a/airavata-django-portal/django_airavata/apps/workspace/tests/test_launch_view.py b/airavata-django-portal/django_airavata/apps/workspace/tests/test_launch_view.py
index eb7065622..812aaa75d 100644
--- a/airavata-django-portal/django_airavata/apps/workspace/tests/test_launch_view.py
+++ b/airavata-django-portal/django_airavata/apps/workspace/tests/test_launch_view.py
@@ -73,6 +73,11 @@ class LaunchViewTest(TestCase):
self.assertEqual(resp.status_code, 301)
self.assertEqual(resp["Location"], "/workspace/launch")
+ def test_old_application_editor_url_redirects_to_launch(self):
+ resp = self.client.get("/workspace/applications/some-module-id/", follow=False)
+ self.assertEqual(resp.status_code, 301)
+ self.assertEqual(resp["Location"], "/workspace/launch")
+
def test_launch_requires_auth(self):
self.client.logout()
self._authz_patcher.stop()
diff --git a/airavata-django-portal/django_airavata/apps/workspace/views.py b/airavata-django-portal/django_airavata/apps/workspace/views.py
index 34258b59c..cfdf3010d 100644
--- a/airavata-django-portal/django_airavata/apps/workspace/views.py
+++ b/airavata-django-portal/django_airavata/apps/workspace/views.py
@@ -453,6 +453,7 @@ def datasets_list(request):
@login_required
def launch(request):
+ request.active_nav_item = "launch"
return render(request, "django_airavata_workspace/launch.html", {
"feature_flag": getattr(settings, "FEATURE_GENERIC_LAUNCHER", False),
})
(airavata-portals) 19/34: feat(launcher): ScalarInputRow + FileInputRow
Posted by ya...@apache.org.
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 1d1e486f72328b84a5896431e77b8cf3435dc58f
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 22:41:57 2026 -0400
feat(launcher): ScalarInputRow + FileInputRow
---
.../js/components/launch/FileInputRow.vue | 43 +++++++++++++++++
.../js/components/launch/ScalarInputRow.vue | 56 ++++++++++++++++++++++
.../unit/components/launch/FileInputRow.spec.ts | 45 +++++++++++++++++
.../unit/components/launch/ScalarInputRow.spec.ts | 31 ++++++++++++
4 files changed, 175 insertions(+)
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/FileInputRow.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/FileInputRow.vue
new file mode 100644
index 000000000..df78b521d
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/FileInputRow.vue
@@ -0,0 +1,43 @@
+<template>
+ <div class="d-flex align-items-center gap-2 border rounded p-2 mb-1">
+ <div class="flex-shrink-0" style="width: 140px;">
+ <code>{{ descriptor.name }}</code>
+ <span class="text-muted small ms-1">{{ descriptor.type }}</span>
+ </div>
+ <select
+ class="form-select form-select-sm"
+ style="width: 160px;"
+ :value="modelValue?.storage_id ?? ''"
+ @change="onStorage(($event.target as HTMLSelectElement).value)"
+ >
+ <option value="" disabled>Storage…</option>
+ <option v-for="s in storages" :key="s.storage_id" :value="s.storage_id">{{ s.name }}</option>
+ </select>
+ <input
+ class="form-control form-control-sm flex-grow-1"
+ :data-test="`file-path-${descriptor.name}`"
+ :value="modelValue?.path ?? ''"
+ placeholder="/path/to/data"
+ @input="onPath(($event.target as HTMLInputElement).value)"
+ />
+ <span data-test="io-badge" class="badge bg-warning text-dark">stage-in</span>
+ </div>
+</template>
+
+<script setup lang="ts">
+import type { IODescriptor, StorageRef, UserStorage } from "django-airavata-common-ui/js/stores/launch-types";
+
+const props = defineProps<{
+ descriptor: IODescriptor;
+ modelValue: StorageRef | null;
+ storages: UserStorage[];
+}>();
+const emit = defineEmits<{ "update:modelValue": [v: StorageRef] }>();
+
+function onStorage(id: string) {
+ emit("update:modelValue", { storage_id: id, path: props.modelValue?.path ?? "" });
+}
+function onPath(p: string) {
+ emit("update:modelValue", { storage_id: props.modelValue?.storage_id ?? "", path: p });
+}
+</script>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/ScalarInputRow.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/ScalarInputRow.vue
new file mode 100644
index 000000000..6b33e2f90
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/ScalarInputRow.vue
@@ -0,0 +1,56 @@
+<template>
+ <div class="row mb-1 align-items-center">
+ <label class="col-md-3 col-form-label" :for="`scalar-${descriptor.name}`">
+ <code>{{ descriptor.name }}</code>
+ <span class="text-muted small ms-1">{{ descriptor.type }}</span>
+ </label>
+ <div class="col-md-9">
+ <select
+ v-if="descriptor.type === 'enum'"
+ :id="`scalar-${descriptor.name}`"
+ :data-test="`scalar-${descriptor.name}`"
+ class="form-select"
+ :value="modelValue ?? ''"
+ @change="emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
+ >
+ <option value="" disabled>Choose…</option>
+ <option v-for="o in descriptor.options ?? []" :key="o" :value="o">{{ o }}</option>
+ </select>
+ <input
+ v-else
+ :id="`scalar-${descriptor.name}`"
+ :data-test="`scalar-${descriptor.name}`"
+ class="form-control"
+ :type="inputType"
+ :value="modelValue ?? ''"
+ @input="onInput(($event.target as HTMLInputElement).value)"
+ />
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from "vue";
+import type { IODescriptor, ScalarValue } from "django-airavata-common-ui/js/stores/launch-types";
+
+const props = defineProps<{ descriptor: IODescriptor; modelValue: ScalarValue | null }>();
+const emit = defineEmits<{ "update:modelValue": [v: ScalarValue | null] }>();
+
+const inputType = computed(() => {
+ switch (props.descriptor.type) {
+ case "int":
+ case "float":
+ return "number";
+ case "bool":
+ return "checkbox";
+ default:
+ return "text";
+ }
+});
+
+function onInput(raw: string) {
+ if (props.descriptor.type === "int") emit("update:modelValue", raw === "" ? null : Number.parseInt(raw, 10));
+ else if (props.descriptor.type === "float") emit("update:modelValue", raw === "" ? null : Number.parseFloat(raw));
+ else emit("update:modelValue", raw);
+}
+</script>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/FileInputRow.spec.ts b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/FileInputRow.spec.ts
new file mode 100644
index 000000000..c16ab713c
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/FileInputRow.spec.ts
@@ -0,0 +1,45 @@
+import { mount } from "@vue/test-utils";
+import { describe, expect, it } from "vitest";
+import FileInputRow from "../../../../js/components/launch/FileInputRow.vue";
+
+const STORAGES = [
+ { storage_id: "my-home", name: "My Home", is_primary: true },
+ { storage_id: "scratch", name: "Scratch", is_primary: false },
+];
+
+describe("FileInputRow", () => {
+ it("emits update:modelValue when storage changes", async () => {
+ const w = mount(FileInputRow, {
+ props: {
+ descriptor: { name: "sim_dir", type: "dir", required: true },
+ modelValue: { storage_id: "my-home", path: "" },
+ storages: STORAGES,
+ },
+ });
+ await w.find("select").setValue("scratch");
+ expect(w.emitted("update:modelValue")?.at(-1)).toEqual([{ storage_id: "scratch", path: "" }]);
+ });
+
+ it("emits update:modelValue when path changes", async () => {
+ const w = mount(FileInputRow, {
+ props: {
+ descriptor: { name: "sim_dir", type: "dir", required: true },
+ modelValue: { storage_id: "my-home", path: "" },
+ storages: STORAGES,
+ },
+ });
+ await w.find("input[data-test='file-path-sim_dir']").setValue("/home/x/sim");
+ expect(w.emitted("update:modelValue")?.at(-1)).toEqual([{ storage_id: "my-home", path: "/home/x/sim" }]);
+ });
+
+ it("renders a stage-in badge", () => {
+ const w = mount(FileInputRow, {
+ props: {
+ descriptor: { name: "sim_dir", type: "dir", required: true },
+ modelValue: null,
+ storages: STORAGES,
+ },
+ });
+ expect(w.find("[data-test='io-badge']").text()).toMatch(/stage-in/i);
+ });
+});
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/ScalarInputRow.spec.ts b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/ScalarInputRow.spec.ts
new file mode 100644
index 000000000..2d5c92bd6
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/ScalarInputRow.spec.ts
@@ -0,0 +1,31 @@
+import { mount } from "@vue/test-utils";
+import { describe, expect, it } from "vitest";
+import ScalarInputRow from "../../../../js/components/launch/ScalarInputRow.vue";
+
+describe("ScalarInputRow", () => {
+ it("renders a number input for type=int", async () => {
+ const w = mount(ScalarInputRow, {
+ props: { descriptor: { name: "steps", type: "int", required: true }, modelValue: null },
+ });
+ const input = w.find("input[data-test='scalar-steps']");
+ expect(input.attributes("type")).toBe("number");
+ });
+
+ it("emits update:modelValue on input", async () => {
+ const w = mount(ScalarInputRow, {
+ props: { descriptor: { name: "steps", type: "int", required: true }, modelValue: null },
+ });
+ await w.find("input").setValue("42");
+ expect(w.emitted("update:modelValue")?.at(-1)).toEqual([42]);
+ });
+
+ it("renders a select for enum descriptors", () => {
+ const w = mount(ScalarInputRow, {
+ props: {
+ descriptor: { name: "mode", type: "enum", required: true, options: ["a", "b"] },
+ modelValue: null,
+ },
+ });
+ expect(w.find("select").exists()).toBe(true);
+ });
+});
(airavata-portals) 16/34: feat(launcher): LaunchContainer + entry-launch.ts (tabs scaffolding)
Posted by ya...@apache.org.
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 fe5298a0e0e0dbb351c7fd5857c610c8fa32a0b2
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 22:33:03 2026 -0400
feat(launcher): LaunchContainer + entry-launch.ts (tabs scaffolding)
- Add LaunchContainer.vue orchestrating ExperimentMetaHeader, WizardTabs,
and the three tab panels; tab state mirrored to ?tab= query param
- Add entry-launch.ts Vite entry (createApp + Pinia) mounting into #launch-app
- Add Tab1ApplicationInputs, Tab2Runtime, Tab3ReviewLaunch empty stubs
- Register entry-launch.ts in vite.config.js explicit entries map
- Add LaunchContainer.spec.ts (3 tests: active tab, meta header, one panel shown)
- Use v-if panels (not :hidden) so the test's attribute filter works correctly
- Defensive r?.results ?? [] guards against restoreMocks clearing mock impls
---
.../js/components/launch/Tab1ApplicationInputs.vue | 2 +
.../js/components/launch/Tab2Runtime.vue | 2 +
.../js/components/launch/Tab3ReviewLaunch.vue | 2 +
.../js/containers/LaunchContainer.vue | 44 ++++++++++++++++++++++
.../django_airavata_workspace/js/entry-launch.ts | 10 +++++
.../unit/components/launch/LaunchContainer.spec.ts | 33 ++++++++++++++++
.../django_airavata/apps/workspace/vite.config.js | 1 +
7 files changed, 94 insertions(+)
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab1ApplicationInputs.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab1ApplicationInputs.vue
new file mode 100644
index 000000000..25a445eed
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab1ApplicationInputs.vue
@@ -0,0 +1,2 @@
+<template><div /></template>
+<script setup lang="ts"></script>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab2Runtime.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab2Runtime.vue
new file mode 100644
index 000000000..25a445eed
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab2Runtime.vue
@@ -0,0 +1,2 @@
+<template><div /></template>
+<script setup lang="ts"></script>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab3ReviewLaunch.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab3ReviewLaunch.vue
new file mode 100644
index 000000000..25a445eed
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/Tab3ReviewLaunch.vue
@@ -0,0 +1,2 @@
+<template><div /></template>
+<script setup lang="ts"></script>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/LaunchContainer.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/LaunchContainer.vue
new file mode 100644
index 000000000..d9a1d0809
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/LaunchContainer.vue
@@ -0,0 +1,44 @@
+<template>
+ <div class="container-fluid">
+ <ExperimentMetaHeader :projects="projects" />
+ <WizardTabs :active="active" @update:active="onChangeTab" />
+ <div data-test="active-tab" :data-active="active" />
+ <section v-if="active === 1" role="tabpanel">
+ <Tab1ApplicationInputs />
+ </section>
+ <section v-if="active === 2" role="tabpanel">
+ <Tab2Runtime />
+ </section>
+ <section v-if="active === 3" role="tabpanel">
+ <Tab3ReviewLaunch />
+ </section>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref } from "vue";
+import { launcherService } from "django-airavata-common-ui/js/services/launcherService";
+import ExperimentMetaHeader from "../components/launch/ExperimentMetaHeader.vue";
+import WizardTabs from "../components/launch/WizardTabs.vue";
+import Tab1ApplicationInputs from "../components/launch/Tab1ApplicationInputs.vue";
+import Tab2Runtime from "../components/launch/Tab2Runtime.vue";
+import Tab3ReviewLaunch from "../components/launch/Tab3ReviewLaunch.vue";
+
+const active = ref<1 | 2 | 3>(1);
+const projects = ref<Array<{ project_id: string; name: string }>>([]);
+
+function onChangeTab(n: 1 | 2 | 3) {
+ active.value = n;
+ const url = new URL(window.location.href);
+ url.searchParams.set("tab", String(n));
+ window.history.replaceState({}, "", url);
+}
+
+onMounted(async () => {
+ const url = new URL(window.location.href);
+ const t = Number(url.searchParams.get("tab"));
+ if (t === 1 || t === 2 || t === 3) active.value = t as 1 | 2 | 3;
+ const r = await launcherService.listProjects();
+ projects.value = r?.results ?? [];
+});
+</script>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/entry-launch.ts b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/entry-launch.ts
new file mode 100644
index 000000000..e5350ed07
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/entry-launch.ts
@@ -0,0 +1,10 @@
+import { createApp } from "vue";
+import { createPinia } from "pinia";
+import LaunchContainer from "./containers/LaunchContainer.vue";
+
+const root = document.getElementById("launch-app");
+if (root) {
+ const app = createApp(LaunchContainer);
+ app.use(createPinia());
+ app.mount(root);
+}
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/LaunchContainer.spec.ts b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/LaunchContainer.spec.ts
new file mode 100644
index 000000000..44d8f5e46
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/LaunchContainer.spec.ts
@@ -0,0 +1,33 @@
+import { mount, flushPromises } from "@vue/test-utils";
+import { createPinia, setActivePinia } from "pinia";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import LaunchContainer from "../../../../js/containers/LaunchContainer.vue";
+
+vi.mock("django-airavata-common-ui/js/services/launcherService", () => ({
+ launcherService: {
+ listProjects: vi.fn().mockResolvedValue({ results: [] }),
+ },
+}));
+
+describe("LaunchContainer", () => {
+ beforeEach(() => setActivePinia(createPinia()));
+
+ it("starts on tab 1", async () => {
+ const w = mount(LaunchContainer);
+ await flushPromises();
+ expect(w.find("[data-test='active-tab']").attributes("data-active")).toBe("1");
+ });
+
+ it("renders the meta header", async () => {
+ const w = mount(LaunchContainer);
+ await flushPromises();
+ expect(w.find("input[data-test='exp-name']").exists()).toBe(true);
+ });
+
+ it("only one tab panel is shown at a time", async () => {
+ const w = mount(LaunchContainer);
+ await flushPromises();
+ const visiblePanels = w.findAll("[role='tabpanel']").filter((p) => !p.attributes("hidden"));
+ expect(visiblePanels).toHaveLength(1);
+ });
+});
diff --git a/airavata-django-portal/django_airavata/apps/workspace/vite.config.js b/airavata-django-portal/django_airavata/apps/workspace/vite.config.js
index d0ba14c25..a73d02e6a 100644
--- a/airavata-django-portal/django_airavata/apps/workspace/vite.config.js
+++ b/airavata-django-portal/django_airavata/apps/workspace/vite.config.js
@@ -25,5 +25,6 @@ export default defineAppConfig({
"compute-detail": resolve(srcDir, "entry-compute-detail.js"),
"project-overview": resolve(srcDir, "entry-project-overview.js"),
"application-editor": resolve(srcDir, "entry-application-editor.js"),
+ launch: resolve(srcDir, "entry-launch.ts"),
},
});
(airavata-portals) 14/34: feat(launcher): ExperimentMetaHeader (name/project/description)
Posted by ya...@apache.org.
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 bbff3c557460f4ca7de13a1e6613c8b687797744
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 22:19:02 2026 -0400
feat(launcher): ExperimentMetaHeader (name/project/description)
---
.../js/components/launch/ExperimentMetaHeader.vue | 55 ++++++++++++++++++++++
.../components/launch/ExperimentMetaHeader.spec.ts | 31 ++++++++++++
2 files changed, 86 insertions(+)
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/ExperimentMetaHeader.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/ExperimentMetaHeader.vue
new file mode 100644
index 000000000..52fddd29b
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/ExperimentMetaHeader.vue
@@ -0,0 +1,55 @@
+<template>
+ <div class="row g-2 mb-2">
+ <div class="col-md-6">
+ <input
+ data-test="exp-name"
+ class="form-control"
+ :value="store.draft.name"
+ placeholder="Experiment name"
+ maxlength="256"
+ @input="onName(($event.target as HTMLInputElement).value)"
+ />
+ </div>
+ <div class="col-md-6">
+ <select
+ data-test="exp-project"
+ class="form-select"
+ :value="store.draft.project_id ?? ''"
+ @change="onProject(($event.target as HTMLSelectElement).value)"
+ >
+ <option value="" disabled>Select a project</option>
+ <option v-for="p in projects" :key="p.project_id" :value="p.project_id">
+ {{ p.name }}
+ </option>
+ </select>
+ </div>
+ <div class="col-12">
+ <textarea
+ data-test="exp-description"
+ class="form-control"
+ rows="2"
+ :value="store.draft.description"
+ placeholder="Description (optional)"
+ @input="onDescription(($event.target as HTMLTextAreaElement).value)"
+ />
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { useLaunchStore } from "django-airavata-common-ui/js/stores/launch";
+
+defineProps<{ projects: Array<{ project_id: string; name: string }> }>();
+
+const store = useLaunchStore();
+
+function onName(v: string) {
+ store.setMeta({ name: v, project_id: store.draft.project_id, description: store.draft.description });
+}
+function onProject(v: string) {
+ store.setMeta({ name: store.draft.name, project_id: v || null, description: store.draft.description });
+}
+function onDescription(v: string) {
+ store.setMeta({ name: store.draft.name, project_id: store.draft.project_id, description: v });
+}
+</script>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/ExperimentMetaHeader.spec.ts b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/ExperimentMetaHeader.spec.ts
new file mode 100644
index 000000000..484086527
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/ExperimentMetaHeader.spec.ts
@@ -0,0 +1,31 @@
+import { mount } from "@vue/test-utils";
+import { setActivePinia, createPinia } from "pinia";
+import { beforeEach, describe, expect, it } from "vitest";
+import ExperimentMetaHeader from "../../../../js/components/launch/ExperimentMetaHeader.vue";
+import { useLaunchStore } from "django-airavata-common-ui/js/stores/launch";
+
+const PROJECTS = [
+ { project_id: "p1", name: "my-lab-2026" },
+ { project_id: "p2", name: "shared" },
+];
+
+describe("ExperimentMetaHeader", () => {
+ beforeEach(() => setActivePinia(createPinia()));
+
+ it("binds name input to the store", async () => {
+ const w = mount(ExperimentMetaHeader, { props: { projects: PROJECTS } });
+ await w.find("input[data-test='exp-name']").setValue("my-run");
+ expect(useLaunchStore().draft.name).toBe("my-run");
+ });
+
+ it("binds project dropdown to the store", async () => {
+ const w = mount(ExperimentMetaHeader, { props: { projects: PROJECTS } });
+ await w.find("select[data-test='exp-project']").setValue("p2");
+ expect(useLaunchStore().draft.project_id).toBe("p2");
+ });
+
+ it("renders description as a textarea", () => {
+ const w = mount(ExperimentMetaHeader, { props: { projects: PROJECTS } });
+ expect(w.find("textarea[data-test='exp-description']").exists()).toBe(true);
+ });
+});
(airavata-portals) 28/34: test(launcher): full happy-path integration spec (mocked API)
Posted by ya...@apache.org.
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 ff10e84e0bf8e381973f03474fe8d21b91aad406
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 23:12:09 2026 -0400
test(launcher): full happy-path integration spec (mocked API)
---
.../tests/unit/integration/launch-flow.spec.ts | 110 +++++++++++++++++++++
1 file changed, 110 insertions(+)
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/integration/launch-flow.spec.ts b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/integration/launch-flow.spec.ts
new file mode 100644
index 000000000..e0fc5b4ac
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/integration/launch-flow.spec.ts
@@ -0,0 +1,110 @@
+import { mount, flushPromises } from "@vue/test-utils";
+import { createPinia, setActivePinia } from "pinia";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import LaunchContainer from "../../../js/containers/LaunchContainer.vue";
+
+const APP = {
+ app_id: "namd", name: "NAMD", category: "MD",
+ content: { kind: "github" as const, url: "g" },
+ interfaces: [{
+ name: "run",
+ inputs: [
+ { name: "sim_dir", type: "dir" as const, required: true },
+ { name: "steps", type: "int" as const, required: true },
+ ],
+ outputs: [{ name: "trajectory", type: "file" as const }],
+ }],
+};
+
+const PROFILE = {
+ project_id: "p1", allocation_id: "NSF-1",
+ compute_resources: [{
+ compute_resource_id: "bridges-2", name: "Bridges-2",
+ mapped_storage: { storage_id: "scratch", scratch_path: "/scratch/p1" },
+ partitions: [{ name: "RM", max_walltime: "48:00:00", max_nodes: 64, cpus_per_node: 128 }],
+ }],
+};
+
+vi.mock("django-airavata-common-ui/js/services/launcherService", () => ({
+ launcherService: {
+ listApplications: vi.fn(),
+ listUserStorages: vi.fn(),
+ listProjects: vi.fn(),
+ getProjectResourceProfile: vi.fn(),
+ generatePreview: vi.fn(),
+ launchExperiment: vi.fn(),
+ },
+}));
+
+import { launcherService } from "django-airavata-common-ui/js/services/launcherService";
+
+describe("launch flow integration", () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ localStorage.clear();
+ vi.mocked(launcherService.listApplications).mockResolvedValue({ results: [APP] });
+ vi.mocked(launcherService.listUserStorages).mockResolvedValue({
+ results: [{ storage_id: "my-home", name: "Home", is_primary: true }],
+ });
+ vi.mocked(launcherService.listProjects).mockResolvedValue({
+ results: [{ project_id: "p1", name: "lab" }],
+ });
+ vi.mocked(launcherService.getProjectResourceProfile).mockResolvedValue(PROFILE);
+ vi.mocked(launcherService.generatePreview).mockResolvedValue({
+ invocation_command: "sbatch /tmp/run.sh",
+ script_contents: "#!/bin/bash\necho hi",
+ warnings: [],
+ });
+ vi.mocked(launcherService.launchExperiment).mockResolvedValue({ experiment_id: "exp-7" });
+ Object.defineProperty(window, "location", {
+ value: { href: "http://localhost/", assign: vi.fn() }, writable: true,
+ });
+ vi.spyOn(window.history, "replaceState").mockImplementation(() => {});
+ });
+
+ it("walks tab 1 → tab 2 → tab 3 → launch", async () => {
+ const w = mount(LaunchContainer);
+ await flushPromises();
+
+ // Meta
+ await w.find("input[data-test='exp-name']").setValue("test-run");
+ await w.find("select[data-test='exp-project']").setValue("p1");
+
+ // App + interface
+ await w.find("[data-test='app-tile-namd']").trigger("click");
+ await flushPromises();
+ await w.find("[data-test='iface-card-run']").trigger("click");
+
+ // Inputs + output
+ // sim_dir is a dir input: its storage select is the sibling select immediately
+ // before the file-path input (inside the same flex row div).
+ const simDirInput = w.find("input[data-test='file-path-sim_dir']");
+ // The FileInputRow renders: select (storage) | input (path) — find all selects
+ // and pick the one whose next input sibling has data-test='file-path-sim_dir'
+ const simDirStorageSelect = w.findAll("select").find((s) => {
+ const next = s.element.nextElementSibling;
+ return next instanceof HTMLInputElement && next.dataset.test === "file-path-sim_dir";
+ });
+ if (simDirStorageSelect) await simDirStorageSelect.setValue("my-home");
+ await simDirInput.setValue("/x");
+ await w.find("input[data-test='scalar-steps']").setValue("100");
+ // Output: find the storage select within the trajectory output row
+ const outSelect = w.findAll("select").find((s) => s.element.parentElement?.outerHTML.includes("trajectory"));
+ if (outSelect) await outSelect.setValue("my-home");
+ await w.find("input[data-test='file-out-path-trajectory']").setValue("/y");
+
+ // Tab 2
+ await w.findAll("button[role='tab']")[1].trigger("click");
+ await flushPromises();
+ await w.find("select[data-test='cr']").setValue("bridges-2");
+ await w.find("select[data-test='partition']").setValue("RM");
+
+ // Tab 3
+ await w.findAll("button[role='tab']")[2].trigger("click");
+ await flushPromises();
+ expect(w.text()).toContain("sbatch /tmp/run.sh");
+ await w.find("button[data-test='launch']").trigger("click");
+ await flushPromises();
+ expect(window.location.href).toBe("/workspace/experiments/exp-7");
+ });
+});
(airavata-portals) 07/34: feat(launcher): listing endpoints (apps, profile, storages, projects)
Posted by ya...@apache.org.
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 3bc56cb272009ca920a117cff9956b827b27009d
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 21:36:19 2026 -0400
feat(launcher): listing endpoints (apps, profile, storages, projects)
Adds five GET endpoints under /api/launcher/ as thin proxies over
launcher_client: applications list (with category/search filtering),
application detail, project resource profile, user storages, and
projects list. All require authentication (403 for anonymous requests).
---
.../django_airavata/apps/api/launcher_views.py | 47 ++++++++++++++
.../apps/api/tests/test_launcher_views.py | 74 ++++++++++++++++++++++
.../django_airavata/apps/api/urls.py | 20 ++++++
3 files changed, 141 insertions(+)
diff --git a/airavata-django-portal/django_airavata/apps/api/launcher_views.py b/airavata-django-portal/django_airavata/apps/api/launcher_views.py
new file mode 100644
index 000000000..7c23d0a69
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/api/launcher_views.py
@@ -0,0 +1,47 @@
+from rest_framework import permissions, status
+from rest_framework.decorators import api_view, permission_classes
+from rest_framework.request import Request
+from rest_framework.response import Response
+
+from django_airavata.apps.api import launcher_client
+
+
+def _client(request: Request) -> launcher_client.LauncherClient:
+ token = request.session.get("ACCESS_TOKEN", "")
+ return launcher_client.get_client(user_token=token)
+
+
+@api_view(["GET"])
+@permission_classes([permissions.IsAuthenticated])
+def applications_list(request: Request) -> Response:
+ category = request.query_params.get("category") or None
+ search = request.query_params.get("search") or None
+ results = _client(request).list_applications(category=category, search=search)
+ return Response({"results": results})
+
+
+@api_view(["GET"])
+@permission_classes([permissions.IsAuthenticated])
+def application_detail(request: Request, app_id: str) -> Response:
+ try:
+ return Response(_client(request).get_application(app_id=app_id))
+ except LookupError:
+ return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
+
+
+@api_view(["GET"])
+@permission_classes([permissions.IsAuthenticated])
+def project_resource_profile(request: Request, project_id: str) -> Response:
+ return Response(_client(request).get_project_resource_profile(project_id=project_id))
+
+
+@api_view(["GET"])
+@permission_classes([permissions.IsAuthenticated])
+def user_storages(request: Request) -> Response:
+ return Response({"results": _client(request).list_user_storages()})
+
+
+@api_view(["GET"])
+@permission_classes([permissions.IsAuthenticated])
+def projects_list(request: Request) -> Response:
+ return Response({"results": _client(request).list_projects()})
diff --git a/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_views.py b/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_views.py
new file mode 100644
index 000000000..ae4e35c9c
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_views.py
@@ -0,0 +1,74 @@
+from django.contrib.auth import get_user_model
+from django.test import override_settings
+from rest_framework.test import APITestCase
+
+
+class LauncherListingViewsTest(APITestCase):
+ def setUp(self):
+ User = get_user_model()
+ self.user = User.objects.create_user(username="alice", password="pw")
+ # Use DRF force_authenticate: bypasses session/CSRF and directly sets
+ # request._force_auth_user so the view sees an authenticated user.
+ # We don't need a real session for these thin-proxy views because the
+ # ACCESS_TOKEN is only read by _client() to construct the LauncherClient,
+ # and the stub client ignores the token entirely.
+ self.client.force_authenticate(user=self.user)
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_applications_list_default(self):
+ resp = self.client.get("/api/launcher/applications/")
+ self.assertEqual(resp.status_code, 200)
+ body = resp.json()
+ self.assertIn("results", body)
+ self.assertTrue(len(body["results"]) >= 1)
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_applications_list_filters_by_search(self):
+ resp = self.client.get("/api/launcher/applications/?search=namd")
+ self.assertEqual(resp.status_code, 200)
+ names = [a["name"] for a in resp.json()["results"]]
+ self.assertIn("NAMD", names)
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_application_detail(self):
+ resp = self.client.get("/api/launcher/applications/namd/")
+ self.assertEqual(resp.status_code, 200)
+ self.assertEqual(resp.json()["app_id"], "namd")
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_resource_profile_for_project(self):
+ resp = self.client.get("/api/launcher/projects/proj-1/resource-profile/")
+ self.assertEqual(resp.status_code, 200)
+ body = resp.json()
+ self.assertIn("compute_resources", body)
+ self.assertIn("allocation_id", body)
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_user_storages(self):
+ resp = self.client.get("/api/launcher/storages/")
+ self.assertEqual(resp.status_code, 200)
+ body = resp.json()
+ self.assertIn("results", body)
+ self.assertTrue(any(s.get("is_primary") for s in body["results"]))
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_projects_list(self):
+ resp = self.client.get("/api/launcher/projects/")
+ self.assertEqual(resp.status_code, 200)
+ body = resp.json()
+ self.assertIn("results", body)
+ self.assertTrue(len(body["results"]) >= 1)
+ self.assertIn("project_id", body["results"][0])
+ self.assertIn("name", body["results"][0])
+
+ def test_endpoints_require_auth(self):
+ # Clear the forced authentication so the requests are anonymous.
+ self.client.force_authenticate(user=None)
+ for url in [
+ "/api/launcher/applications/",
+ "/api/launcher/applications/namd/",
+ "/api/launcher/projects/proj-1/resource-profile/",
+ "/api/launcher/storages/",
+ "/api/launcher/projects/",
+ ]:
+ self.assertEqual(self.client.get(url).status_code, 403)
diff --git a/airavata-django-portal/django_airavata/apps/api/urls.py b/airavata-django-portal/django_airavata/apps/api/urls.py
index 5a34cb2f0..3997fca61 100644
--- a/airavata-django-portal/django_airavata/apps/api/urls.py
+++ b/airavata-django-portal/django_airavata/apps/api/urls.py
@@ -4,6 +4,7 @@ from django.urls import re_path
from rest_framework import routers
from rest_framework.urlpatterns import format_suffix_patterns
+from . import launcher_views
from . import views
from . import views_ssh
@@ -91,6 +92,25 @@ urlpatterns = [
urlpatterns = router.urls + format_suffix_patterns(urlpatterns)
+# Launcher endpoints — thin proxies over launcher_client.
+# The parameterized project route must be listed before the bare projects list
+# so Django's regex matcher resolves it first.
+urlpatterns += [
+ re_path(r"^launcher/applications/$", launcher_views.applications_list, name="launcher_applications_list"),
+ re_path(
+ r"^launcher/applications/(?P<app_id>[^/]+)/$",
+ launcher_views.application_detail,
+ name="launcher_application_detail",
+ ),
+ re_path(
+ r"^launcher/projects/(?P<project_id>[^/]+)/resource-profile/$",
+ launcher_views.project_resource_profile,
+ name="launcher_project_resource_profile",
+ ),
+ re_path(r"^launcher/storages/$", launcher_views.user_storages, name="launcher_user_storages"),
+ re_path(r"^launcher/projects/$", launcher_views.projects_list, name="launcher_projects_list"),
+]
+
if logger.isEnabledFor(logging.DEBUG):
for router_url in router.urls:
logger.debug(f"router url: {router_url}")
(airavata-portals) 04/34: feat(launcher): launcher_client stub + settings flags
Posted by ya...@apache.org.
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 4a675e2496dd22ab7ba7f229c0d276dd1b2704a4
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 20:12:03 2026 -0400
feat(launcher): launcher_client stub + settings flags
Adds FEATURE_GENERIC_LAUNCHER and LAUNCHER_CLIENT_STUB settings flags,
implements _StubClient returning fixture data and _RealClient raising
NotImplementedError, wired through get_client(). 4 TDD tests pass.
---
.../django_airavata/apps/api/launcher_client.py | 180 +++++++++++++++++++++
.../apps/api/tests/test_launcher_client.py | 59 +++++++
airavata-django-portal/django_airavata/settings.py | 4 +-
3 files changed, 242 insertions(+), 1 deletion(-)
diff --git a/airavata-django-portal/django_airavata/apps/api/launcher_client.py b/airavata-django-portal/django_airavata/apps/api/launcher_client.py
new file mode 100644
index 000000000..b25ca5e60
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/api/launcher_client.py
@@ -0,0 +1,180 @@
+"""Abstraction layer over the airavata server's launcher RPCs.
+
+The real RPCs (new-model applications + dry-run preview) are landing in the
+Java server separately. To unblock portal development and keep CI green,
+this module ships with a stub implementation that returns plausible
+fixtures. The settings flag ``LAUNCHER_CLIENT_STUB`` selects between the
+stub and the real client. Tests should mock at the ``get_client`` boundary.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Protocol
+
+from django.conf import settings
+
+
+class LauncherClient(Protocol):
+ def list_applications(self, *, category: str | None, search: str | None) -> list[dict[str, Any]]: ...
+ def get_application(self, *, app_id: str) -> dict[str, Any]: ...
+ def get_project_resource_profile(self, *, project_id: str) -> dict[str, Any]: ...
+ def list_user_storages(self) -> list[dict[str, Any]]: ...
+ def list_projects(self) -> list[dict[str, Any]]: ...
+ def generate_preview(self, draft: dict[str, Any]) -> dict[str, Any]: ...
+ def launch_experiment(self, draft: dict[str, Any]) -> dict[str, Any]: ...
+
+
+_STUB_APPS: list[dict[str, Any]] = [
+ {
+ "app_id": "namd",
+ "name": "NAMD",
+ "category": "Molecular Dynamics",
+ "content": {"kind": "github", "url": "github.com/Illinois/namd@v3.0"},
+ "interfaces": [
+ {
+ "name": "compile",
+ "inputs": [],
+ "outputs": [{"name": "binary", "type": "file"}],
+ },
+ {
+ "name": "run",
+ "inputs": [
+ {"name": "sim_dir", "type": "dir", "required": True},
+ {"name": "force_field", "type": "file", "required": True},
+ {"name": "steps", "type": "int", "required": True},
+ ],
+ "outputs": [{"name": "trajectory", "type": "file"}],
+ },
+ ],
+ }
+]
+
+
+class _StubClient:
+ def __init__(self, user_token: str) -> None:
+ self.user_token = user_token
+
+ def list_applications(self, *, category: str | None, search: str | None) -> list[dict[str, Any]]:
+ results = list(_STUB_APPS)
+ if category:
+ results = [a for a in results if a["category"] == category]
+ if search:
+ needle = search.lower()
+ results = [a for a in results if needle in a["name"].lower()]
+ return results
+
+ def get_application(self, *, app_id: str) -> dict[str, Any]:
+ for a in _STUB_APPS:
+ if a["app_id"] == app_id:
+ return a
+ raise LookupError(f"unknown app_id {app_id!r}")
+
+ def get_project_resource_profile(self, *, project_id: str) -> dict[str, Any]:
+ return {
+ "project_id": project_id,
+ "allocation_id": "NSF-CS240042",
+ "compute_resources": [
+ {
+ "compute_resource_id": "bridges-2",
+ "name": "Bridges-2",
+ "mapped_storage": {"storage_id": "bridges2-scratch", "scratch_path": f"/scratch/{project_id}"},
+ "partitions": [
+ {"name": "RM", "max_walltime": "48:00:00", "max_nodes": 64, "cpus_per_node": 128},
+ {"name": "RM-shared", "max_walltime": "48:00:00", "max_nodes": 1, "cpus_per_node": 128},
+ {"name": "GPU", "max_walltime": "48:00:00", "max_nodes": 16, "cpus_per_node": 64},
+ ],
+ }
+ ],
+ }
+
+ def list_user_storages(self) -> list[dict[str, Any]]:
+ return [
+ {"storage_id": "my-home", "name": "My Home", "is_primary": True},
+ {"storage_id": "bridges2-scratch", "name": "Bridges-2 Scratch", "is_primary": False},
+ ]
+
+ def list_projects(self) -> list[dict[str, Any]]:
+ return [
+ {"project_id": "proj-stub", "name": "stub-lab-2026"},
+ ]
+
+ def generate_preview(self, draft: dict[str, Any]) -> dict[str, Any]:
+ compute_id = draft["runtime"]["compute_resource_id"]
+ is_slurm = "bridges" in compute_id or "expanse" in compute_id or "anvil" in compute_id
+ partition = draft["runtime"]["partition"]
+ walltime = draft["runtime"]["walltime"]
+ nodes = draft["runtime"]["nodes"]
+ cpus = draft["runtime"]["cpus_per_node"]
+ if is_slurm:
+ invocation = f"sbatch /tmp/{draft['name']}/run.sh"
+ script_lines = [
+ "#!/bin/bash",
+ f"#SBATCH --job-name={draft['name']}",
+ "#SBATCH -A NSF-CS240042",
+ f"#SBATCH -p {partition}",
+ f"#SBATCH -N {nodes}",
+ f"#SBATCH --ntasks-per-node={cpus}",
+ f"#SBATCH -t {walltime}",
+ "",
+ "# compute-service",
+ "module load openmpi/4.1.2",
+ "",
+ "# agent-service",
+ "airavata-agent --exp-id $EXP_ID &",
+ "",
+ "# research-framework",
+ f"srun {draft.get('app_id', 'app')} {draft.get('interface_name', 'run')}",
+ ]
+ else:
+ invocation = f"bash /tmp/{draft['name']}/run.sh"
+ script_lines = [
+ "#!/bin/bash",
+ "# compute-service",
+ "module load openmpi/4.1.2",
+ "",
+ "# agent-service",
+ "airavata-agent --exp-id $EXP_ID &",
+ "",
+ "# research-framework",
+ f"{draft.get('app_id', 'app')} {draft.get('interface_name', 'run')}",
+ ]
+ return {
+ "invocation_command": invocation,
+ "script_contents": "\n".join(script_lines) + "\n",
+ "warnings": [],
+ }
+
+ def launch_experiment(self, draft: dict[str, Any]) -> dict[str, Any]:
+ return {"experiment_id": f"exp-{draft['name']}-stub"}
+
+
+class _RealClient:
+ def __init__(self, user_token: str) -> None:
+ self.user_token = user_token
+
+ def list_applications(self, *, category, search):
+ raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)")
+
+ def get_application(self, *, app_id):
+ raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)")
+
+ def get_project_resource_profile(self, *, project_id):
+ raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)")
+
+ def list_user_storages(self):
+ raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)")
+
+ def list_projects(self):
+ raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)")
+
+ def generate_preview(self, draft):
+ raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)")
+
+ def launch_experiment(self, draft):
+ raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)")
+
+
+def get_client(user_token: str) -> LauncherClient:
+ if getattr(settings, "LAUNCHER_CLIENT_STUB", True):
+ return _StubClient(user_token)
+ return _RealClient(user_token)
diff --git a/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_client.py b/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_client.py
new file mode 100644
index 000000000..2ba428000
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_client.py
@@ -0,0 +1,59 @@
+from unittest import TestCase
+
+from django.test import override_settings
+
+from django_airavata.apps.api import launcher_client
+
+
+class StubLauncherClientTest(TestCase):
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_list_applications_returns_at_least_one_app(self):
+ client = launcher_client.get_client(user_token="ignored")
+ apps = client.list_applications(category=None, search=None)
+ self.assertTrue(len(apps) >= 1)
+ first = apps[0]
+ self.assertIn("app_id", first)
+ self.assertIn("name", first)
+ self.assertIn("content", first)
+ self.assertIn("interfaces", first)
+ self.assertIsInstance(first["interfaces"], list)
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_get_resource_profile_returns_partitions(self):
+ client = launcher_client.get_client(user_token="ignored")
+ profile = client.get_project_resource_profile(project_id="proj-stub")
+ self.assertIn("compute_resources", profile)
+ self.assertIn("allocation_id", profile)
+ self.assertTrue(len(profile["compute_resources"]) >= 1)
+ self.assertIn("partitions", profile["compute_resources"][0])
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_generate_preview_returns_script_and_command(self):
+ client = launcher_client.get_client(user_token="ignored")
+ preview = client.generate_preview({
+ "name": "x",
+ "project_id": "proj-stub",
+ "app_id": "app-stub",
+ "interface_name": "run",
+ "inputs": {},
+ "outputs": {},
+ "runtime": {
+ "compute_resource_id": "cr-stub",
+ "partition": "RM",
+ "walltime": "01:00:00",
+ "nodes": 1,
+ "cpus_per_node": 8,
+ },
+ })
+ self.assertIn("invocation_command", preview)
+ self.assertIn("script_contents", preview)
+ self.assertIn("warnings", preview)
+ self.assertIsInstance(preview["warnings"], list)
+
+ @override_settings(LAUNCHER_CLIENT_STUB=True)
+ def test_list_projects_returns_at_least_one(self):
+ client = launcher_client.get_client(user_token="ignored")
+ projects = client.list_projects()
+ self.assertTrue(len(projects) >= 1)
+ self.assertIn("project_id", projects[0])
+ self.assertIn("name", projects[0])
diff --git a/airavata-django-portal/django_airavata/settings.py b/airavata-django-portal/django_airavata/settings.py
index 8450f9af9..493b1875f 100644
--- a/airavata-django-portal/django_airavata/settings.py
+++ b/airavata-django-portal/django_airavata/settings.py
@@ -624,7 +624,9 @@ if 'GATEWAY_ID' not in dir():
GATEWAY_ID = os.environ.get('GATEWAY_ID', 'default')
-
+# Generic experiment launcher feature flags
+FEATURE_GENERIC_LAUNCHER = os.environ.get("FEATURE_GENERIC_LAUNCHER", "False").lower() == "true"
+LAUNCHER_CLIENT_STUB = os.environ.get("LAUNCHER_CLIENT_STUB", "True").lower() == "true"
(airavata-portals) 15/34: feat(launcher): WizardTabs with strict-forward gating
Posted by ya...@apache.org.
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 c5f97a3a426cc0dea70a1366e0d29179835f6e43
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 22:21:53 2026 -0400
feat(launcher): WizardTabs with strict-forward gating
---
.../js/components/launch/WizardTabs.vue | 37 ++++++++++++++++++++
.../unit/components/launch/WizardTabs.spec.ts | 40 ++++++++++++++++++++++
2 files changed, 77 insertions(+)
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/WizardTabs.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/WizardTabs.vue
new file mode 100644
index 000000000..7d2b4f76d
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/WizardTabs.vue
@@ -0,0 +1,37 @@
+<template>
+ <div class="nav nav-tabs mb-3" role="tablist">
+ <button
+ v-for="t in tabs"
+ :key="t.idx"
+ role="tab"
+ type="button"
+ class="nav-link"
+ :class="{ active: t.idx === active }"
+ :disabled="t.disabled"
+ @click="onClick(t.idx, t.disabled)"
+ >
+ {{ t.idx }} · {{ t.label }}
+ </button>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from "vue";
+import { useLaunchStore } from "django-airavata-common-ui/js/stores/launch";
+
+defineProps<{ active: 1 | 2 | 3 }>();
+const emit = defineEmits<{ "update:active": [n: 1 | 2 | 3] }>();
+
+const store = useLaunchStore();
+
+const tabs = computed(() => [
+ { idx: 1 as const, label: "Application & Inputs", disabled: false },
+ { idx: 2 as const, label: "Runtime", disabled: !store.tab1Valid },
+ { idx: 3 as const, label: "Review & Launch", disabled: !store.tab1Valid || !store.tab2Valid },
+]);
+
+function onClick(n: 1 | 2 | 3, disabled: boolean) {
+ if (disabled) return;
+ emit("update:active", n);
+}
+</script>
diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/WizardTabs.spec.ts b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/WizardTabs.spec.ts
new file mode 100644
index 000000000..41ebbab5f
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/WizardTabs.spec.ts
@@ -0,0 +1,40 @@
+import { mount } from "@vue/test-utils";
+import { setActivePinia, createPinia } from "pinia";
+import { beforeEach, describe, expect, it } from "vitest";
+import WizardTabs from "../../../../js/components/launch/WizardTabs.vue";
+import { useLaunchStore } from "django-airavata-common-ui/js/stores/launch";
+
+describe("WizardTabs", () => {
+ beforeEach(() => setActivePinia(createPinia()));
+
+ function makeMount(active: 1 | 2 | 3 = 1) {
+ return mount(WizardTabs, { props: { active }, global: { stubs: { transition: false } } });
+ }
+
+ it("renders three tab buttons", () => {
+ const w = makeMount();
+ expect(w.findAll("button[role='tab']")).toHaveLength(3);
+ });
+
+ it("disables tabs 2 and 3 when tab 1 is invalid", () => {
+ const w = makeMount();
+ const tabs = w.findAll("button[role='tab']");
+ expect(tabs[1].attributes("disabled")).toBeDefined();
+ expect(tabs[2].attributes("disabled")).toBeDefined();
+ });
+
+ it("emits update:active when allowed tab clicked", async () => {
+ const store = useLaunchStore();
+ // Force tab 1 valid by spying on the getter
+ Object.defineProperty(store, "tab1Valid", { value: true });
+ const w = makeMount();
+ await w.findAll("button[role='tab']")[1].trigger("click");
+ expect(w.emitted("update:active")?.[0]).toEqual([2]);
+ });
+
+ it("does not emit update:active for a disabled tab", async () => {
+ const w = makeMount();
+ await w.findAll("button[role='tab']")[1].trigger("click");
+ expect(w.emitted("update:active")).toBeUndefined();
+ });
+});
(airavata-portals) 02/34: chore: ignore .superpowers/ brainstorming scratch dir in django portal
Posted by ya...@apache.org.
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 41d78797a1cd4b9770e4ce6550f58c08a1605de7
Author: yasithdev <ya...@icloud.com>
AuthorDate: Fri Apr 24 16:17:42 2026 -0400
chore: ignore .superpowers/ brainstorming scratch dir in django portal
---
airavata-django-portal/.gitignore | 3 +++
1 file changed, 3 insertions(+)
diff --git a/airavata-django-portal/.gitignore b/airavata-django-portal/.gitignore
index 1cfe2a799..061af58ac 100644
--- a/airavata-django-portal/.gitignore
+++ b/airavata-django-portal/.gitignore
@@ -21,3 +21,6 @@ yarn-error.log
tests/e2e/.auth/
playwright-report/
test-results/
+
+# Superpowers brainstorming scratch (mockups, server state)
+.superpowers/