From dac5a14e114cde5c8c7b1877dfd5527d01eb7af9 Mon Sep 17 00:00:00 2001
From: louiscsq
Date: Fri, 17 Apr 2026 10:26:36 +1000
Subject: [PATCH 01/13] Add cross-workspace deployment UI and catalog remapping
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Frontend:
- Add collapsible Deployment section to OptimizationConfig with target
workspace URL, target space ID, and catalog mapping (source→target)
- Extend GSOTriggerRequest with deploy_space_id and catalog_map fields
Backend:
- Extend TriggerRequest with deploy_space_id and catalog_map
- Forward new params through trigger → job_launcher → job parameters
GSO:
- Add catalog_map widget to run_cross_env_deploy notebook
- Remap table/metric_view identifiers before applying to target workspace
(e.g. dev_catalog.schema.table → prod_catalog.schema.table)
- Forward deploy_space_id and catalog_map through trigger and job_launcher
Co-authored-by: Isaac
---
backend/routers/auto_optimize.py | 4 +
.../auto-optimize/OptimizationConfig.tsx | 109 +++++++++++++++++-
frontend/src/types/index.ts | 2 +
.../backend/job_launcher.py | 4 +
.../integration/trigger.py | 4 +
.../jobs/run_cross_env_deploy.py | 25 ++++
6 files changed, 147 insertions(+), 1 deletion(-)
diff --git a/backend/routers/auto_optimize.py b/backend/routers/auto_optimize.py
index 1b5bad49b..d9e76f0e4 100644
--- a/backend/routers/auto_optimize.py
+++ b/backend/routers/auto_optimize.py
@@ -61,6 +61,8 @@ class TriggerRequest(BaseModel):
apply_mode: str = "genie_config"
levers: list[int] | None = None
deploy_target: str | None = None
+ deploy_space_id: str | None = None
+ catalog_map: dict[str, str] | None = None
class SchemaAccessStatus(BaseModel):
@@ -857,6 +859,8 @@ async def trigger(body: TriggerRequest, request: Request):
apply_mode=body.apply_mode,
levers=body.levers,
deploy_target=body.deploy_target,
+ deploy_space_id=body.deploy_space_id,
+ catalog_map=body.catalog_map,
)
return {
"runId": result.run_id,
diff --git a/frontend/src/components/auto-optimize/OptimizationConfig.tsx b/frontend/src/components/auto-optimize/OptimizationConfig.tsx
index 4ea4de597..686737467 100644
--- a/frontend/src/components/auto-optimize/OptimizationConfig.tsx
+++ b/frontend/src/components/auto-optimize/OptimizationConfig.tsx
@@ -1,5 +1,5 @@
import { useState } from "react"
-import { AlertTriangle, Rocket } from "lucide-react"
+import { AlertTriangle, ChevronDown, ChevronRight, Plus, Rocket, Trash2, Upload } from "lucide-react"
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { triggerAutoOptimize } from "@/lib/api"
@@ -33,6 +33,12 @@ export function OptimizationConfig({ spaceId, onStarted, onTriggerStart, onTrigg
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
+ // Deployment config
+ const [deployExpanded, setDeployExpanded] = useState(false)
+ const [deployTarget, setDeployTarget] = useState("")
+ const [deploySpaceId, setDeploySpaceId] = useState("")
+ const [catalogMappings, setCatalogMappings] = useState<{ source: string; target: string }[]>([])
+
const hasHealthIssues = (healthIssues?.length ?? 0) > 0
const canStart = permissions?.can_start === true && !hasHealthIssues
@@ -50,10 +56,18 @@ export function OptimizationConfig({ spaceId, onStarted, onTriggerStart, onTrigg
setError(null)
onTriggerStart?.()
try {
+ const catalogMap = catalogMappings.reduce>((acc, m) => {
+ if (m.source.trim() && m.target.trim()) acc[m.source.trim()] = m.target.trim()
+ return acc
+ }, {})
+
const result = await triggerAutoOptimize({
space_id: spaceId,
apply_mode: applyMode,
levers: Array.from(selectedLevers).sort(),
+ deploy_target: deployTarget.trim() || undefined,
+ deploy_space_id: deploySpaceId.trim() || undefined,
+ catalog_map: Object.keys(catalogMap).length > 0 ? catalogMap : undefined,
})
onStarted(result.runId)
} catch (e) {
@@ -104,6 +118,99 @@ export function OptimizationConfig({ spaceId, onStarted, onTriggerStart, onTrigg
+ {/* Deployment (collapsible) */}
+
+
setDeployExpanded(!deployExpanded)}
+ className="w-full flex items-center gap-2 px-4 py-3 text-sm font-medium text-secondary hover:text-primary transition-colors"
+ >
+ {deployExpanded ? : }
+
+ Cross-Workspace Deployment
+ {deployTarget && Configured }
+
+ {deployExpanded && (
+
+
+ After optimization, deploy the optimized config to a target workspace. Leave blank to skip deployment.
+
+
+
+ Target workspace URL
+ setDeployTarget(e.target.value)}
+ placeholder="https://my-prod-workspace.cloud.databricks.com"
+ className="w-full px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
+
+
+ Target space ID (optional — creates new if blank)
+ setDeploySpaceId(e.target.value)}
+ placeholder="01f1347d7f1516ceaea7e5853166498f"
+ className="w-full px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
+
+
+
+
Catalog mapping (source → target)
+
setCatalogMappings([...catalogMappings, { source: "", target: "" }])}
+ className="flex items-center gap-1 text-xs text-accent hover:text-accent/80 transition-colors"
+ >
+ Add mapping
+
+
+ {catalogMappings.map((m, i) => (
+
+ {
+ const next = [...catalogMappings]
+ next[i] = { ...next[i], source: e.target.value }
+ setCatalogMappings(next)
+ }}
+ placeholder="dev_catalog"
+ className="flex-1 px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+ →
+ {
+ const next = [...catalogMappings]
+ next[i] = { ...next[i], target: e.target.value }
+ setCatalogMappings(next)
+ }}
+ placeholder="prod_catalog"
+ className="flex-1 px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+ setCatalogMappings(catalogMappings.filter((_, j) => j !== i))}
+ className="text-muted hover:text-danger transition-colors"
+ >
+
+
+
+ ))}
+ {catalogMappings.length === 0 && (
+
No catalog mappings — table references will be used as-is
+ )}
+
+
+ )}
+
+
{/* Health Issues */}
{hasHealthIssues && (
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 0b5f6681f..ee4e97119 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -264,6 +264,8 @@ export interface GSOTriggerRequest {
apply_mode?: "genie_config" | "uc_artifact" | "both"
levers?: number[]
deploy_target?: string
+ deploy_space_id?: string
+ catalog_map?: Record
}
export interface GSOTriggerResponse {
diff --git a/packages/genie-space-optimizer/src/genie_space_optimizer/backend/job_launcher.py b/packages/genie-space-optimizer/src/genie_space_optimizer/backend/job_launcher.py
index de08d8954..795378dfb 100644
--- a/packages/genie-space-optimizer/src/genie_space_optimizer/backend/job_launcher.py
+++ b/packages/genie-space-optimizer/src/genie_space_optimizer/backend/job_launcher.py
@@ -363,6 +363,8 @@ def submit_optimization(
triggered_by: str = "",
experiment_name: str = "",
deploy_target: str = "",
+ deploy_space_id: str = "",
+ catalog_map: str = "",
warehouse_id: str = "",
target_benchmark_count: str = "",
) -> tuple[str, int]:
@@ -393,6 +395,8 @@ def submit_optimization(
"triggered_by": triggered_by,
"experiment_name": experiment_name,
"deploy_target": deploy_target,
+ "deploy_space_id": deploy_space_id,
+ "catalog_map": catalog_map,
"warehouse_id": warehouse_id,
"target_benchmark_count": target_benchmark_count,
},
diff --git a/packages/genie-space-optimizer/src/genie_space_optimizer/integration/trigger.py b/packages/genie-space-optimizer/src/genie_space_optimizer/integration/trigger.py
index bf881e067..0ec718cb3 100644
--- a/packages/genie-space-optimizer/src/genie_space_optimizer/integration/trigger.py
+++ b/packages/genie-space-optimizer/src/genie_space_optimizer/integration/trigger.py
@@ -45,6 +45,8 @@ def trigger_optimization(
apply_mode: str = "genie_config",
levers: list[int] | None = None,
deploy_target: str | None = None,
+ deploy_space_id: str | None = None,
+ catalog_map: dict[str, str] | None = None,
) -> TriggerResult:
"""Trigger a GSO optimization run using SQL Warehouse for state management.
@@ -227,6 +229,8 @@ def trigger_optimization(
triggered_by=caller_email,
experiment_name=experiment_name or "",
deploy_target=deploy_target or "",
+ deploy_space_id=deploy_space_id or "",
+ catalog_map=json.dumps(catalog_map) if catalog_map else "",
warehouse_id=config.warehouse_id or "",
)
diff --git a/packages/genie-space-optimizer/src/genie_space_optimizer/jobs/run_cross_env_deploy.py b/packages/genie-space-optimizer/src/genie_space_optimizer/jobs/run_cross_env_deploy.py
index 09bbc6a66..b1b6c45fe 100644
--- a/packages/genie-space-optimizer/src/genie_space_optimizer/jobs/run_cross_env_deploy.py
+++ b/packages/genie-space-optimizer/src/genie_space_optimizer/jobs/run_cross_env_deploy.py
@@ -56,11 +56,13 @@
dbutils.widgets.text("model_version", "", "Model Version")
dbutils.widgets.text("target_workspace_url", "", "Target Workspace URL")
dbutils.widgets.text("target_space_id", "", "Target Space ID")
+dbutils.widgets.text("catalog_map", "", "Catalog Mapping (JSON)")
model_name = dbutils.widgets.get("model_name").strip()
model_version = dbutils.widgets.get("model_version").strip()
target_workspace_url = dbutils.widgets.get("target_workspace_url").strip()
target_space_id = dbutils.widgets.get("target_space_id").strip()
+catalog_map_raw = dbutils.widgets.get("catalog_map").strip()
_banner("Resolved Parameters")
_log(
@@ -139,6 +141,29 @@
_log("Config loaded", keys=list(space_config.keys()))
+# Remap catalog references if catalog_map is provided
+catalog_map: dict[str, str] = {}
+if catalog_map_raw:
+ try:
+ catalog_map = json.loads(catalog_map_raw)
+ except json.JSONDecodeError:
+ _log("WARNING: Could not parse catalog_map JSON, skipping remapping", raw=catalog_map_raw)
+
+if catalog_map:
+ _banner("Remapping Catalog References")
+ remapped = 0
+ for source_list_key in ("tables", "metric_views"):
+ for src in space_config.get("data_sources", {}).get(source_list_key, []):
+ ident = src.get("identifier", "")
+ parts = ident.replace("`", "").split(".")
+ if len(parts) >= 3 and parts[0] in catalog_map:
+ old_ident = ident
+ parts[0] = catalog_map[parts[0]]
+ src["identifier"] = ".".join(parts)
+ _log("Remapped", old=old_ident, new=src["identifier"])
+ remapped += 1
+ _log("Catalog remapping complete", remapped=remapped, mappings=catalog_map)
+
# COMMAND ----------
# MAGIC %md
From 1e0c9d0f800f5d0e64e4cd3a830a2cf735fc6a40 Mon Sep 17 00:00:00 2001
From: louiscsq
Date: Fri, 17 Apr 2026 10:32:56 +1000
Subject: [PATCH 02/13] Add deployment status display in run detail view
- RunDetailView: add deployment status banner showing target workspace,
status (PENDING_APPROVAL/DEPLOYED/FAILED), and link to target workspace
Color-coded: green=deployed, blue=pending, red=failed
- Backend: expose deployTarget at top-level run response (was only in
step detail before)
- Types: add deployTarget to GSOPipelineRun interface
Co-authored-by: Isaac
---
backend/routers/auto_optimize.py | 1 +
.../auto-optimize/RunDetailView.tsx | 54 ++++++++++++++++++-
frontend/src/types/index.ts | 1 +
3 files changed, 55 insertions(+), 1 deletion(-)
diff --git a/backend/routers/auto_optimize.py b/backend/routers/auto_optimize.py
index d9e76f0e4..2284b04ad 100644
--- a/backend/routers/auto_optimize.py
+++ b/backend/routers/auto_optimize.py
@@ -1166,6 +1166,7 @@ async def get_run(run_id: RunId):
"levers": levers,
"links": links,
"convergenceReason": run.get("convergence_reason"),
+ "deployTarget": run.get("deploy_target") or None,
"deploymentStatus": run.get("deploy_status"),
"labelingSessionUrl": run.get("labeling_session_url") or None,
"labelingSessionName": run.get("labeling_session_name") or None,
diff --git a/frontend/src/components/auto-optimize/RunDetailView.tsx b/frontend/src/components/auto-optimize/RunDetailView.tsx
index a4fdb67a7..eed8ca1c9 100644
--- a/frontend/src/components/auto-optimize/RunDetailView.tsx
+++ b/frontend/src/components/auto-optimize/RunDetailView.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"
-import { ArrowLeft, Cog, UserCheck, ExternalLink } from "lucide-react"
+import { ArrowLeft, Cog, UserCheck, ExternalLink, Upload, CheckCircle2, Clock, AlertTriangle } from "lucide-react"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { ScoreSummary } from "@/components/auto-optimize/ScoreSummary"
@@ -157,6 +157,58 @@ export function RunDetailView({ runId, onBack }: RunDetailViewProps) {
)}
+ {/* Deployment Status Banner */}
+ {run.deployTarget && (
+
+ {run.deploymentStatus === "DEPLOYED" ? (
+
+ ) : run.deploymentStatus === "FAILED" ? (
+
+ ) : run.deploymentStatus === "PENDING_APPROVAL" ? (
+
+ ) : (
+
+ )}
+
+
+ {run.deploymentStatus === "DEPLOYED"
+ ? "Deployed to target workspace"
+ : run.deploymentStatus === "PENDING_APPROVAL"
+ ? "Deployment awaiting approval"
+ : run.deploymentStatus === "FAILED"
+ ? "Deployment failed"
+ : `Deployment: ${run.deploymentStatus ?? "configured"}`}
+
+
+ Target: {run.deployTarget}
+
+
+ {run.deploymentStatus === "DEPLOYED" && (
+
+ Open Workspace
+
+
+ )}
+
+ )}
+
{/* Tabs */}
Date: Fri, 17 Apr 2026 23:07:33 +1000
Subject: [PATCH 03/13] Fix deploy.sh: fallback when user_api_scopes not
supported
The PATCH API rejects user_api_scopes on workspaces without the token
passthrough feature, causing ALL resources (including sql-warehouse and
postgres) to not be attached. The error was hidden by 2>/dev/null.
Fix: try PATCH with scopes first; if it fails, retry with resources
only. This ensures the warehouse and Lakebase resources are always
attached regardless of workspace feature flags.
Co-authored-by: Isaac
---
scripts/deploy.sh | 22 +++++++++++++++++-----
1 file changed, 17 insertions(+), 5 deletions(-)
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
index 7d2cacb58..02facf273 100755
--- a/scripts/deploy.sh
+++ b/scripts/deploy.sh
@@ -592,12 +592,24 @@ if lakebase_db:
}
}
-print(json.dumps({'user_api_scopes': scopes, 'resources': list(by_name.values())}))
+resources_list = list(by_name.values())
+print(json.dumps({'user_api_scopes': scopes, 'resources': resources_list}) + '|||' + json.dumps({'resources': resources_list}))
")
-databricks api patch "/api/2.0/apps/$APP_NAME" \
- --profile "$PROFILE" --json "$PATCH_PAYLOAD" 2>/dev/null && \
- echo " ✓ App scopes and resources configured" || \
- echo " ⚠ Could not configure app scopes/resources"
+
+# Split into scopes+resources and resources-only payloads
+PATCH_WITH_SCOPES="${PATCH_PAYLOAD%%|||*}"
+PATCH_RESOURCES_ONLY="${PATCH_PAYLOAD##*|||}"
+
+# Try with scopes first; if workspace doesn't support token passthrough, retry resources-only
+if databricks api patch "/api/2.0/apps/$APP_NAME" \
+ --profile "$PROFILE" --json "$PATCH_WITH_SCOPES" 2>/dev/null; then
+ echo " ✓ App scopes and resources configured"
+elif databricks api patch "/api/2.0/apps/$APP_NAME" \
+ --profile "$PROFILE" --json "$PATCH_RESOURCES_ONLY" 2>/dev/null; then
+ echo " ✓ App resources configured (user_api_scopes not supported on this workspace)"
+else
+ echo " ⚠ Could not configure app resources"
+fi
databricks apps deploy "$APP_NAME" --profile "$PROFILE" \
--source-code-path "$WS_PATH" --no-wait
From e39319afd014ab0d7d87311004110310d411eb9b Mon Sep 17 00:00:00 2001
From: louiscsq
Date: Fri, 17 Apr 2026 23:29:26 +1000
Subject: [PATCH 04/13] Redesign deployops: on-demand deployment from run
detail view
Move deployment config from OptimizationConfig (auto-deploy during
pipeline) to RunDetailView (on-demand after optimization completes).
Frontend:
- Revert OptimizationConfig to original (no deployment section)
- Add "Deploy to Workspace" collapsible panel in RunDetailView for
completed runs (CONVERGED/STALLED/MAX_ITERATIONS/APPLIED)
- Pre-fill config from localStorage, save on successful deploy
- Show deployment status banner for runs that have been deployed
Backend:
- Add POST /api/auto-optimize/runs/{run_id}/deploy endpoint
- Validates run is terminal, has UC model, then triggers deployment job
- Uses existing ensure_deployment_job() from GSO job_launcher
API:
- Add deployOptimizationRun() in api.ts
Co-authored-by: Isaac
---
backend/routers/auto_optimize.py | 52 +++++
.../auto-optimize/OptimizationConfig.tsx | 109 +----------
.../auto-optimize/RunDetailView.tsx | 179 +++++++++++++++---
frontend/src/lib/api.ts | 15 ++
4 files changed, 218 insertions(+), 137 deletions(-)
diff --git a/backend/routers/auto_optimize.py b/backend/routers/auto_optimize.py
index 2284b04ad..d22b1c425 100644
--- a/backend/routers/auto_optimize.py
+++ b/backend/routers/auto_optimize.py
@@ -883,6 +883,58 @@ async def trigger(body: TriggerRequest, request: Request):
raise HTTPException(status_code=500, detail="Failed to start optimization job.")
+class DeployRequest(BaseModel):
+ target_workspace_url: str
+ target_space_id: str | None = None
+ catalog_map: dict[str, str] | None = None
+
+
+@router.post("/runs/{run_id}/deploy")
+async def deploy_run(run_id: RunId, body: DeployRequest):
+ """Trigger cross-workspace deployment for a completed optimization run."""
+ if not _is_configured():
+ raise HTTPException(status_code=503, detail="Auto-Optimize is not configured.")
+
+ # Load run to get UC model info
+ run = await gso_lakebase.load_gso_run(run_id)
+ if not run and _is_configured():
+ config = _build_gso_config()
+ run = _fetch_run_via_sql(run_id, config)
+ if not run:
+ raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
+
+ status = run.get("status", "")
+ terminal = {"CONVERGED", "STALLED", "MAX_ITERATIONS", "APPLIED"}
+ if status not in terminal:
+ raise HTTPException(status_code=400, detail=f"Run is {status} — can only deploy terminal runs")
+
+ # Get model name/version from the run's finalize output
+ model_name = run.get("uc_model_name") or run.get("model_name")
+ model_version = run.get("uc_model_version") or run.get("model_version")
+ if not model_name or not model_version:
+ raise HTTPException(status_code=400, detail="Run has no registered UC model — cannot deploy")
+
+ try:
+ import json as _json
+ from genie_space_optimizer.backend.job_launcher import ensure_deployment_job
+ sp_ws = get_service_principal_client()
+ config = _build_gso_config()
+
+ job_run_id = ensure_deployment_job(
+ ws=sp_ws,
+ job_id=config.job_id,
+ model_name=model_name,
+ model_version=str(model_version),
+ target_workspace_url=body.target_workspace_url,
+ target_space_id=body.target_space_id or "",
+ catalog_map=_json.dumps(body.catalog_map) if body.catalog_map else "",
+ )
+ return {"jobRunId": str(job_run_id), "status": "DEPLOYING"}
+ except Exception as e:
+ logger.exception("Failed to trigger deployment: %s", e)
+ raise HTTPException(status_code=500, detail=f"Deployment failed: {e}")
+
+
# ---------------------------------------------------------------------------
# Pipeline step definitions — group raw sub-stages into 6 logical steps
# (Ported from Genie Space Optimizer's map_stages_to_steps)
diff --git a/frontend/src/components/auto-optimize/OptimizationConfig.tsx b/frontend/src/components/auto-optimize/OptimizationConfig.tsx
index 686737467..4ea4de597 100644
--- a/frontend/src/components/auto-optimize/OptimizationConfig.tsx
+++ b/frontend/src/components/auto-optimize/OptimizationConfig.tsx
@@ -1,5 +1,5 @@
import { useState } from "react"
-import { AlertTriangle, ChevronDown, ChevronRight, Plus, Rocket, Trash2, Upload } from "lucide-react"
+import { AlertTriangle, Rocket } from "lucide-react"
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { triggerAutoOptimize } from "@/lib/api"
@@ -33,12 +33,6 @@ export function OptimizationConfig({ spaceId, onStarted, onTriggerStart, onTrigg
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
- // Deployment config
- const [deployExpanded, setDeployExpanded] = useState(false)
- const [deployTarget, setDeployTarget] = useState("")
- const [deploySpaceId, setDeploySpaceId] = useState("")
- const [catalogMappings, setCatalogMappings] = useState<{ source: string; target: string }[]>([])
-
const hasHealthIssues = (healthIssues?.length ?? 0) > 0
const canStart = permissions?.can_start === true && !hasHealthIssues
@@ -56,18 +50,10 @@ export function OptimizationConfig({ spaceId, onStarted, onTriggerStart, onTrigg
setError(null)
onTriggerStart?.()
try {
- const catalogMap = catalogMappings.reduce>((acc, m) => {
- if (m.source.trim() && m.target.trim()) acc[m.source.trim()] = m.target.trim()
- return acc
- }, {})
-
const result = await triggerAutoOptimize({
space_id: spaceId,
apply_mode: applyMode,
levers: Array.from(selectedLevers).sort(),
- deploy_target: deployTarget.trim() || undefined,
- deploy_space_id: deploySpaceId.trim() || undefined,
- catalog_map: Object.keys(catalogMap).length > 0 ? catalogMap : undefined,
})
onStarted(result.runId)
} catch (e) {
@@ -118,99 +104,6 @@ export function OptimizationConfig({ spaceId, onStarted, onTriggerStart, onTrigg
- {/* Deployment (collapsible) */}
-
-
setDeployExpanded(!deployExpanded)}
- className="w-full flex items-center gap-2 px-4 py-3 text-sm font-medium text-secondary hover:text-primary transition-colors"
- >
- {deployExpanded ? : }
-
- Cross-Workspace Deployment
- {deployTarget && Configured }
-
- {deployExpanded && (
-
-
- After optimization, deploy the optimized config to a target workspace. Leave blank to skip deployment.
-
-
-
- Target workspace URL
- setDeployTarget(e.target.value)}
- placeholder="https://my-prod-workspace.cloud.databricks.com"
- className="w-full px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent"
- />
-
-
-
- Target space ID (optional — creates new if blank)
- setDeploySpaceId(e.target.value)}
- placeholder="01f1347d7f1516ceaea7e5853166498f"
- className="w-full px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent"
- />
-
-
-
-
-
Catalog mapping (source → target)
-
setCatalogMappings([...catalogMappings, { source: "", target: "" }])}
- className="flex items-center gap-1 text-xs text-accent hover:text-accent/80 transition-colors"
- >
- Add mapping
-
-
- {catalogMappings.map((m, i) => (
-
- {
- const next = [...catalogMappings]
- next[i] = { ...next[i], source: e.target.value }
- setCatalogMappings(next)
- }}
- placeholder="dev_catalog"
- className="flex-1 px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent"
- />
- →
- {
- const next = [...catalogMappings]
- next[i] = { ...next[i], target: e.target.value }
- setCatalogMappings(next)
- }}
- placeholder="prod_catalog"
- className="flex-1 px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent"
- />
- setCatalogMappings(catalogMappings.filter((_, j) => j !== i))}
- className="text-muted hover:text-danger transition-colors"
- >
-
-
-
- ))}
- {catalogMappings.length === 0 && (
-
No catalog mappings — table references will be used as-is
- )}
-
-
- )}
-
-
{/* Health Issues */}
{hasHealthIssues && (
diff --git a/frontend/src/components/auto-optimize/RunDetailView.tsx b/frontend/src/components/auto-optimize/RunDetailView.tsx
index eed8ca1c9..39c357280 100644
--- a/frontend/src/components/auto-optimize/RunDetailView.tsx
+++ b/frontend/src/components/auto-optimize/RunDetailView.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"
-import { ArrowLeft, Cog, UserCheck, ExternalLink, Upload, CheckCircle2, Clock, AlertTriangle } from "lucide-react"
+import { ArrowLeft, ChevronDown, ChevronRight, Cog, UserCheck, ExternalLink, Plus, Rocket, Trash2, Upload, CheckCircle2, Clock, AlertTriangle } from "lucide-react"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { ScoreSummary } from "@/components/auto-optimize/ScoreSummary"
@@ -9,6 +9,7 @@ import { PipelineDetailsModal } from "@/components/auto-optimize/PipelineDetails
import {
getAutoOptimizeRun,
getAutoOptimizeQuestionResults,
+ deployOptimizationRun,
} from "@/lib/api"
import type { GSOPipelineRun, GSOQuestionDetail } from "@/types"
@@ -19,6 +20,21 @@ interface RunDetailViewProps {
type EvalTab = "baseline" | "final"
+const DEPLOY_STORAGE_KEY = "genie-workbench:deploy-config"
+const TERMINAL_STATUSES = new Set(["CONVERGED", "STALLED", "MAX_ITERATIONS", "APPLIED"])
+
+function loadDeployConfig(): { targetUrl: string; spaceId: string; catalogMappings: { source: string; target: string }[] } {
+ try {
+ const raw = localStorage.getItem(DEPLOY_STORAGE_KEY)
+ if (raw) return JSON.parse(raw)
+ } catch {}
+ return { targetUrl: "", spaceId: "", catalogMappings: [] }
+}
+
+function saveDeployConfig(config: { targetUrl: string; spaceId: string; catalogMappings: { source: string; target: string }[] }) {
+ localStorage.setItem(DEPLOY_STORAGE_KEY, JSON.stringify(config))
+}
+
const STATUS_VARIANT: Record = {
CONVERGED: "success",
APPLIED: "success",
@@ -40,6 +56,15 @@ export function RunDetailView({ runId, onBack }: RunDetailViewProps) {
const [selectedQuestionId, setSelectedQuestionId] = useState(null)
const [showPipeline, setShowPipeline] = useState(false)
+ // Deploy dialog state
+ const [deployOpen, setDeployOpen] = useState(false)
+ const [deployTargetUrl, setDeployTargetUrl] = useState("")
+ const [deploySpaceId, setDeploySpaceId] = useState("")
+ const [deployCatalogMappings, setDeployCatalogMappings] = useState<{ source: string; target: string }[]>([])
+ const [deploying, setDeploying] = useState(false)
+ const [deployError, setDeployError] = useState(null)
+ const [deploySuccess, setDeploySuccess] = useState(false)
+
useEffect(() => {
getAutoOptimizeRun(runId)
.then((r) => {
@@ -157,8 +182,8 @@ export function RunDetailView({ runId, onBack }: RunDetailViewProps) {
)}
- {/* Deployment Status Banner */}
- {run.deployTarget && (
+ {/* Deployment Status Banner (when already deployed) */}
+ {run.deployTarget && run.deploymentStatus && (
) : run.deploymentStatus === "FAILED" ? (
- ) : run.deploymentStatus === "PENDING_APPROVAL" ? (
-
) : (
-
+
)}
- {run.deploymentStatus === "DEPLOYED"
- ? "Deployed to target workspace"
- : run.deploymentStatus === "PENDING_APPROVAL"
- ? "Deployment awaiting approval"
- : run.deploymentStatus === "FAILED"
- ? "Deployment failed"
- : `Deployment: ${run.deploymentStatus ?? "configured"}`}
+ {run.deploymentStatus === "DEPLOYED" ? "Deployed to target workspace"
+ : run.deploymentStatus === "PENDING_APPROVAL" ? "Deployment awaiting approval"
+ : run.deploymentStatus === "FAILED" ? "Deployment failed"
+ : `Deployment: ${run.deploymentStatus}`}
-
- Target: {run.deployTarget}
-
+
Target: {run.deployTarget}
{run.deploymentStatus === "DEPLOYED" && (
-
- Open Workspace
-
+
+ Open Workspace
)}
)}
+ {/* Deploy to Workspace — on-demand for completed runs */}
+ {TERMINAL_STATUSES.has(run.status) && !run.deploymentStatus && (
+
+
{
+ if (!deployOpen) {
+ const saved = loadDeployConfig()
+ setDeployTargetUrl(saved.targetUrl)
+ setDeploySpaceId(saved.spaceId)
+ setDeployCatalogMappings(saved.catalogMappings)
+ }
+ setDeployOpen(!deployOpen)
+ }}
+ className="w-full flex items-center gap-2 px-4 py-3 text-sm font-medium text-secondary hover:text-primary transition-colors"
+ >
+ {deployOpen ? : }
+
+ Deploy to Workspace
+ {deploySuccess && Triggered }
+
+ {deployOpen && (
+
+
+ Deploy this optimized config to another workspace. Previous settings are remembered.
+
+
+ Target workspace URL
+ setDeployTargetUrl(e.target.value)}
+ placeholder="https://my-prod-workspace.cloud.databricks.com"
+ className="w-full px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" />
+
+
+ Target space ID (optional)
+ setDeploySpaceId(e.target.value)}
+ placeholder="01f1347d7f1516ceaea7e5853166498f"
+ className="w-full px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" />
+
+
+
+
Catalog mapping (source → target)
+
setDeployCatalogMappings([...deployCatalogMappings, { source: "", target: "" }])}
+ className="flex items-center gap-1 text-xs text-accent hover:text-accent/80 transition-colors">
+ Add mapping
+
+
+ {deployCatalogMappings.map((m, i) => (
+
+ { const next = [...deployCatalogMappings]; next[i] = { ...next[i], source: e.target.value }; setDeployCatalogMappings(next) }}
+ placeholder="dev_catalog"
+ className="flex-1 px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" />
+ →
+ { const next = [...deployCatalogMappings]; next[i] = { ...next[i], target: e.target.value }; setDeployCatalogMappings(next) }}
+ placeholder="prod_catalog"
+ className="flex-1 px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" />
+ setDeployCatalogMappings(deployCatalogMappings.filter((_, j) => j !== i))}
+ className="text-muted hover:text-danger transition-colors">
+
+
+
+ ))}
+ {deployCatalogMappings.length === 0 && (
+
No catalog mappings — table references will be used as-is
+ )}
+
+ {deployError && (
+
{deployError}
+ )}
+ {deploySuccess && (
+
+ Deployment job triggered successfully.
+
+ )}
+
{
+ if (!deployTargetUrl.trim()) { setDeployError("Target workspace URL is required"); return }
+ setDeploying(true); setDeployError(null); setDeploySuccess(false)
+ const catalogMap = deployCatalogMappings.reduce>((acc, m) => {
+ if (m.source.trim() && m.target.trim()) acc[m.source.trim()] = m.target.trim()
+ return acc
+ }, {})
+ try {
+ await deployOptimizationRun(runId, {
+ target_workspace_url: deployTargetUrl.trim(),
+ target_space_id: deploySpaceId.trim() || undefined,
+ catalog_map: Object.keys(catalogMap).length > 0 ? catalogMap : undefined,
+ })
+ saveDeployConfig({ targetUrl: deployTargetUrl.trim(), spaceId: deploySpaceId.trim(), catalogMappings: deployCatalogMappings })
+ setDeploySuccess(true)
+ } catch (e) {
+ setDeployError(e instanceof Error ? e.message : "Deployment failed")
+ } finally {
+ setDeploying(false)
+ }
+ }}
+ disabled={deploying || !deployTargetUrl.trim()}
+ className="flex items-center gap-2 px-4 py-2 rounded-lg bg-accent text-white font-medium text-sm hover:bg-accent/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
+ >
+
+ {deploying ? "Deploying..." : "Deploy"}
+
+
+ )}
+
+ )}
+
{/* Tabs */}
}
+): Promise<{ jobRunId: string; status: string }> {
+ return fetchWithTimeout<{ jobRunId: string; status: string }>(
+ `${API_BASE}/auto-optimize/runs/${runId}/deploy`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(config),
+ },
+ LONG_TIMEOUT
+ )
+}
+
export async function getAutoOptimizeRun(runId: string): Promise {
return fetchWithTimeout(`${API_BASE}/auto-optimize/runs/${runId}`)
}
From 6bd8131c937ef0e4ee50e381f656923ae35280e6 Mon Sep 17 00:00:00 2001
From: louiscsq
Date: Sat, 18 Apr 2026 15:37:59 +1000
Subject: [PATCH 05/13] =?UTF-8?q?Add=20Deploy=20as=20top-level=20tab:=20Sc?=
=?UTF-8?q?ore=20=E2=86=92=20Optimize=20=E2=86=92=20Deploy?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New DeployTab component:
- Top-level tab in SpaceDetail alongside Score, Optimize, History
- Deployment config: target workspace URL, space ID, catalog mappings
- Config remembered via localStorage across sessions
- Shows completed optimization runs as selectable cards
- Deploy button triggers cross-env deployment job
- Success/error feedback with link to target workspace
RunDetailView cleaned up:
- Removed deploy dialog (moved to DeployTab)
- Kept deployment status banner for already-deployed runs
Co-authored-by: Isaac
---
.../auto-optimize/RunDetailView.tsx | 136 +-------
frontend/src/pages/DeployTab.tsx | 294 ++++++++++++++++++
frontend/src/pages/SpaceDetail.tsx | 11 +-
3 files changed, 303 insertions(+), 138 deletions(-)
create mode 100644 frontend/src/pages/DeployTab.tsx
diff --git a/frontend/src/components/auto-optimize/RunDetailView.tsx b/frontend/src/components/auto-optimize/RunDetailView.tsx
index 39c357280..c34801732 100644
--- a/frontend/src/components/auto-optimize/RunDetailView.tsx
+++ b/frontend/src/components/auto-optimize/RunDetailView.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"
-import { ArrowLeft, ChevronDown, ChevronRight, Cog, UserCheck, ExternalLink, Plus, Rocket, Trash2, Upload, CheckCircle2, Clock, AlertTriangle } from "lucide-react"
+import { ArrowLeft, Cog, UserCheck, ExternalLink, Upload, CheckCircle2, Clock, AlertTriangle } from "lucide-react"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { ScoreSummary } from "@/components/auto-optimize/ScoreSummary"
@@ -9,7 +9,6 @@ import { PipelineDetailsModal } from "@/components/auto-optimize/PipelineDetails
import {
getAutoOptimizeRun,
getAutoOptimizeQuestionResults,
- deployOptimizationRun,
} from "@/lib/api"
import type { GSOPipelineRun, GSOQuestionDetail } from "@/types"
@@ -20,21 +19,6 @@ interface RunDetailViewProps {
type EvalTab = "baseline" | "final"
-const DEPLOY_STORAGE_KEY = "genie-workbench:deploy-config"
-const TERMINAL_STATUSES = new Set(["CONVERGED", "STALLED", "MAX_ITERATIONS", "APPLIED"])
-
-function loadDeployConfig(): { targetUrl: string; spaceId: string; catalogMappings: { source: string; target: string }[] } {
- try {
- const raw = localStorage.getItem(DEPLOY_STORAGE_KEY)
- if (raw) return JSON.parse(raw)
- } catch {}
- return { targetUrl: "", spaceId: "", catalogMappings: [] }
-}
-
-function saveDeployConfig(config: { targetUrl: string; spaceId: string; catalogMappings: { source: string; target: string }[] }) {
- localStorage.setItem(DEPLOY_STORAGE_KEY, JSON.stringify(config))
-}
-
const STATUS_VARIANT: Record = {
CONVERGED: "success",
APPLIED: "success",
@@ -56,15 +40,6 @@ export function RunDetailView({ runId, onBack }: RunDetailViewProps) {
const [selectedQuestionId, setSelectedQuestionId] = useState(null)
const [showPipeline, setShowPipeline] = useState(false)
- // Deploy dialog state
- const [deployOpen, setDeployOpen] = useState(false)
- const [deployTargetUrl, setDeployTargetUrl] = useState("")
- const [deploySpaceId, setDeploySpaceId] = useState("")
- const [deployCatalogMappings, setDeployCatalogMappings] = useState<{ source: string; target: string }[]>([])
- const [deploying, setDeploying] = useState(false)
- const [deployError, setDeployError] = useState(null)
- const [deploySuccess, setDeploySuccess] = useState(false)
-
useEffect(() => {
getAutoOptimizeRun(runId)
.then((r) => {
@@ -220,115 +195,6 @@ export function RunDetailView({ runId, onBack }: RunDetailViewProps) {
)}
- {/* Deploy to Workspace — on-demand for completed runs */}
- {TERMINAL_STATUSES.has(run.status) && !run.deploymentStatus && (
-
-
{
- if (!deployOpen) {
- const saved = loadDeployConfig()
- setDeployTargetUrl(saved.targetUrl)
- setDeploySpaceId(saved.spaceId)
- setDeployCatalogMappings(saved.catalogMappings)
- }
- setDeployOpen(!deployOpen)
- }}
- className="w-full flex items-center gap-2 px-4 py-3 text-sm font-medium text-secondary hover:text-primary transition-colors"
- >
- {deployOpen ? : }
-
- Deploy to Workspace
- {deploySuccess && Triggered }
-
- {deployOpen && (
-
-
- Deploy this optimized config to another workspace. Previous settings are remembered.
-
-
- Target workspace URL
- setDeployTargetUrl(e.target.value)}
- placeholder="https://my-prod-workspace.cloud.databricks.com"
- className="w-full px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" />
-
-
- Target space ID (optional)
- setDeploySpaceId(e.target.value)}
- placeholder="01f1347d7f1516ceaea7e5853166498f"
- className="w-full px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" />
-
-
-
-
Catalog mapping (source → target)
-
setDeployCatalogMappings([...deployCatalogMappings, { source: "", target: "" }])}
- className="flex items-center gap-1 text-xs text-accent hover:text-accent/80 transition-colors">
- Add mapping
-
-
- {deployCatalogMappings.map((m, i) => (
-
- { const next = [...deployCatalogMappings]; next[i] = { ...next[i], source: e.target.value }; setDeployCatalogMappings(next) }}
- placeholder="dev_catalog"
- className="flex-1 px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" />
- →
- { const next = [...deployCatalogMappings]; next[i] = { ...next[i], target: e.target.value }; setDeployCatalogMappings(next) }}
- placeholder="prod_catalog"
- className="flex-1 px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" />
- setDeployCatalogMappings(deployCatalogMappings.filter((_, j) => j !== i))}
- className="text-muted hover:text-danger transition-colors">
-
-
-
- ))}
- {deployCatalogMappings.length === 0 && (
-
No catalog mappings — table references will be used as-is
- )}
-
- {deployError && (
-
{deployError}
- )}
- {deploySuccess && (
-
- Deployment job triggered successfully.
-
- )}
-
{
- if (!deployTargetUrl.trim()) { setDeployError("Target workspace URL is required"); return }
- setDeploying(true); setDeployError(null); setDeploySuccess(false)
- const catalogMap = deployCatalogMappings.reduce>((acc, m) => {
- if (m.source.trim() && m.target.trim()) acc[m.source.trim()] = m.target.trim()
- return acc
- }, {})
- try {
- await deployOptimizationRun(runId, {
- target_workspace_url: deployTargetUrl.trim(),
- target_space_id: deploySpaceId.trim() || undefined,
- catalog_map: Object.keys(catalogMap).length > 0 ? catalogMap : undefined,
- })
- saveDeployConfig({ targetUrl: deployTargetUrl.trim(), spaceId: deploySpaceId.trim(), catalogMappings: deployCatalogMappings })
- setDeploySuccess(true)
- } catch (e) {
- setDeployError(e instanceof Error ? e.message : "Deployment failed")
- } finally {
- setDeploying(false)
- }
- }}
- disabled={deploying || !deployTargetUrl.trim()}
- className="flex items-center gap-2 px-4 py-2 rounded-lg bg-accent text-white font-medium text-sm hover:bg-accent/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
- >
-
- {deploying ? "Deploying..." : "Deploy"}
-
-
- )}
-
- )}
{/* Tabs */}
diff --git a/frontend/src/pages/DeployTab.tsx b/frontend/src/pages/DeployTab.tsx
new file mode 100644
index 000000000..fa1730d33
--- /dev/null
+++ b/frontend/src/pages/DeployTab.tsx
@@ -0,0 +1,294 @@
+/**
+ * DeployTab — Cross-workspace deployment for optimized Genie Spaces.
+ * Shows deployment config (remembered via localStorage), completed runs to deploy, and deployment status.
+ */
+import { useState, useEffect } from "react"
+import { CheckCircle2, ExternalLink, Plus, Rocket, Trash2, Upload } from "lucide-react"
+import { Badge } from "@/components/ui/badge"
+import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
+import { getAutoOptimizeRunsForSpace, deployOptimizationRun } from "@/lib/api"
+import type { GSORunSummary } from "@/types"
+
+const DEPLOY_STORAGE_KEY = "genie-workbench:deploy-config"
+const TERMINAL_STATUSES = new Set(["CONVERGED", "STALLED", "MAX_ITERATIONS", "APPLIED"])
+
+interface DeployConfig {
+ targetUrl: string
+ spaceId: string
+ catalogMappings: { source: string; target: string }[]
+}
+
+function loadDeployConfig(): DeployConfig {
+ try {
+ const raw = localStorage.getItem(DEPLOY_STORAGE_KEY)
+ if (raw) return JSON.parse(raw)
+ } catch {}
+ return { targetUrl: "", spaceId: "", catalogMappings: [] }
+}
+
+function saveDeployConfig(config: DeployConfig) {
+ localStorage.setItem(DEPLOY_STORAGE_KEY, JSON.stringify(config))
+}
+
+interface DeployTabProps {
+ spaceId: string
+}
+
+export function DeployTab({ spaceId }: DeployTabProps) {
+ // Config state (loaded from localStorage)
+ const [targetUrl, setTargetUrl] = useState("")
+ const [targetSpaceId, setTargetSpaceId] = useState("")
+ const [catalogMappings, setCatalogMappings] = useState<{ source: string; target: string }[]>([])
+
+ // Runs
+ const [runs, setRuns] = useState
([])
+ const [runsLoading, setRunsLoading] = useState(true)
+ const [selectedRunId, setSelectedRunId] = useState(null)
+
+ // Deploy state
+ const [deploying, setDeploying] = useState(false)
+ const [deployError, setDeployError] = useState(null)
+ const [deploySuccess, setDeploySuccess] = useState(false)
+
+ // Load config from localStorage on mount
+ useEffect(() => {
+ const saved = loadDeployConfig()
+ setTargetUrl(saved.targetUrl)
+ setTargetSpaceId(saved.spaceId)
+ setCatalogMappings(saved.catalogMappings)
+ }, [])
+
+ // Fetch completed runs
+ useEffect(() => {
+ setRunsLoading(true)
+ getAutoOptimizeRunsForSpace(spaceId)
+ .then((allRuns) => {
+ const completed = allRuns.filter((r) => TERMINAL_STATUSES.has(r.status))
+ setRuns(completed)
+ if (completed.length > 0 && !selectedRunId) {
+ setSelectedRunId(completed[0].run_id)
+ }
+ })
+ .catch(() => setRuns([]))
+ .finally(() => setRunsLoading(false))
+ }, [spaceId])
+
+ async function handleDeploy() {
+ if (!selectedRunId || !targetUrl.trim()) return
+ setDeploying(true)
+ setDeployError(null)
+ setDeploySuccess(false)
+
+ const catalogMap = catalogMappings.reduce>((acc, m) => {
+ if (m.source.trim() && m.target.trim()) acc[m.source.trim()] = m.target.trim()
+ return acc
+ }, {})
+
+ try {
+ await deployOptimizationRun(selectedRunId, {
+ target_workspace_url: targetUrl.trim(),
+ target_space_id: targetSpaceId.trim() || undefined,
+ catalog_map: Object.keys(catalogMap).length > 0 ? catalogMap : undefined,
+ })
+ saveDeployConfig({ targetUrl: targetUrl.trim(), spaceId: targetSpaceId.trim(), catalogMappings })
+ setDeploySuccess(true)
+ } catch (e) {
+ setDeployError(e instanceof Error ? e.message : "Deployment failed")
+ } finally {
+ setDeploying(false)
+ }
+ }
+
+ return (
+
+ {/* Deployment Config */}
+
+
+
+
+ Deploy to Workspace
+
+
+
+
+ Deploy an optimized Genie Space config to a target workspace. Settings are remembered for next time.
+
+
+
+
+
+
+
+ Catalog mapping (source → target)
+
+
setCatalogMappings([...catalogMappings, { source: "", target: "" }])}
+ className="flex items-center gap-1 text-xs text-accent hover:text-accent/80 transition-colors"
+ >
+ Add mapping
+
+
+ {catalogMappings.map((m, i) => (
+
+ {
+ const next = [...catalogMappings]
+ next[i] = { ...next[i], source: e.target.value }
+ setCatalogMappings(next)
+ }}
+ placeholder="dev_catalog"
+ className="flex-1 px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+ →
+ {
+ const next = [...catalogMappings]
+ next[i] = { ...next[i], target: e.target.value }
+ setCatalogMappings(next)
+ }}
+ placeholder="prod_catalog"
+ className="flex-1 px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+ setCatalogMappings(catalogMappings.filter((_, j) => j !== i))}
+ className="text-muted hover:text-danger transition-colors"
+ >
+
+
+
+ ))}
+ {catalogMappings.length === 0 && (
+
No catalog mappings — table references will be used as-is
+ )}
+
+
+
+
+ {/* Select a completed run */}
+
+
+ Select a completed run to deploy
+
+
+ {runsLoading ? (
+ Loading optimization runs...
+ ) : runs.length === 0 ? (
+
+ No completed optimization runs yet. Run an optimization first on the Optimize tab.
+
+ ) : (
+
+ {runs.map((run) => (
+
setSelectedRunId(run.run_id)}
+ className={`w-full flex items-center justify-between px-4 py-3 rounded-lg border text-left transition-colors ${
+ selectedRunId === run.run_id
+ ? "border-accent bg-accent/5"
+ : "border-default hover:bg-elevated"
+ }`}
+ >
+
+
+
+
+ {run.started_at ? new Date(run.started_at).toLocaleDateString(undefined, {
+ day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit",
+ }) : "Unknown date"}
+
+
+ {run.triggered_by || "system"}
+
+
+
+
+
+ {run.status}
+
+ {run.best_accuracy != null && (
+
+ {Math.round(run.best_accuracy * 100)}%
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+
+ {/* Deploy button + status */}
+ {deployError && (
+
+ {deployError}
+
+ )}
+
+ {deploySuccess && (
+
+
+
+
+ Deployment job triggered
+
+
+ Target: {targetUrl}
+
+
+ {targetUrl && (
+
+ Open Workspace
+
+
+ )}
+
+ )}
+
+
+
+ {deploying ? "Deploying..." : "Deploy Selected Run"}
+
+
+ )
+}
diff --git a/frontend/src/pages/SpaceDetail.tsx b/frontend/src/pages/SpaceDetail.tsx
index efab5f35d..db9dec6f0 100644
--- a/frontend/src/pages/SpaceDetail.tsx
+++ b/frontend/src/pages/SpaceDetail.tsx
@@ -4,7 +4,7 @@
* Score tab includes an inline FixAgentPanel that slides in from the right.
*/
import { useState, useEffect, useRef } from "react"
-import { ArrowLeft, Star, BarChart2, Clock, ExternalLink, Rocket, Play, Zap, ChevronDown, ChevronRight, Settings, RefreshCw } from "lucide-react"
+import { ArrowLeft, Star, BarChart2, Clock, ExternalLink, Rocket, Play, Upload, Zap, ChevronDown, ChevronRight, Settings, RefreshCw } from "lucide-react"
import { scanSpace, toggleStar, getSpaceHistory, getSpaceDetail, getActiveRunForSpace } from "@/lib/api"
import { MATURITY_COLORS, getOptimizationLabel } from "@/lib/utils"
import type { ScanResult, ScoreHistoryPoint, OptimizationEvent } from "@/types"
@@ -13,10 +13,11 @@ import { HistoryTab } from "./HistoryTab"
import { useAnalysis } from "@/hooks/useAnalysis"
import { SpaceOverview } from "@/components/SpaceOverview"
import { AutoOptimizeTab } from "@/components/auto-optimize/AutoOptimizeTab"
+import { DeployTab } from "./DeployTab"
import { FixAgentPanel } from "@/components/FixAgentPanel"
-type Tab = "score" | "optimize" | "history"
-const VALID_TABS: readonly string[] = ["score", "optimize", "history"]
+type Tab = "score" | "optimize" | "history" | "deploy"
+const VALID_TABS: readonly string[] = ["score", "optimize", "history", "deploy"]
interface SpaceDetailProps {
spaceId: string
@@ -162,6 +163,7 @@ export function SpaceDetail({ spaceId, displayName, spaceUrl, initialTab, autoSc
{ id: "score", label: "Score", icon: },
{ id: "optimize", label: "Optimize", icon: },
{ id: "history", label: "History", icon: },
+ { id: "deploy", label: "Deploy", icon: },
]
// Determine contextual action(s) based on scan results
@@ -322,6 +324,9 @@ export function SpaceDetail({ spaceId, displayName, spaceUrl, initialTab, autoSc
{activeTab === "history" && (
)}
+ {activeTab === "deploy" && (
+
+ )}
{/* Fix agent modal overlay */}
From 6bb005eab41006947c38a803f76f12cd193aa80b Mon Sep 17 00:00:00 2001
From: louiscsq
Date: Sat, 18 Apr 2026 15:43:43 +1000
Subject: [PATCH 06/13] Remove unused Upload import from RunDetailView
Co-authored-by: Isaac
---
frontend/src/components/auto-optimize/RunDetailView.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/src/components/auto-optimize/RunDetailView.tsx b/frontend/src/components/auto-optimize/RunDetailView.tsx
index c34801732..b8585933e 100644
--- a/frontend/src/components/auto-optimize/RunDetailView.tsx
+++ b/frontend/src/components/auto-optimize/RunDetailView.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"
-import { ArrowLeft, Cog, UserCheck, ExternalLink, Upload, CheckCircle2, Clock, AlertTriangle } from "lucide-react"
+import { ArrowLeft, Cog, UserCheck, ExternalLink, CheckCircle2, Clock, AlertTriangle } from "lucide-react"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { ScoreSummary } from "@/components/auto-optimize/ScoreSummary"
From 5f7a41d18d024b29acf21d779120c81cb7503e4f Mon Sep 17 00:00:00 2001
From: louiscsq
Date: Sat, 18 Apr 2026 15:56:14 +1000
Subject: [PATCH 07/13] =?UTF-8?q?Fix=20error=20display=20in=20DeployTab=20?=
=?UTF-8?q?=E2=80=94=20handle=20non-Error=20objects?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Isaac
---
frontend/src/pages/DeployTab.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/frontend/src/pages/DeployTab.tsx b/frontend/src/pages/DeployTab.tsx
index fa1730d33..d01351d73 100644
--- a/frontend/src/pages/DeployTab.tsx
+++ b/frontend/src/pages/DeployTab.tsx
@@ -93,7 +93,8 @@ export function DeployTab({ spaceId }: DeployTabProps) {
saveDeployConfig({ targetUrl: targetUrl.trim(), spaceId: targetSpaceId.trim(), catalogMappings })
setDeploySuccess(true)
} catch (e) {
- setDeployError(e instanceof Error ? e.message : "Deployment failed")
+ const msg = e instanceof Error ? e.message : typeof e === "object" ? JSON.stringify(e) : String(e)
+ setDeployError(msg || "Deployment failed")
} finally {
setDeploying(false)
}
From 8de0d23c63c9ebe9ab1661a795e1a1e9e419fbdf Mon Sep 17 00:00:00 2001
From: louiscsq
Date: Sat, 18 Apr 2026 16:01:56 +1000
Subject: [PATCH 08/13] Simplify deploy: fetch space config directly, no UC
model needed
Backend:
- Replace POST /runs/{run_id}/deploy with POST /spaces/{space_id}/deploy
- Fetches current space config via Genie API (get_serialized_space)
- Applies catalog remapping in-memory
- PATCHes directly to target workspace via patch_space_config
- No UC model, no optimization run, no deployment job required
Frontend:
- Simplify DeployTab: remove run selection (not needed)
- Just enter target config and click Deploy
- deploySpace() API replaces deployOptimizationRun()
Co-authored-by: Isaac
---
backend/routers/auto_optimize.py | 75 +++++++++---------
frontend/src/lib/api.ts | 10 +--
frontend/src/pages/DeployTab.tsx | 132 ++++++-------------------------
3 files changed, 68 insertions(+), 149 deletions(-)
diff --git a/backend/routers/auto_optimize.py b/backend/routers/auto_optimize.py
index d22b1c425..dfff16bd4 100644
--- a/backend/routers/auto_optimize.py
+++ b/backend/routers/auto_optimize.py
@@ -889,49 +889,48 @@ class DeployRequest(BaseModel):
catalog_map: dict[str, str] | None = None
-@router.post("/runs/{run_id}/deploy")
-async def deploy_run(run_id: RunId, body: DeployRequest):
- """Trigger cross-workspace deployment for a completed optimization run."""
- if not _is_configured():
- raise HTTPException(status_code=503, detail="Auto-Optimize is not configured.")
+@router.post("/spaces/{space_id}/deploy")
+async def deploy_space(space_id: str, body: DeployRequest):
+ """Deploy a Genie Space config to a target workspace.
- # Load run to get UC model info
- run = await gso_lakebase.load_gso_run(run_id)
- if not run and _is_configured():
- config = _build_gso_config()
- run = _fetch_run_via_sql(run_id, config)
- if not run:
- raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
+ Fetches the current space config, applies catalog remapping, and
+ PATCHes it to the target workspace. No UC model or optimization run
+ required — works with any Genie Space.
+ """
+ import json as _json
- status = run.get("status", "")
- terminal = {"CONVERGED", "STALLED", "MAX_ITERATIONS", "APPLIED"}
- if status not in terminal:
- raise HTTPException(status_code=400, detail=f"Run is {status} — can only deploy terminal runs")
+ # 1. Fetch source space config
+ try:
+ from backend.services.genie_client import get_serialized_space
+ space_config = get_serialized_space(genie_space_id=space_id)
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=f"Failed to fetch space config: {e}")
+
+ # 2. Apply catalog remapping
+ if body.catalog_map:
+ remapped = 0
+ for key in ("tables", "metric_views"):
+ for src in space_config.get("data_sources", {}).get(key, []):
+ ident = src.get("identifier", "")
+ parts = ident.replace("`", "").split(".")
+ if len(parts) >= 3 and parts[0] in body.catalog_map:
+ parts[0] = body.catalog_map[parts[0]]
+ src["identifier"] = ".".join(parts)
+ remapped += 1
+ logger.info("Remapped %d catalog references for deploy", remapped)
+
+ # 3. PATCH to target workspace
+ try:
+ from databricks.sdk import WorkspaceClient
+ target_ws = WorkspaceClient(host=body.target_workspace_url.rstrip("/"))
+ target_space = body.target_space_id or space_id
- # Get model name/version from the run's finalize output
- model_name = run.get("uc_model_name") or run.get("model_name")
- model_version = run.get("uc_model_version") or run.get("model_version")
- if not model_name or not model_version:
- raise HTTPException(status_code=400, detail="Run has no registered UC model — cannot deploy")
+ from genie_space_optimizer.common.genie_client import patch_space_config
+ result = patch_space_config(target_ws, target_space, space_config)
- try:
- import json as _json
- from genie_space_optimizer.backend.job_launcher import ensure_deployment_job
- sp_ws = get_service_principal_client()
- config = _build_gso_config()
-
- job_run_id = ensure_deployment_job(
- ws=sp_ws,
- job_id=config.job_id,
- model_name=model_name,
- model_version=str(model_version),
- target_workspace_url=body.target_workspace_url,
- target_space_id=body.target_space_id or "",
- catalog_map=_json.dumps(body.catalog_map) if body.catalog_map else "",
- )
- return {"jobRunId": str(job_run_id), "status": "DEPLOYING"}
+ return {"status": "DEPLOYED", "targetSpaceId": target_space, "targetUrl": body.target_workspace_url}
except Exception as e:
- logger.exception("Failed to trigger deployment: %s", e)
+ logger.exception("Failed to deploy to target workspace: %s", e)
raise HTTPException(status_code=500, detail=f"Deployment failed: {e}")
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 1ee31df17..41bdb5794 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -407,12 +407,12 @@ export async function triggerAutoOptimize(request: GSOTriggerRequest): Promise }
-): Promise<{ jobRunId: string; status: string }> {
- return fetchWithTimeout<{ jobRunId: string; status: string }>(
- `${API_BASE}/auto-optimize/runs/${runId}/deploy`,
+): Promise<{ status: string; targetSpaceId: string; targetUrl: string }> {
+ return fetchWithTimeout<{ status: string; targetSpaceId: string; targetUrl: string }>(
+ `${API_BASE}/auto-optimize/spaces/${spaceId}/deploy`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
diff --git a/frontend/src/pages/DeployTab.tsx b/frontend/src/pages/DeployTab.tsx
index d01351d73..576fa9317 100644
--- a/frontend/src/pages/DeployTab.tsx
+++ b/frontend/src/pages/DeployTab.tsx
@@ -1,16 +1,14 @@
/**
- * DeployTab — Cross-workspace deployment for optimized Genie Spaces.
- * Shows deployment config (remembered via localStorage), completed runs to deploy, and deployment status.
+ * DeployTab — Cross-workspace deployment for Genie Spaces.
+ * Deploys the current space config to a target workspace with optional catalog remapping.
+ * Settings are remembered via localStorage.
*/
import { useState, useEffect } from "react"
import { CheckCircle2, ExternalLink, Plus, Rocket, Trash2, Upload } from "lucide-react"
-import { Badge } from "@/components/ui/badge"
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
-import { getAutoOptimizeRunsForSpace, deployOptimizationRun } from "@/lib/api"
-import type { GSORunSummary } from "@/types"
+import { deploySpace } from "@/lib/api"
const DEPLOY_STORAGE_KEY = "genie-workbench:deploy-config"
-const TERMINAL_STATUSES = new Set(["CONVERGED", "STALLED", "MAX_ITERATIONS", "APPLIED"])
interface DeployConfig {
targetUrl: string
@@ -35,20 +33,13 @@ interface DeployTabProps {
}
export function DeployTab({ spaceId }: DeployTabProps) {
- // Config state (loaded from localStorage)
const [targetUrl, setTargetUrl] = useState("")
const [targetSpaceId, setTargetSpaceId] = useState("")
const [catalogMappings, setCatalogMappings] = useState<{ source: string; target: string }[]>([])
- // Runs
- const [runs, setRuns] = useState([])
- const [runsLoading, setRunsLoading] = useState(true)
- const [selectedRunId, setSelectedRunId] = useState(null)
-
- // Deploy state
const [deploying, setDeploying] = useState(false)
const [deployError, setDeployError] = useState(null)
- const [deploySuccess, setDeploySuccess] = useState(false)
+ const [deploySuccess, setDeploySuccess] = useState<{ targetUrl: string; targetSpaceId: string } | null>(null)
// Load config from localStorage on mount
useEffect(() => {
@@ -58,26 +49,11 @@ export function DeployTab({ spaceId }: DeployTabProps) {
setCatalogMappings(saved.catalogMappings)
}, [])
- // Fetch completed runs
- useEffect(() => {
- setRunsLoading(true)
- getAutoOptimizeRunsForSpace(spaceId)
- .then((allRuns) => {
- const completed = allRuns.filter((r) => TERMINAL_STATUSES.has(r.status))
- setRuns(completed)
- if (completed.length > 0 && !selectedRunId) {
- setSelectedRunId(completed[0].run_id)
- }
- })
- .catch(() => setRuns([]))
- .finally(() => setRunsLoading(false))
- }, [spaceId])
-
async function handleDeploy() {
- if (!selectedRunId || !targetUrl.trim()) return
+ if (!targetUrl.trim()) return
setDeploying(true)
setDeployError(null)
- setDeploySuccess(false)
+ setDeploySuccess(null)
const catalogMap = catalogMappings.reduce>((acc, m) => {
if (m.source.trim() && m.target.trim()) acc[m.source.trim()] = m.target.trim()
@@ -85,13 +61,13 @@ export function DeployTab({ spaceId }: DeployTabProps) {
}, {})
try {
- await deployOptimizationRun(selectedRunId, {
+ const result = await deploySpace(spaceId, {
target_workspace_url: targetUrl.trim(),
target_space_id: targetSpaceId.trim() || undefined,
catalog_map: Object.keys(catalogMap).length > 0 ? catalogMap : undefined,
})
saveDeployConfig({ targetUrl: targetUrl.trim(), spaceId: targetSpaceId.trim(), catalogMappings })
- setDeploySuccess(true)
+ setDeploySuccess({ targetUrl: result.targetUrl, targetSpaceId: result.targetSpaceId })
} catch (e) {
const msg = e instanceof Error ? e.message : typeof e === "object" ? JSON.stringify(e) : String(e)
setDeployError(msg || "Deployment failed")
@@ -112,7 +88,7 @@ export function DeployTab({ spaceId }: DeployTabProps) {
- Deploy an optimized Genie Space config to a target workspace. Settings are remembered for next time.
+ Deploy this Genie Space's current config to a target workspace. Catalog references are remapped automatically. Settings are remembered for next time.
@@ -194,101 +170,45 @@ export function DeployTab({ spaceId }: DeployTabProps) {
- {/* Select a completed run */}
-
-
- Select a completed run to deploy
-
-
- {runsLoading ? (
- Loading optimization runs...
- ) : runs.length === 0 ? (
-
- No completed optimization runs yet. Run an optimization first on the Optimize tab.
-
- ) : (
-
- {runs.map((run) => (
-
setSelectedRunId(run.run_id)}
- className={`w-full flex items-center justify-between px-4 py-3 rounded-lg border text-left transition-colors ${
- selectedRunId === run.run_id
- ? "border-accent bg-accent/5"
- : "border-default hover:bg-elevated"
- }`}
- >
-
-
-
-
- {run.started_at ? new Date(run.started_at).toLocaleDateString(undefined, {
- day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit",
- }) : "Unknown date"}
-
-
- {run.triggered_by || "system"}
-
-
-
-
-
- {run.status}
-
- {run.best_accuracy != null && (
-
- {Math.round(run.best_accuracy * 100)}%
-
- )}
-
-
- ))}
-
- )}
-
-
-
- {/* Deploy button + status */}
+ {/* Error */}
{deployError && (
{deployError}
)}
+ {/* Success */}
{deploySuccess && (
- Deployment job triggered
+ Deployed successfully
- Target: {targetUrl}
+ Space config deployed to {deploySuccess.targetUrl} (space: {deploySuccess.targetSpaceId})
- {targetUrl && (
-
- Open Workspace
-
-
- )}
+
+ Open Workspace
+
+
)}
+ {/* Deploy button */}
- {deploying ? "Deploying..." : "Deploy Selected Run"}
+ {deploying ? "Deploying..." : "Deploy to Target Workspace"}
)
From f726bb019ffa5d1d13ebd17fab997cb7dc58efaf Mon Sep 17 00:00:00 2001
From: louiscsq
Date: Sat, 18 Apr 2026 16:17:49 +1000
Subject: [PATCH 09/13] Fix deploy auth: use OBO token for cross-workspace
PATCH
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The app SP isn't registered in the target workspace. Use the user's
OBO token instead — the user is authenticated in both workspaces.
Co-authored-by: Isaac
---
backend/routers/auto_optimize.py | 15 +++++++++++++--
1 file changed, 13 insertions(+), 2 deletions(-)
diff --git a/backend/routers/auto_optimize.py b/backend/routers/auto_optimize.py
index dfff16bd4..b808c3922 100644
--- a/backend/routers/auto_optimize.py
+++ b/backend/routers/auto_optimize.py
@@ -919,10 +919,21 @@ async def deploy_space(space_id: str, body: DeployRequest):
remapped += 1
logger.info("Remapped %d catalog references for deploy", remapped)
- # 3. PATCH to target workspace
+ # 3. PATCH to target workspace (use OBO token so user's identity authenticates)
try:
from databricks.sdk import WorkspaceClient
- target_ws = WorkspaceClient(host=body.target_workspace_url.rstrip("/"))
+ from databricks.sdk.config import Config
+ from backend.services.auth import get_workspace_client
+ obo_ws = get_workspace_client()
+ obo_token = obo_ws.config.token
+ target_cfg = Config(
+ host=body.target_workspace_url.rstrip("/"),
+ token=obo_token,
+ auth_type="pat",
+ client_id=None,
+ client_secret=None,
+ )
+ target_ws = WorkspaceClient(config=target_cfg)
target_space = body.target_space_id or space_id
from genie_space_optimizer.common.genie_client import patch_space_config
From 5838c8ace61959c8e73d991e1f968fb6aa598efd Mon Sep 17 00:00:00 2001
From: louiscsq
Date: Sat, 18 Apr 2026 16:20:42 +1000
Subject: [PATCH 10/13] Revert to SP auth for cross-workspace deploy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Use the app's SP credentials (client_id + client_secret) to
authenticate to the target workspace. The SP must be registered
in the target workspace — this is a one-time admin setup.
Co-authored-by: Isaac
---
backend/routers/auto_optimize.py | 18 +++++++-----------
1 file changed, 7 insertions(+), 11 deletions(-)
diff --git a/backend/routers/auto_optimize.py b/backend/routers/auto_optimize.py
index b808c3922..3e0f38f05 100644
--- a/backend/routers/auto_optimize.py
+++ b/backend/routers/auto_optimize.py
@@ -919,21 +919,17 @@ async def deploy_space(space_id: str, body: DeployRequest):
remapped += 1
logger.info("Remapped %d catalog references for deploy", remapped)
- # 3. PATCH to target workspace (use OBO token so user's identity authenticates)
+ # 3. PATCH to target workspace using the app's SP credentials.
+ # The SP must be registered in the target workspace with CAN_MANAGE
+ # on the target Genie Space.
try:
from databricks.sdk import WorkspaceClient
- from databricks.sdk.config import Config
- from backend.services.auth import get_workspace_client
- obo_ws = get_workspace_client()
- obo_token = obo_ws.config.token
- target_cfg = Config(
+ sp_ws = get_service_principal_client()
+ target_ws = WorkspaceClient(
host=body.target_workspace_url.rstrip("/"),
- token=obo_token,
- auth_type="pat",
- client_id=None,
- client_secret=None,
+ client_id=sp_ws.config.client_id,
+ client_secret=sp_ws.config.client_secret,
)
- target_ws = WorkspaceClient(config=target_cfg)
target_space = body.target_space_id or space_id
from genie_space_optimizer.common.genie_client import patch_space_config
From 28c5bdd0df5b2a723a8456de0516753e2f0269d9 Mon Sep 17 00:00:00 2001
From: louiscsq
Date: Sat, 18 Apr 2026 16:30:30 +1000
Subject: [PATCH 11/13] Handle deploy to new space: create when target space ID
is blank
When target space ID is not provided, create a new Genie Space in
the target workspace using POST /api/2.0/genie/spaces with:
- Title from the source space
- First available SQL warehouse in target workspace
- Parent path /Shared/
- Remapped serialized config
When target space ID is provided, PATCH the existing space as before.
Also return spaceUrl in the response so the frontend can link
directly to the newly created Genie Space.
Co-authored-by: Isaac
---
backend/routers/auto_optimize.py | 51 +++++++++++++++++++++++++++-----
frontend/src/lib/api.ts | 4 +--
frontend/src/pages/DeployTab.tsx | 8 ++---
3 files changed, 50 insertions(+), 13 deletions(-)
diff --git a/backend/routers/auto_optimize.py b/backend/routers/auto_optimize.py
index 3e0f38f05..ff5255226 100644
--- a/backend/routers/auto_optimize.py
+++ b/backend/routers/auto_optimize.py
@@ -919,9 +919,8 @@ async def deploy_space(space_id: str, body: DeployRequest):
remapped += 1
logger.info("Remapped %d catalog references for deploy", remapped)
- # 3. PATCH to target workspace using the app's SP credentials.
- # The SP must be registered in the target workspace with CAN_MANAGE
- # on the target Genie Space.
+ # 3. Connect to target workspace using the app's SP credentials.
+ # The SP must be registered in the target workspace.
try:
from databricks.sdk import WorkspaceClient
sp_ws = get_service_principal_client()
@@ -930,12 +929,50 @@ async def deploy_space(space_id: str, body: DeployRequest):
client_id=sp_ws.config.client_id,
client_secret=sp_ws.config.client_secret,
)
- target_space = body.target_space_id or space_id
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Failed to connect to target workspace: {e}")
- from genie_space_optimizer.common.genie_client import patch_space_config
- result = patch_space_config(target_ws, target_space, space_config)
+ # 4. Deploy: PATCH existing space or CREATE new one
+ try:
+ if body.target_space_id:
+ # Update existing space
+ from genie_space_optimizer.common.genie_client import patch_space_config
+ patch_space_config(target_ws, body.target_space_id, space_config)
+ target_space = body.target_space_id
+ logger.info("PATCHed existing space %s in target workspace", target_space)
+ else:
+ # Create new space in target workspace
+ from backend.services.genie_client import get_genie_space
+ source_space = get_genie_space(genie_space_id=space_id)
+ display_name = source_space.get("title", "Deployed Genie Space")
+
+ # Find a warehouse in the target workspace
+ warehouses = list(target_ws.warehouses.list())
+ if not warehouses:
+ raise ValueError("No SQL warehouse found in target workspace")
+ target_warehouse_id = warehouses[0].id
+
+ response = target_ws.api_client.do(
+ method="POST",
+ path="/api/2.0/genie/spaces",
+ body={
+ "title": display_name,
+ "description": f"Deployed from source workspace (space {space_id})",
+ "parent_path": "/Shared/",
+ "warehouse_id": target_warehouse_id,
+ "serialized_space": _json.dumps(space_config),
+ },
+ )
+ target_space = response.get("space_id", "")
+ logger.info("Created new space %s in target workspace", target_space)
- return {"status": "DEPLOYED", "targetSpaceId": target_space, "targetUrl": body.target_workspace_url}
+ target_host = body.target_workspace_url.rstrip("/")
+ return {
+ "status": "DEPLOYED",
+ "targetSpaceId": target_space,
+ "targetUrl": target_host,
+ "spaceUrl": f"{target_host}/genie/rooms/{target_space}",
+ }
except Exception as e:
logger.exception("Failed to deploy to target workspace: %s", e)
raise HTTPException(status_code=500, detail=f"Deployment failed: {e}")
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 41bdb5794..70bb35caf 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -410,8 +410,8 @@ export async function triggerAutoOptimize(request: GSOTriggerRequest): Promise }
-): Promise<{ status: string; targetSpaceId: string; targetUrl: string }> {
- return fetchWithTimeout<{ status: string; targetSpaceId: string; targetUrl: string }>(
+): Promise<{ status: string; targetSpaceId: string; targetUrl: string; spaceUrl?: string }> {
+ return fetchWithTimeout<{ status: string; targetSpaceId: string; targetUrl: string; spaceUrl?: string }>(
`${API_BASE}/auto-optimize/spaces/${spaceId}/deploy`,
{
method: "POST",
diff --git a/frontend/src/pages/DeployTab.tsx b/frontend/src/pages/DeployTab.tsx
index 576fa9317..9d02db8c4 100644
--- a/frontend/src/pages/DeployTab.tsx
+++ b/frontend/src/pages/DeployTab.tsx
@@ -39,7 +39,7 @@ export function DeployTab({ spaceId }: DeployTabProps) {
const [deploying, setDeploying] = useState(false)
const [deployError, setDeployError] = useState(null)
- const [deploySuccess, setDeploySuccess] = useState<{ targetUrl: string; targetSpaceId: string } | null>(null)
+ const [deploySuccess, setDeploySuccess] = useState<{ targetUrl: string; targetSpaceId: string; spaceUrl?: string } | null>(null)
// Load config from localStorage on mount
useEffect(() => {
@@ -67,7 +67,7 @@ export function DeployTab({ spaceId }: DeployTabProps) {
catalog_map: Object.keys(catalogMap).length > 0 ? catalogMap : undefined,
})
saveDeployConfig({ targetUrl: targetUrl.trim(), spaceId: targetSpaceId.trim(), catalogMappings })
- setDeploySuccess({ targetUrl: result.targetUrl, targetSpaceId: result.targetSpaceId })
+ setDeploySuccess({ targetUrl: result.targetUrl, targetSpaceId: result.targetSpaceId, spaceUrl: result.spaceUrl })
} catch (e) {
const msg = e instanceof Error ? e.message : typeof e === "object" ? JSON.stringify(e) : String(e)
setDeployError(msg || "Deployment failed")
@@ -190,12 +190,12 @@ export function DeployTab({ spaceId }: DeployTabProps) {
- Open Workspace
+ {deploySuccess.spaceUrl ? "Open Genie Space" : "Open Workspace"}
From e7be82eb06b32bb7872c6fe168a87fed5e429eff Mon Sep 17 00:00:00 2001
From: louiscsq
Date: Sat, 18 Apr 2026 17:06:04 +1000
Subject: [PATCH 12/13] Add prerequisites info to Deploy tab
Document permission requirements: SP registration, Genie Space
permissions, target catalog existence, and table access grants.
Co-authored-by: Isaac
---
frontend/src/pages/DeployTab.tsx | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/frontend/src/pages/DeployTab.tsx b/frontend/src/pages/DeployTab.tsx
index 9d02db8c4..2e4a8e7a6 100644
--- a/frontend/src/pages/DeployTab.tsx
+++ b/frontend/src/pages/DeployTab.tsx
@@ -91,6 +91,16 @@ export function DeployTab({ spaceId }: DeployTabProps) {
Deploy this Genie Space's current config to a target workspace. Catalog references are remapped automatically. Settings are remembered for next time.
+
+
Prerequisites
+
+ The app's service principal must be registered in the target workspace
+ The SP needs permission to create Genie Spaces (or CAN_MANAGE on the target space if updating)
+ Target catalog and tables must exist in the target workspace (e.g. prod_bank.retail.*)
+ The SP needs SELECT on the target catalog's tables
+
+
+
Target workspace URL
From 94bee3dff90d85a96be16afe6e08db7b8220bcbd Mon Sep 17 00:00:00 2001
From: louiscsq
Date: Sat, 18 Apr 2026 17:07:31 +1000
Subject: [PATCH 13/13] Fix prerequisites wording: use correct UC terminology
Catalogs are accessed via shared metastore or target metastore
configuration, not 'exist in the target workspace'. List the
specific UC grants needed (USE_CATALOG, USE_SCHEMA, SELECT).
Co-authored-by: Isaac
---
frontend/src/pages/DeployTab.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/frontend/src/pages/DeployTab.tsx b/frontend/src/pages/DeployTab.tsx
index 2e4a8e7a6..d3fc02ac3 100644
--- a/frontend/src/pages/DeployTab.tsx
+++ b/frontend/src/pages/DeployTab.tsx
@@ -96,8 +96,8 @@ export function DeployTab({ spaceId }: DeployTabProps) {
The app's service principal must be registered in the target workspace
The SP needs permission to create Genie Spaces (or CAN_MANAGE on the target space if updating)
- Target catalog and tables must exist in the target workspace (e.g. prod_bank.retail.*)
- The SP needs SELECT on the target catalog's tables
+ The referenced catalogs must be accessible from the target workspace (shared metastore, or configured in target metastore)
+ The SP needs USE_CATALOG, USE_SCHEMA, and SELECT on the referenced tables