You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/06/28 09:07:46 UTC

(texera) branch gh-readonly-queue/main/pr-5961-01b9254bea697b936724c46c20cdd353f00ba226 created (now de6c31ac48)

This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a change to branch gh-readonly-queue/main/pr-5961-01b9254bea697b936724c46c20cdd353f00ba226
in repository https://gitbox.apache.org/repos/asf/texera.git


      at de6c31ac48 feat(infra): add `bin/local-dev.sh` dev-stack manager + TUI (#5961)

This branch includes the following new commits:

     new de6c31ac48 feat(infra): add `bin/local-dev.sh` dev-stack manager + TUI (#5961)

The 1 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.



(texera) 01/01: feat(infra): add `bin/local-dev.sh` dev-stack manager + TUI (#5961)

Posted by gi...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-5961-01b9254bea697b936724c46c20cdd353f00ba226
in repository https://gitbox.apache.org/repos/asf/texera.git

commit de6c31ac48ddf829b00c78b1d4f45b826236ff9d
Author: Yicong Huang <17...@users.noreply.github.com>
AuthorDate: Sun Jun 28 02:07:25 2026 -0700

    feat(infra): add `bin/local-dev.sh` dev-stack manager + TUI (#5961)
    
    ### What changes were proposed in this PR?
    
    - Introduce `bin/local-dev.sh` to build/start/stop/status/logs for
    docker infra + JVM services + agent-service + frontend.
    - Add `bin/local-dev-tui.py` (Textual) to provide a live dashboard,
    command prompt, and log tailing by shelling out to bin/local-dev.sh.
    - Add tests and CI plumbing (labeler + required-checks stack + build
    workflow job) to run shell smoke tests and pytest unit tests for the
    tooling.
    
    #### Demo 1: Starting Texera Local Dev
    
    https://github.com/user-attachments/assets/beff0dd7-5afa-4de7-8c5c-72f8ed58bfe7
    
    #### Demo 2: Auto-updating Changed Services
    
    https://github.com/user-attachments/assets/c84cfe12-2b9b-4e80-8651-b1375a6a82fb
    
    #### Demo 3: Tailing Service Logs
    
    
    https://github.com/user-attachments/assets/d53b35f4-c821-4fcc-bd11-5dd696946895
    
    
    
    #### What you can do
    
    | Command | What it does |
    | --------------------------------------------- |
    -----------------------------------------------------------------------
    |
    | `bin/local-dev.sh` / `… status` | One-shot dashboard (state · uptime ·
    CPU% · mem · PID per service). |
    | `bin/local-dev.sh -i` | Live Textual TUI of the same dashboard, with a
    command prompt. |
    | `bin/local-dev.sh up` | Build dirty JVMs, install yarn/bun deps if
    locks moved, bring all 14 services up. Bootstraps the postgres schema on
    stale volumes. |
    | `bin/local-dev.sh down` | Stop everything, reverse order. |
    | `bin/local-dev.sh auto` | Detect which services' source changed (`★`)
    and rebuild + bounce only those. |
    | `bin/local-dev.sh <svc>` | Rebuild that one service and bounce it. |
    | `bin/local-dev.sh logs <svc>` | Tail a service's log. |
    
    #### Key behaviours
    
    - **Dirty detection** drives `auto` / the `★` indicator: SHA-1 of source
    bytes against a per-service stamp, walking the per-service transitive
    sbt dependency graph parsed from `build.sbt`. Editing
    `common/workflow-operator/src` doesn't trigger services that don't
    depend on it.
    - **Dynamic everything** — texera version, sbt graph, host LAN IP (for
    the Iceberg/MinIO endpoint that Lakekeeper hands back to host JVMs), JDK
    17 location, Python interpreter, Node toolchain — all auto-detected at
    startup with clear "what we saw / what to install" diagnostics on miss.
    - **Bash 3.2 compatible** — runs on Apple's pinned `/bin/bash` 3.2 with
    no re-exec.
    
    ### Any related issues, documentation, discussions?
    
    Closes #5960.
    
    ### How was this PR tested?
    
    - 36 pytest + 10 shell smoke under bash 3.2 / bash 5 / zsh.
    - Manual cold-start on macOS: `up` brings all 14 services to healthy
    from an empty state; `down` tears them down. Editing `.scala` files
    marks only the dependent services dirty; `auto` rebuilds + bounces just
    those (amber's two siblings always paired). `up --build / --fresh /
    --skip=…` all exercised. Toolchain-missing paths print the diagnostic
    env + install hint.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Anthropic, Claude Opus 4.7).
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <no...@anthropic.com>
---
 .github/labeler.yml                                |   11 +
 .github/workflows/build.yml                        |   42 +
 .github/workflows/required-checks.yml              |    9 +-
 amber/dev-requirements.txt                         |    3 +
 bin/local-dev-tui.py                               | 1651 +++++++++++++
 bin/local-dev.sh                                   | 2536 ++++++++++++++++++++
 bin/local-dev/docker-compose.override.yml          |   56 +
 .../local-dev/tests/__init__.py                    |   18 -
 bin/local-dev/tests/conftest.py                    |   44 +
 bin/local-dev/tests/test_local_dev_sh.sh           |  167 ++
 bin/local-dev/tests/test_local_dev_tui.py          |  376 +++
 11 files changed, 4894 insertions(+), 19 deletions(-)

diff --git a/.github/labeler.yml b/.github/labeler.yml
index 75f20d2c33..e520c6a6df 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -113,6 +113,17 @@ dev:
       - any-glob-to-any-file:
           - 'bin/**'
 
+infra:
+  # Dev-loop tooling for bringing up / tailing the local stack lives under
+  # bin/local-dev*. Tracked on its own label so the matching CI stack runs
+  # on changes here without dragging the broader `dev` label (which is a
+  # no-op stack-mapping-wise) into the equation.
+  - changed-files:
+      - any-glob-to-any-file:
+          - 'bin/local-dev.sh'
+          - 'bin/local-dev-tui.py'
+          - 'bin/local-dev/**'
+
 dependencies:
   - changed-files:
       - any-glob-to-any-file:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index addb2d3236..b44f035552 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -60,6 +60,10 @@ on:
         required: false
         type: boolean
         default: true
+      run_infra:
+        required: false
+        type: boolean
+        default: true
       mode:
         # PR (default) | nightly | release. Only "PR" passes
         # --ignore-transitive-version to check_binary_deps.py.
@@ -917,3 +921,41 @@ jobs:
           report_type: test_results
           disable_search: true
           fail_ci_if_error: false
+
+  infra:
+    # Tests for the dev-loop tooling under bin/local-dev*.
+    # Light job: bash syntax + smoke tests, plus pytest unit tests for the
+    # pure helpers in local-dev-tui.py. No docker / sbt / live stack — those
+    # belong in an e2e job we'll add when the tool is exercised more widely.
+    if: ${{ inputs.run_infra }}
+    name: ${{ format('infra{0} ({1})', inputs.job_name_suffix, matrix.os) }}
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        os: [ubuntu-latest, macos-latest]
+        python-version: ["3.12"]
+    steps:
+      - name: Checkout Texera
+        uses: actions/checkout@v5
+        with:
+          ref: ${{ inputs.checkout_ref || github.sha }}
+          fetch-depth: 0
+      - name: Prepare backport workspace
+        if: ${{ inputs.backport_target_branch != '' }}
+        run: bash ./.github/scripts/prepare-backport-checkout.sh "${{ inputs.backport_target_branch }}" "${{ inputs.backport_commit_range }}"
+      - name: Setup Python
+        uses: actions/setup-python@v6
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install Python test deps
+        # amber/dev-requirements.txt pins both `pytest` and `textual` — the
+        # latter so a fresh dev who follows the AGENTS.md venv-setup
+        # instructions ends up with the TUI working.
+        run: |
+          python -m pip install --upgrade pip
+          python -m pip install -r amber/dev-requirements.txt
+      - name: Run shell smoke tests (bash)
+        run: bash bin/local-dev/tests/test_local_dev_sh.sh
+      - name: Run Python unit tests
+        run: python -m pytest bin/local-dev/tests/ -v --tb=short
diff --git a/.github/workflows/required-checks.yml b/.github/workflows/required-checks.yml
index 2d7012fe33..d854be3f17 100644
--- a/.github/workflows/required-checks.yml
+++ b/.github/workflows/required-checks.yml
@@ -65,6 +65,7 @@ jobs:
       run_platform: ${{ steps.decide.outputs.run_platform }}
       run_pyamber: ${{ steps.decide.outputs.run_pyamber }}
       run_agent_service: ${{ steps.decide.outputs.run_agent_service }}
+      run_infra: ${{ steps.decide.outputs.run_infra }}
       backport_targets: ${{ steps.decide.outputs.backport_targets }}
     steps:
       - name: Wait for Pull Request Labeler
@@ -153,7 +154,8 @@ jobs:
               "agent-service": ["agent-service"],
               common: ["amber", "amber-integration", "platform"],
               "ddl-change": ["amber", "amber-integration", "platform"],
-              ci: ["frontend", "amber", "amber-integration", "platform", "pyamber", "agent-service"],
+              infra: ["infra"],
+              ci: ["frontend", "amber", "amber-integration", "platform", "pyamber", "agent-service", "infra"],
             };
 
             let runFrontend = true;
@@ -162,6 +164,7 @@ jobs:
             let runPlatform = true;
             let runPyamber = true;
             let runAgentService = true;
+            let runInfra = true;
 
             if (eventName === "pull_request") {
               const stacks = new Set();
@@ -176,6 +179,7 @@ jobs:
               runPlatform = stacks.has("platform");
               runPyamber = stacks.has("pyamber");
               runAgentService = stacks.has("agent-service");
+              runInfra = stacks.has("infra");
               core.info(
                 `Stacks selected by label union: ${[...stacks].sort().join(", ") || "(none)"}`
               );
@@ -187,6 +191,7 @@ jobs:
             core.setOutput("run_platform", runPlatform ? "true" : "false");
             core.setOutput("run_pyamber", runPyamber ? "true" : "false");
             core.setOutput("run_agent_service", runAgentService ? "true" : "false");
+            core.setOutput("run_infra", runInfra ? "true" : "false");
 
             // Backport targets: all current release/* labels on the PR.
             const targets = [...new Set(labels.filter((n) => /^release\/.+$/.test(n)))].sort();
@@ -242,6 +247,7 @@ jobs:
       run_platform: ${{ needs.precheck.outputs.run_platform == 'true' }}
       run_pyamber: ${{ needs.precheck.outputs.run_pyamber == 'true' }}
       run_agent_service: ${{ needs.precheck.outputs.run_agent_service == 'true' }}
+      run_infra: ${{ needs.precheck.outputs.run_infra == 'true' }}
     secrets: inherit
 
   backport:
@@ -263,6 +269,7 @@ jobs:
       run_platform: ${{ needs.precheck.outputs.run_platform == 'true' }}
       run_pyamber: ${{ needs.precheck.outputs.run_pyamber == 'true' }}
       run_agent_service: ${{ needs.precheck.outputs.run_agent_service == 'true' }}
+      run_infra: ${{ needs.precheck.outputs.run_infra == 'true' }}
     secrets: inherit
 
   required-checks:
diff --git a/amber/dev-requirements.txt b/amber/dev-requirements.txt
index 4ab14960cb..3ac549283d 100644
--- a/amber/dev-requirements.txt
+++ b/amber/dev-requirements.txt
@@ -32,3 +32,6 @@ pytest-cov==7.1.0
 # protoc plugin for bin/python-proto-gen.sh; runtime needs only the
 # base `betterproto` in requirements.txt.
 betterproto[compiler]==2.0.0b7
+
+# Required by `bin/local-dev.sh -i` (the interactive Textual TUI).
+textual==8.2.7
diff --git a/bin/local-dev-tui.py b/bin/local-dev-tui.py
new file mode 100644
index 0000000000..3b294491f7
--- /dev/null
+++ b/bin/local-dev-tui.py
@@ -0,0 +1,1651 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""bin/local-dev-tui.py — Textual dashboard for the Texera local dev stack.
+
+Lives next to bin/local-dev.sh; the shell script remains the canonical engine
+(build, start, stop, status) and this TUI shells out to it for every action.
+The dashboard itself owns state polling, dirty-source detection, and the
+prompt loop.  Textual handles diff rendering so the screen doesn't accrete in
+scrollback the way the old zsh `\\e[H` redraw did.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import hashlib
+import os
+import re
+import shlex
+import shutil
+import subprocess
+import time
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Optional
+
+from rich.text import Text
+from textual import events, work
+from textual.app import App, ComposeResult
+from textual.binding import Binding
+from textual.containers import Vertical
+from textual.reactive import reactive
+from textual.widgets import DataTable, Input, RichLog, Static
+
+# ─────────────────── Constants ───────────────────
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+STATE_DIR = Path(os.environ.get("TEXERA_LOCAL_DEV_DIR", "/tmp/texera-local-dev"))
+LOG_DIR = STATE_DIR / "logs"
+BUILD_STAMP_DIR = STATE_DIR / "build-stamps"
+# Per-service phase markers written by the shell during stop/build/start.
+# Each file holds `<phase>\t<epoch>` — we read it back in _tick_state and
+# render an animated transitional STATE if it's recent (<90s).
+PHASE_DIR = STATE_DIR / "svc-phase"
+PHASE_STALE_S = 90.0
+REPL_LOG = LOG_DIR / "repl.log"
+LOG_DIR.mkdir(parents=True, exist_ok=True)
+BUILD_STAMP_DIR.mkdir(parents=True, exist_ok=True)
+
+LOCAL_DEV_SH = REPO_ROOT / "bin" / "local-dev.sh"
+DOCKER_PROJECT = "texera-local-dev"
+
+HISTORY_FILE = STATE_DIR / "tui-history"
+MAX_HISTORY = 500
+
+SOURCE_SUFFIXES = {".scala", ".java", ".proto"}
+
+# Single source of truth for per-JVM-service source dirs is the SBT build
+# graph in build.sbt. We parse it instead of hardcoding `common/*` so that
+# adding a `lazy val NewCommon = (project in file("common/new"))` and a
+# corresponding `.dependsOn(NewCommon)` automatically flows through to
+# dirty-detection without anyone touching this file.
+
+# Matches test-scope dependsOn args we want to drop from the runtime
+# graph. `X % "test->test"`, `X % "test"`, `X % Test`. Tighter than the
+# previous "any %" check, which would have silently dropped a future
+# `.dependsOn(X % "compile->compile")` and broken dirty-detection on X.
+# No trailing `\b` — `\b` after a literal `"` doesn't match because both
+# sides are non-word chars; we anchor the end of the quoted form on the
+# closing `"` itself, and the `Test` form on (?:\b|$).
+_TEST_SCOPE_RE = re.compile(r'%\s*(?:"test(?:->[^"]*)?"|Test(?:\b|$))')
+
+
+def _parse_sbt_deps() -> dict[str, dict]:
+    """Walk `build.sbt` and return a dependency graph keyed by sbt project
+    name. Each entry is `{"path": <repo-relative dir>, "deps": [..main-scope
+    deps..]}`. Test-only `% "test->test"` deps are filtered out — they
+    can't affect a service's runtime artifact, so they don't drive dirty.
+    """
+    bs = REPO_ROOT / "build.sbt"
+    try:
+        text = bs.read_text(encoding="utf-8", errors="replace")
+    except OSError:
+        return {}
+
+    proj_re = re.compile(
+        r'^lazy\s+val\s+([A-Z][A-Za-z0-9]*)\s*=\s*\(project\s+in\s+file\("([^"]+)"\)\)',
+        re.MULTILINE,
+    )
+    matches = list(proj_re.finditer(text))
+    if not matches:
+        return {}
+
+    graph: dict[str, dict] = {}
+    for i, m in enumerate(matches):
+        name = m.group(1)
+        path = m.group(2)
+        # The "block" for this project spans from this match to the next
+        # `lazy val ... = (project in file(...))` (or end of file). All the
+        # chained `.dependsOn(...)` calls live in there.
+        end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
+        block = text[m.start():end]
+
+        deps: list[str] = []
+        for arg_list in re.findall(r"\.dependsOn\(([^)]*)\)", block):
+            for arg in arg_list.split(","):
+                arg = arg.strip()
+                # Skip ONLY test-scope deps — match `% "test->test"` and
+                # `% Test` exactly. Filtering all `%`-scoped args was too
+                # broad: a future `.dependsOn(X % "compile->compile")` or
+                # `% Provided` would silently disappear from the dep
+                # graph and break dirty-detection on `X`.
+                if _TEST_SCOPE_RE.search(arg):
+                    continue
+                # Strip any trailing whitespace / comments. Project refs
+                # are bare identifiers.
+                ref = arg.split()[0] if arg else ""
+                if ref and ref[0].isupper():
+                    deps.append(ref)
+        graph[name] = {"path": path, "deps": deps}
+    return graph
+
+
+def _transitive_src_dirs(sbt_project: Optional[str], graph: dict[str, dict]) -> list[str]:
+    """Return source dirs for sbt_project AND every project it transitively
+    depends on. Each dir is `<project_path>/src` (the canonical sbt source
+    location). Order is stable; duplicates removed. Raises if the graph
+    is empty or `sbt_project` isn't in it — silently falling back to a
+    hardcoded common/* list would mask a real build.sbt drift."""
+    if not graph:
+        raise RuntimeError("build.sbt dependency graph is empty — parse failed")
+    if not sbt_project or sbt_project not in graph:
+        raise RuntimeError(
+            f"sbt project {sbt_project!r} not found in build.sbt graph; "
+            f"known projects: {sorted(graph.keys())}"
+        )
+    visited: set[str] = set()
+    out: list[str] = []
+
+    def walk(name: str) -> None:
+        if name in visited or name not in graph:
+            return
+        visited.add(name)
+        path = graph[name]["path"]
+        src = f"{path}/src"
+        if src not in out:
+            out.append(src)
+        for d in graph[name]["deps"]:
+            walk(d)
+    walk(sbt_project)
+    return out
+
+
+_SBT_GRAPH = _parse_sbt_deps()
+
+
+# Per-sbt-project transitive closure, memoised. Computed once on first
+# touch; subsequent dirty checks are O(1) lookup vs the prior O(deps)
+# BFS per call.
+_SBT_TRANSITIVE_CACHE: dict[str, list[str]] = {}
+
+
+def _transitive_src_dirs_cached(sbt_project: Optional[str]) -> list[str]:
+    if sbt_project is None:
+        return _transitive_src_dirs(None, _SBT_GRAPH)
+    if sbt_project not in _SBT_TRANSITIVE_CACHE:
+        _SBT_TRANSITIVE_CACHE[sbt_project] = _transitive_src_dirs(sbt_project, _SBT_GRAPH)
+    return _SBT_TRANSITIVE_CACHE[sbt_project]
+
+POLL_INTERVAL_S = 1.0     # how often to refresh service state
+DIRTY_INTERVAL_S = 2.0    # how often to recompute dirty indicators
+
+# Services whose own runtime watches the filesystem and rebuilds on source
+# change: `yarn start` runs `ng serve` (Angular dev server, hot-reload) and
+# `bun run --watch` reloads the agent-service on change.  The dashboard
+# surfaces this in the SRC column so the user doesn't try to bounce them
+# unnecessarily; the ★ "dirty" indicator only flashes for them when the lock
+# file changes (i.e. an actual dep refresh is needed).
+WATCH_TYPES = {"yarn", "bun"}
+
+
+# ─────────────────── Texera version (dynamic) ───────────────────
+
+_VERSION_RE = re.compile(
+    r'^\s*ThisBuild\s*/\s*version\s*:=\s*"([^"]+)"', re.MULTILINE
+)
+
+
+def texera_version() -> str:
+    """Parse the project version from build.sbt so artifact paths track
+    whatever branch the developer is on (it's 1.3.0-incubating-SNAPSHOT on
+    main today, was 1.2.0-incubating on release/v1.2, will differ again).
+    Override with `TEXERA_VERSION` env var to bypass parsing."""
+    env = os.environ.get("TEXERA_VERSION")
+    if env:
+        return env
+    bs = REPO_ROOT / "build.sbt"
+    if not bs.exists():
+        raise RuntimeError(
+            f"build.sbt not found at {bs} — set TEXERA_VERSION to bypass"
+        )
+    m = _VERSION_RE.search(bs.read_text(errors="replace"))
+    if not m:
+        raise RuntimeError(
+            f"could not find `ThisBuild / version := \"…\"` in {bs} — "
+            f"set TEXERA_VERSION to bypass"
+        )
+    return m.group(1)
+
+
+TEXERA_VERSION = texera_version()
+
+
+# ─────────────────── Service catalog ───────────────────
+
+@dataclass
+class Service:
+    name: str
+    type: str             # "docker" | "jvm" | "yarn" | "bun"
+    port: int
+    sbt_project: Optional[str] = None     # for jvm
+    own_src: Optional[str] = None         # for jvm
+    artifact_jar: Optional[str] = None    # for jvm
+
+
+def _jvm(name: str, port: int, project: Optional[str], own_src: str) -> Service:
+    """sbt-native-packager lays the dist out as
+    `target/<artifact>-<VERSION>/lib/org.apache.texera.<artifact>-<VERSION>.jar`
+    for every subproject. amber is the exception: its sbt subproject is
+    named `amber` (not `texera-web`) and the dist goes under `amber/target/`
+    rather than the repo-level `target/`. computing-unit-master rides that
+    same amber dist as a sibling launcher — its `project` is None because
+    no separate sbt invocation produces it."""
+    is_amber_svc = name in ("texera-web", "computing-unit-master")
+    artifact = "amber" if is_amber_svc else name
+    target_prefix = "amber/" if is_amber_svc else ""
+    jar = (
+        f"{target_prefix}target/{artifact}-{TEXERA_VERSION}/lib/"
+        f"org.apache.texera.{artifact}-{TEXERA_VERSION}.jar"
+    )
+    return Service(name, "jvm", port, sbt_project=project,
+                   own_src=own_src, artifact_jar=jar)
+
+
+SERVICES: list[Service] = [
+    Service("postgres",   "docker", 5432),
+    Service("minio",      "docker", 9000),
+    Service("lakefs",     "docker", 8000),
+    Service("lakekeeper", "docker", 8181),
+    Service("litellm",    "docker", 4000),
+    _jvm("config-service",                  9094, "ConfigService",
+         "config-service/src"),
+    _jvm("access-control-service",          9096, "AccessControlService",
+         "access-control-service/src"),
+    _jvm("file-service",                    9092, "FileService",
+         "file-service/src"),
+    _jvm("workflow-compiling-service",      9090, "WorkflowCompilingService",
+         "workflow-compiling-service/src"),
+    _jvm("computing-unit-master",           8085, None,
+         "amber/src"),
+    _jvm("computing-unit-managing-service", 8082, "ComputingUnitManagingService",
+         "computing-unit-managing-service/src"),
+    _jvm("texera-web",                      8080, "WorkflowExecutionService",
+         "amber/src"),
+    Service("agent-service", "bun",  3001),
+    Service("frontend",      "yarn", 4200),
+]
+
+SERVICES_BY_NAME = {s.name: s for s in SERVICES}
+
+
+# ─────────────────── Live state model ───────────────────
+
+def read_svc_phase(svc_name: str) -> Optional[str]:
+    """Return the shell-written phase (`stopping` / `building` / `starting`)
+    or None if absent / stale. Stale-after-90s guards against a crashed
+    `bin/local-dev.sh` leaving an orphan file."""
+    f = PHASE_DIR / svc_name
+    try:
+        text = f.read_text().strip()
+    except OSError:
+        return None
+    if not text:
+        return None
+    parts = text.split("\t", 1)
+    phase = parts[0]
+    try:
+        ts = float(parts[1]) if len(parts) > 1 else 0.0
+    except ValueError:
+        ts = 0.0
+    if ts and (time.time() - ts) > PHASE_STALE_S:
+        return None
+    return phase
+
+
+@dataclass
+class LiveState:
+    """Snapshot of the world this tick — what the dashboard renders."""
+    docker: dict[str, tuple[str, str]] = field(default_factory=dict)   # name -> (state, status)
+    pids: dict[str, Optional[str]] = field(default_factory=dict)       # name -> pid or None
+    dirty: dict[str, bool] = field(default_factory=dict)
+    mtimes: dict[str, Optional[str]] = field(default_factory=dict)
+    # Per-service resource usage (computed every poll for native services
+    # via cheap `ps`; refreshed less often for docker via `docker stats`
+    # since that call costs ~2s).
+    uptimes: dict[str, str] = field(default_factory=dict)              # name -> "12m" / "—"
+    cpu_pct: dict[str, str] = field(default_factory=dict)              # name -> "8.2%" / "—"
+    mem_use: dict[str, str] = field(default_factory=dict)              # name -> "85M" / "—"
+    phases:  dict[str, str] = field(default_factory=dict)              # name -> "stopping"/"building"/"starting" or ""
+
+
+# ─────────────────── Helpers ───────────────────
+
+async def _run_capture(*argv: str) -> str:
+    proc = await asyncio.create_subprocess_exec(
+        *argv,
+        stdout=asyncio.subprocess.PIPE,
+        stderr=asyncio.subprocess.DEVNULL,
+    )
+    stdout, _ = await proc.communicate()
+    return stdout.decode(errors="replace")
+
+
+async def lsof_port_pid(port: int) -> Optional[str]:
+    out = await _run_capture("lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t")
+    out = out.strip()
+    return out.split("\n", 1)[0] if out else None
+
+
+async def docker_ps_all() -> dict[str, tuple[str, str]]:
+    out = await _run_capture(
+        "docker", "compose", "-p", DOCKER_PROJECT, "ps", "-a",
+        "--format", "{{.Service}}|{{.State}}|{{.Status}}",
+    )
+    result: dict[str, tuple[str, str]] = {}
+    for line in out.splitlines():
+        parts = line.split("|", 2)
+        if len(parts) == 3:
+            result[parts[0]] = (parts[1], parts[2])
+    return result
+
+
+def docker_state(svc_state: str, svc_status: str) -> str:
+    """Map docker's raw state/status to the small palette the dashboard renders."""
+    if svc_state == "running":
+        if "(healthy)" in svc_status:
+            return "running"
+        if "(health: starting)" in svc_status:
+            return "starting"
+        if "(unhealthy)" in svc_status:
+            return "unhealthy"
+        return "running"
+    if svc_state == "exited":
+        return "exited" if svc_status.startswith("Exited (0)") else "failed"
+    if svc_state in ("created", "restarting", "paused", "removing"):
+        return "starting"
+    return "stopped"
+
+
+# ─────────────────── Dirty-source detection (content hash) ───────────────────
+
+# ─────────────────── Uptime / resource helpers ───────────────────
+
+def _format_uptime(secs: int) -> str:
+    """Compact duration: `12s`, `5m 23s`, `2h 14m`, `3d 4h`."""
+    if secs < 0:
+        return "—"
+    if secs < 60:
+        return f"{secs}s"
+    if secs < 3600:
+        return f"{secs // 60}m {secs % 60}s"
+    if secs < 86400:
+        return f"{secs // 3600}h {(secs % 3600) // 60}m"
+    return f"{secs // 86400}d {(secs % 86400) // 3600}h"
+
+
+def _format_bytes(n: int) -> str:
+    """Bytes → short human string (`85M`, `1.2G`, `467K`)."""
+    if n < 1024:
+        return f"{n}B"
+    if n < 1024 ** 2:
+        return f"{n // 1024}K"
+    if n < 1024 ** 3:
+        return f"{n // (1024 ** 2)}M"
+    return f"{n / (1024 ** 3):.1f}G"
+
+
+def _parse_etime(et: str) -> int:
+    """`ps -o etime` format `[[DD-]hh:]mm:ss` → seconds."""
+    et = et.strip()
+    if not et:
+        return -1
+    days = 0
+    if "-" in et:
+        d, et = et.split("-", 1)
+        days = int(d or 0)
+    parts = et.split(":")
+    try:
+        if len(parts) == 3:
+            h, m, s = (int(p or 0) for p in parts)
+        elif len(parts) == 2:
+            h = 0
+            m, s = (int(p or 0) for p in parts)
+        else:
+            h = m = 0
+            s = int(parts[0] or 0)
+    except ValueError:
+        return -1
+    return days * 86400 + h * 3600 + m * 60 + s
+
+
+def _docker_container_for(svc_name: str) -> str:
+    """Container names: most are `texera-<svc>`; `litellm` is unprefixed."""
+    if svc_name == "litellm":
+        return "litellm"
+    return f"texera-{svc_name}"
+
+
+_DOCKER_STATS_CACHE: dict[str, tuple[str, str]] = {}
+_DOCKER_STATS_TS: float = 0.0
+_DOCKER_STATS_TTL = 5.0  # seconds — docker stats --no-stream costs ~2s, don't run it more than this
+
+
+def _refresh_docker_stats() -> None:
+    """Single `docker stats --no-stream` pass; refreshes the cached
+    container → (cpu%, mem) map. Called from a background worker so the
+    main poll tick stays cheap."""
+    global _DOCKER_STATS_CACHE, _DOCKER_STATS_TS
+    try:
+        out = subprocess.run(
+            ["docker", "stats", "--no-stream",
+             "--format", "{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}"],
+            capture_output=True, text=True, timeout=8,
+        ).stdout
+    except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
+        return
+    fresh: dict[str, tuple[str, str]] = {}
+    for line in out.splitlines():
+        parts = line.split("|")
+        if len(parts) != 3:
+            continue
+        name, cpu, memu = parts
+        # MemUsage is "85.2MiB / 1.95GiB" — strip the "/ total" half.
+        used = memu.split(" /")[0].strip()
+        # Normalise to our own _format_bytes shape: parse the IEC unit suffix.
+        m = re.match(r"^([0-9.]+)\s*([KMGTPE]?i?B)?$", used)
+        if m:
+            val = float(m.group(1))
+            unit = (m.group(2) or "B").lower()
+            mult = {
+                "b":    1,
+                "kb":   1024,    "kib": 1024,
+                "mb":   1024**2, "mib": 1024**2,
+                "gb":   1024**3, "gib": 1024**3,
+                "tb":   1024**4, "tib": 1024**4,
+            }.get(unit, 1)
+            mem_str = _format_bytes(int(val * mult))
+        else:
+            mem_str = used or "—"
+        fresh[name] = (cpu, mem_str)
+    _DOCKER_STATS_CACHE = fresh
+    _DOCKER_STATS_TS = time.time()
+
+
+def proc_uptime_cpu_mem(pid: str) -> tuple[str, str, str]:
+    """For a native PID return (uptime, cpu%, mem) — `—` if not running."""
+    try:
+        out = subprocess.run(
+            ["ps", "-p", pid, "-o", "etime=,pcpu=,rss="],
+            capture_output=True, text=True, timeout=2,
+        ).stdout.strip()
+    except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
+        return ("—", "—", "—")
+    if not out:
+        return ("—", "—", "—")
+    parts = out.split()
+    if len(parts) < 3:
+        return ("—", "—", "—")
+    etime, cpu, rss_kb = parts[0], parts[1], parts[2]
+    secs = _parse_etime(etime)
+    try:
+        mem = _format_bytes(int(rss_kb) * 1024)
+    except ValueError:
+        mem = "—"
+    return (_format_uptime(secs), f"{cpu}%", mem)
+
+
+def container_uptime(svc_name: str) -> str:
+    """ISO StartedAt → human duration. Empty if not started."""
+    container = _docker_container_for(svc_name)
+    try:
+        out = subprocess.run(
+            ["docker", "inspect", "-f", "{{.State.StartedAt}}", container],
+            capture_output=True, text=True, timeout=2,
+        ).stdout.strip()
+    except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
+        return "—"
+    if not out or out.startswith("0001-01-01"):
+        return "—"
+    try:
+        # ISO 8601 with fractional + Z. Trim to the second.
+        trimmed = out.split(".", 1)[0].rstrip("Z")
+        started = datetime.strptime(trimmed, "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
+    except ValueError:
+        return "—"
+    elapsed = (datetime.now(timezone.utc) - started).total_seconds()
+    return _format_uptime(max(0, int(elapsed)))
+
+
+def _jvm_src_dirs(svc: Service) -> list[Path]:
+    # Derive the source-dir set from the SBT dependency graph in build.sbt.
+    # Services that share another's sbt project (e.g. computing-unit-master
+    # rides amber's dist) enter the graph at the *producing* project via
+    # SHARED_SBT_PROJECT.
+    SHARED_SBT_PROJECT = {
+        "computing-unit-master": "WorkflowExecutionService",
+    }
+    project = svc.sbt_project or SHARED_SBT_PROJECT.get(svc.name)
+    if not project:
+        raise RuntimeError(
+            f"service {svc.name!r} has no sbt_project and no SHARED_SBT_PROJECT "
+            f"mapping — can't compute its source-dir closure"
+        )
+    src_dirs = _transitive_src_dirs_cached(project)
+    return [REPO_ROOT / d for d in src_dirs if (REPO_ROOT / d).exists()]
+
+
+def _jvm_source_files(svc: Service) -> list[Path]:
+    files: list[Path] = []
+    for d in _jvm_src_dirs(svc):
+        for f in d.rglob("*"):
+            if f.is_file() and f.suffix in SOURCE_SUFFIXES:
+                files.append(f)
+    # Sort by raw bytes of the string representation so the order matches
+    # GNU `sort -z` exactly (the shell-side `svc_source_hash` uses that).
+    # Python's default `Path.sort()` is component-wise and could disagree
+    # at e.g. `Foo.scala` vs `Foo/` sibling-name collisions, which would
+    # silently break stamp agreement between the two implementations.
+    files.sort(key=lambda p: str(p).encode())
+    return files
+
+
+def source_hash(svc: Service, files: Optional[list[Path]] = None) -> str:
+    """SHA-1 of every source byte under `svc`'s transitive sbt deps.
+    Pass a pre-computed `files` list to skip the rglob walk — the
+    dirty-check fast path already needs the file list to compare mtimes
+    against the stamp, no point rglob'ing twice."""
+    if files is None:
+        files = _jvm_source_files(svc)
+    h = hashlib.sha1()
+    for f in files:
+        try:
+            h.update(f.read_bytes())
+        except OSError:
+            pass
+    return h.hexdigest()
+
+
+def _newest_mtime_after(files: list[Path], stamp_mtime: float) -> bool:
+    for f in files:
+        try:
+            if f.stat().st_mtime > stamp_mtime:
+                return True
+        except OSError:
+            continue
+    return False
+
+
+def is_dirty(svc: Service) -> bool:
+    """Did the service's relevant source change since the last build?
+
+    JVM: SHA-1 of all .scala/.java/.proto bytes vs. the hash we wrote at the
+    last build, with an mtime fast filter so 99% of ticks are O(stat).
+    yarn/bun: lock vs. node_modules dir mtime — cheap.
+    docker: always clean.
+    """
+    if svc.type == "docker":
+        return False
+    if svc.type == "jvm":
+        return _jvm_is_dirty(svc)
+    if svc.type == "yarn":
+        nm = REPO_ROOT / "frontend" / "node_modules" / ".yarn-state.yml"
+        lock = REPO_ROOT / "frontend" / "yarn.lock"
+        if not nm.exists() or not lock.exists():
+            return True
+        return lock.stat().st_mtime > nm.stat().st_mtime
+    if svc.type == "bun":
+        nm = REPO_ROOT / "agent-service" / "node_modules"
+        lock = REPO_ROOT / "agent-service" / "bun.lock"
+        if not nm.exists() or not lock.exists():
+            return True
+        return lock.stat().st_mtime > nm.stat().st_mtime
+    return False
+
+
+def _jvm_is_dirty(svc: Service) -> bool:
+    stamp = BUILD_STAMP_DIR / svc.name
+    if not stamp.exists() or stamp.stat().st_size == 0:
+        # Lazy seed: if a jar is present, assume it matches current source and
+        # write the hash. First REPL after a fresh checkout pays this once.
+        jar = REPO_ROOT / svc.artifact_jar if svc.artifact_jar else None
+        if jar is None or not jar.exists():
+            return True
+        stamp.write_text(source_hash(svc))
+        return False
+
+    files = _jvm_source_files(svc)
+    stamp_mtime = stamp.stat().st_mtime
+
+    # Fast filter: any source newer than the stamp?  If not, definitely clean.
+    if not _newest_mtime_after(files, stamp_mtime):
+        return False
+
+    # Slow path — did the content actually move? Reuse the file list above
+    # instead of having `source_hash` rglob the tree a second time.
+    stored = stamp.read_text().strip()
+    current = source_hash(svc, files)
+    if current == stored:
+        # Same content, only mtimes moved (git checkout / touch).  Refresh
+        # the stamp's mtime so the fast filter passes next tick.
+        os.utime(stamp, None)
+        return False
+    return True
+
+
+def artifact_mtime_str(svc: Service) -> Optional[str]:
+    if svc.type == "jvm" and svc.artifact_jar:
+        jar = REPO_ROOT / svc.artifact_jar
+        if jar.exists():
+            return datetime.fromtimestamp(jar.stat().st_mtime).strftime("%m-%d %H:%M")
+        return None
+    if svc.type == "bun":
+        f = REPO_ROOT / "agent-service" / "bun.lock"
+    elif svc.type == "yarn":
+        f = REPO_ROOT / "frontend" / "yarn.lock"
+    else:
+        return None
+    if f.exists():
+        return datetime.fromtimestamp(f.stat().st_mtime).strftime("%m-%d %H:%M")
+    return None
+
+
+# ─────────────────── Banner state (cheap) ───────────────────
+
+def git_head() -> tuple[str, str]:
+    branch = subprocess_run("git", "-C", str(REPO_ROOT), "rev-parse", "--abbrev-ref", "HEAD") or "?"
+    sha = subprocess_run("git", "-C", str(REPO_ROOT), "rev-parse", "--short", "HEAD") or "?"
+    return branch, sha
+
+
+def worktree_info() -> tuple[str, bool]:
+    """Return (label, is_worktree).  Label is the leaf directory name of the
+    checkout — for the canonical clone this is `texera`, for a worktree it
+    matches the worktree's directory name (which by our convention reflects
+    the branch).  is_worktree distinguishes the main checkout from extras so
+    the banner can flag it."""
+    name = REPO_ROOT.name
+    git_dir = subprocess_run("git", "-C", str(REPO_ROOT), "rev-parse", "--git-dir")
+    common_dir = subprocess_run("git", "-C", str(REPO_ROOT), "rev-parse", "--git-common-dir")
+    is_worktree = False
+    try:
+        if git_dir and common_dir:
+            g = Path(git_dir) if Path(git_dir).is_absolute() else (REPO_ROOT / git_dir)
+            c = Path(common_dir) if Path(common_dir).is_absolute() else (REPO_ROOT / common_dir)
+            is_worktree = g.resolve() != c.resolve()
+    except Exception:
+        pass
+    return name, is_worktree
+
+
+def subprocess_run(*argv: str) -> str:
+    import subprocess as sp
+    try:
+        return sp.check_output(argv, stderr=sp.DEVNULL, text=True).strip()
+    except Exception:
+        return ""
+
+
+# ─────────────────── Input with shell-style history ───────────────────
+
+class CommandHistory:
+    """Pure-Python state machine for command history navigation.
+
+    Kept separate from `HistoricInput` (which subclasses Textual's `Input`)
+    so the navigation logic can be unit-tested without a running app —
+    Textual's reactive setters need an active App context, so they can't
+    be exercised from a bare pytest. `HistoricInput` is a thin wrapper that
+    delegates here and forwards the resulting value to its `Input.value`.
+
+    Conventions match bash/zsh: ↑ walks back from newest to oldest, the
+    in-progress draft is saved when stepping off it the first time, ↓
+    walks forward and restores the draft once you step past the newest
+    entry. Consecutive duplicates are coalesced on `push`."""
+
+    def __init__(self, history_file: Optional[Path] = None, max_size: int = MAX_HISTORY) -> None:
+        self._file = history_file
+        self._max = max_size
+        self._history: list[str] = self._load()
+        self._idx: int = -1   # -1 = at the live draft; 0+ = back in history
+        self._draft: str = ""
+
+    def _load(self) -> list[str]:
+        if not self._file or not self._file.exists():
+            return []
+        try:
+            lines = self._file.read_text(errors="replace").splitlines()
+            return [s for s in (l.strip() for l in lines) if s][-self._max:]
+        except OSError:
+            return []
+
+    def _save(self) -> None:
+        if not self._file:
+            return
+        try:
+            self._file.write_text("\n".join(self._history[-self._max:]) + "\n")
+        except OSError:
+            pass
+
+    def push(self, cmd: str) -> None:
+        cmd = cmd.strip()
+        if not cmd:
+            return
+        if self._history and self._history[-1] == cmd:
+            self._reset()
+            return
+        self._history.append(cmd)
+        self._save()
+        self._reset()
+
+    def _reset(self) -> None:
+        self._idx = -1
+        self._draft = ""
+
+    def back(self, current_value: str) -> Optional[str]:
+        """Step one entry back in history. Returns the new value to display,
+        or None if we're already at the oldest entry (caller should leave
+        the input alone)."""
+        if not self._history:
+            return None
+        if self._idx == -1:
+            self._draft = current_value
+        if self._idx + 1 >= len(self._history):
+            return None
+        self._idx += 1
+        return self._history[-1 - self._idx]
+
+    def forward(self) -> Optional[str]:
+        """Step one entry forward. Returns the draft when you cross the
+        newest entry. Returns None if we weren't browsing history."""
+        if self._idx == -1:
+            return None
+        self._idx -= 1
+        if self._idx == -1:
+            return self._draft
+        return self._history[-1 - self._idx]
+
+
+class HistoricInput(Input):
+    """Textual Input wired up to `CommandHistory` for shell-style ↑/↓.
+
+    History is persisted to `HISTORY_FILE` under STATE_DIR so it survives
+    across REPL sessions."""
+
+    BINDINGS = [
+        Binding("up",   "history_back",    "history back",    show=False),
+        Binding("down", "history_forward", "history forward", show=False),
+    ]
+
+    def __init__(self, *args, **kwargs) -> None:
+        super().__init__(*args, **kwargs)
+        self._hist = CommandHistory(HISTORY_FILE)
+
+    def push(self, cmd: str) -> None:
+        self._hist.push(cmd)
+
+    def _set_value(self, v: str) -> None:
+        self.value = v
+        with contextlib.suppress(Exception):
+            self.cursor_position = len(v)
+
+    def action_history_back(self) -> None:
+        v = self._hist.back(self.value)
+        if v is not None:
+            self._set_value(v)
+
+    def action_history_forward(self) -> None:
+        v = self._hist.forward()
+        if v is not None:
+            self._set_value(v)
+
+
+# ─────────────────── Textual app ───────────────────
+
+class LogResizeHandle(Static):
+    """The one-row grip above the log pane. Drag it up/down with the mouse
+    to resize the log; the height is clamped by the RichLog CSS min/max.
+    Doubles as the static "drag to resize" label when idle."""
+
+    DEFAULT_LABEL = " ⇅  log  (drag this row to resize)"
+
+    def __init__(self, **kwargs) -> None:
+        super().__init__(self.DEFAULT_LABEL, **kwargs)
+        self._dragging = False
+        self._drag_origin_y = 0
+        self._drag_origin_h = 0
+
+    def on_mouse_down(self, event: events.MouseDown) -> None:
+        # We only care about the primary button.
+        if event.button != 1:
+            return
+        log = self.app.query_one("#log", RichLog)
+        # `region.height` is the current rendered height in cells. Lock it
+        # in as the baseline so we can compute deltas without races against
+        # auto-layout while the user drags.
+        self._drag_origin_y = event.screen_y
+        self._drag_origin_h = log.region.height
+        self._dragging = True
+        self.capture_mouse(True)
+        # Replace the label so users know they grabbed it.
+        self.update(f" ⇅  resizing… (height {self._drag_origin_h})")
+
+    def on_mouse_move(self, event: events.MouseMove) -> None:
+        if not self._dragging:
+            return
+        # Dragging UP (smaller screen_y) should GROW the log since the log
+        # sits below this handle. Invert the delta and clamp; CSS max/min
+        # are a fallback but the explicit clamp keeps the label honest.
+        delta = self._drag_origin_y - event.screen_y
+        new_h = max(4, min(60, int(self._drag_origin_h + delta)))
+        self.app.query_one("#log", RichLog).styles.height = new_h
+        self.update(f" ⇅  resizing… (height {new_h})")
+
+    def on_mouse_up(self, event: events.MouseUp) -> None:
+        if not self._dragging:
+            return
+        self._dragging = False
+        self.capture_mouse(False)
+        self.update(self.DEFAULT_LABEL)
+
+
+# "Apache Texera" wordmark in box-drawing block characters. Single line,
+# 6 rows × ~104 cols. Coloured teal to match the brand.
+LOGO_TEXERA = (
+    " █████╗ ██████╗  █████╗  ██████╗██╗  ██╗███████╗    ████████╗███████╗██╗  ██╗███████╗██████╗  █████╗ \n"
+    "██╔══██╗██╔══██╗██╔══██╗██╔════╝██║  ██║██╔════╝    ╚══██╔══╝██╔════╝╚██╗██╔╝██╔════╝██╔══██╗██╔══██╗\n"
+    "███████║██████╔╝███████║██║     ███████║█████╗         ██║   █████╗   ╚███╔╝ █████╗  ██████╔╝███████║\n"
+    "██╔══██║██╔═══╝ ██╔══██║██║     ██╔══██║██╔══╝         ██║   ██╔══╝   ██╔██╗ ██╔══╝  ██╔══██╗██╔══██║\n"
+    "██║  ██║██║     ██║  ██║╚██████╗██║  ██║███████╗       ██║   ███████╗██╔╝ ██╗███████╗██║  ██║██║  ██║\n"
+    "╚═╝  ╚═╝╚═╝     ╚═╝  ╚═╝ ╚═════╝╚═╝  ╚═╝╚══════╝       ╚═╝   ╚══════╝╚═╝  ╚═╝╚══════╝╚═╝  ╚═╝╚═╝  ╚═╝"
+)
+
+
+STATE_STYLE = {
+    "running":   ("●", "green"),
+    "starting":  ("⚠", "yellow"),
+    "unhealthy": ("✗", "red"),
+    "failed":    ("✗", "red"),
+    "exited":    ("✓", "grey50"),
+    "stopped":   ("○", "grey50"),
+}
+
+
+def state_cell(state: str) -> Text:
+    sym, style = STATE_STYLE.get(state, ("○", "grey50"))
+    return Text(f"{sym}  {state}", style=style)
+
+
+class LocalDevApp(App):
+    """Live dashboard + REPL for the texera local dev stack."""
+
+    CSS = """
+    Screen { layout: vertical; }
+    #banner {
+        height: 11;
+        background: $boost;
+        color: $text;
+        padding: 0 2;
+        border-bottom: heavy $primary;
+    }
+    /* Collapsed mode: hide the wordmark, leave the title/sub two-liner.
+       Use `Ctrl-B` to toggle — useful on short terminals where the dashboard
+       gets cramped by the logo. */
+    #banner.-collapsed {
+        height: 3;
+    }
+    #banner.-collapsed #banner-logo {
+        display: none;
+    }
+    #banner-logo  {
+        color: #20b2aa;
+        text-style: bold;
+        height: 7;
+    }
+    #banner-title { text-style: bold; }
+    #banner-sub  { color: $text-muted; }
+    DataTable {
+        height: 1fr;
+        border-bottom: solid $primary-darken-1;
+    }
+    #log-header {
+        height: 1;
+        color: $text-muted;
+        background: $primary-darken-3;
+        padding: 0 2;
+    }
+    #log-header.-hidden { display: none; }
+    #log-header:hover { color: $accent; background: $primary-darken-2; }
+    RichLog {
+        height: 18;
+        min-height: 4;
+        max-height: 60;
+        background: $surface;
+        scrollbar-size: 1 1;
+        padding: 0 1;
+    }
+    RichLog.-hidden { display: none; }
+    #status-bar {
+        height: 1;
+        background: $primary-darken-2;
+        color: $text;
+        padding: 0 2;
+    }
+    Input { background: $surface; }
+    """
+
+    BINDINGS = [
+        # Ctrl-C: first press cancels the current command (or log tail); if
+        # nothing's active, requires a second press within 2 s to actually
+        # quit.  Matches the way shells & many TUIs treat it.
+        Binding("ctrl+c", "soft_quit",      "Cancel / Quit",   priority=True, show=False),
+        Binding("escape", "escape_view",    "Exit log view",   priority=True, show=False),
+        Binding("ctrl+l", "clear_log",      "Clear log",                         show=False),
+        Binding("ctrl+r", "manual_refresh", "Refresh",                            show=False),
+        Binding("ctrl+b", "toggle_banner",  "Toggle banner",                      show=False),
+    ]
+
+    # Reactive state — Textual diffs widget content when these change.
+    live: reactive[LiveState] = reactive(LiveState, recompose=False)
+    active_cmd: reactive[Optional[str]] = reactive(None)
+    cmd_started_at: reactive[float] = reactive(0.0)
+    log_log_position: reactive[int] = reactive(0)   # byte offset we've already read
+
+    # Non-reactive bookkeeping
+    _branch: str = "?"
+    _sha: str = "?"
+    _last_dirty_check: float = 0.0
+    _cached_dirty: dict[str, bool]
+    _cached_mtimes: dict[str, Optional[str]]
+    _cmd_proc: Optional[asyncio.subprocess.Process] = None
+
+    def __init__(self) -> None:
+        super().__init__()
+        self._cached_dirty = {s.name: False for s in SERVICES}
+        self._cached_mtimes = {s.name: None for s in SERVICES}
+        self._branch, self._sha = git_head()
+        self._worktree_name, self._is_worktree = worktree_info()
+        self._log_visible = False
+        self._log_auto_hide_handle = None  # type: ignore
+        self._last_ctrl_c_ts: float = 0.0
+
+    # ── Log visibility ──
+    def _set_log_visible(self, show: bool) -> None:
+        self._log_visible = show
+        log = self.query_one("#log", RichLog)
+        header = self.query_one("#log-header", LogResizeHandle)
+        if show:
+            log.remove_class("-hidden")
+            header.remove_class("-hidden")
+        else:
+            log.add_class("-hidden")
+            header.add_class("-hidden")
+
+    # Column keys we keep so update_cell() can address cells reliably.  The
+    # string labels passed to add_columns() are NOT the keys (Textual hands
+    # back auto-generated ColumnKey objects), so doing
+    # `update_cell(row, "STATE", ...)` silently fails.
+    _COL_LABELS = ("●", "SERVICE", "PORT", "PID", "UPTIME", "CPU%", "MEM", "ARTIFACT", "BUILD", "STATE")
+    _COL_KEYS   = ("sym", "svc",     "port", "pid", "uptime", "cpu",  "mem", "mtime",    "src",   "state")
+    # Min width per column key. Without these, Textual sizes the column to
+    # its header (e.g. PID = 3 chars), then truncates wider cell values
+    # like "76348" → "763", which made every JVM look like it shared a
+    # PID. Numeric/timestamp columns deserve enough room for the
+    # widest plausible value.
+    _COL_MIN_WIDTH = {
+        "svc":    32,
+        "port":   6,
+        "pid":    7,    # up to 7-digit PIDs (Linux default max is 4194304)
+        "uptime": 10,
+        "cpu":    7,
+        "mem":    7,
+        "mtime":  18,
+        "src":    5,
+        "state":  12,
+    }
+
+    # ── Layout ──
+    def compose(self) -> ComposeResult:
+        yield Vertical(
+            Static(LOGO_TEXERA, id="banner-logo"),
+            Static("", id="banner-title"),
+            Static("", id="banner-sub"),
+            id="banner",
+        )
+        table = DataTable(zebra_stripes=False, cursor_type="row")
+        for label, key in zip(self._COL_LABELS, self._COL_KEYS):
+            table.add_column(label, key=key, width=self._COL_MIN_WIDTH.get(key))
+        for s in SERVICES:
+            table.add_row("○", s.name, f":{s.port}", "—", "—", "—", "—", "—", "  ", "stopped", key=s.name)
+        yield table
+        yield LogResizeHandle(id="log-header", classes="-hidden")
+        yield RichLog(id="log", highlight=False, markup=False, wrap=False,
+                      auto_scroll=True, classes="-hidden")
+        yield Static("", id="status-bar")
+        yield HistoricInput(placeholder="type a command (h for help · ↑/↓ history · q to quit)", id="prompt")
+
+    def on_mount(self) -> None:
+        self.title = "Texera Local Dev"
+        self._update_banner()
+        self._update_status_bar()
+        self.query_one("#prompt", Input).focus()
+        # Background polling tasks
+        self.set_interval(POLL_INTERVAL_S, self._tick_state)
+        self.set_interval(0.2, self._tick_log)
+        self.set_interval(0.5, self._tick_banner)
+        # 3 Hz lightweight tick so the `stopping.`/`building..` etc dots
+        # animate smoothly between full state polls. Skips work entirely
+        # when no phase markers are active, so the steady-state cost is a
+        # 14-row dict lookup.
+        self.set_interval(0.33, self._tick_phase_animation)
+        # Kick the first poll immediately so the table populates fast.
+        self.call_later(self._tick_state)
+        self.call_later(self._tick_log)
+
+    # ── Updates ──
+    def _update_banner(self) -> None:
+        now = datetime.now().strftime("%H:%M:%S")
+        wt_tag = f"worktree: {self._worktree_name}" if self._is_worktree else f"checkout: {self._worktree_name}"
+        sub = f"{wt_tag}  ·  branch: {self._branch} @ {self._sha}  ·  {now}"
+        self.query_one("#banner-title", Static).update("Apache Texera — Local Dev")
+        self.query_one("#banner-sub", Static).update(sub)
+
+    def _update_status_bar(self) -> None:
+        running = sum(
+            1 for s in SERVICES
+            if (s.type == "docker" and docker_state(*self.live.docker.get(s.name, ("", ""))) == "running")
+            or (s.type != "docker" and self.live.pids.get(s.name))
+        )
+        total = len(SERVICES)
+        dirty = sum(1 for d in self.live.dirty.values() if d)
+        active = self.active_cmd or "idle"
+        elapsed = ""
+        if self.active_cmd and self.cmd_started_at:
+            elapsed = f"  ({int(time.monotonic() - self.cmd_started_at)}s)"
+        dirty_part = f"  ★ {dirty} dirty" if dirty else ""
+        # Surface the frontend URL the moment ng serve is listening, so the
+        # user knows when the web app is actually clickable.
+        frontend_pid = self.live.pids.get("frontend")
+        url_part = "  →  http://localhost:4200" if frontend_pid else ""
+        self.query_one("#status-bar", Static).update(
+            f"{running}/{total} running{dirty_part}{url_part}    last: {active}{elapsed}"
+        )
+
+    # State-cell renderer: when the shell has written a transitional phase
+    # (stopping / building / starting), we display that with cycling dots
+    # ("building." → "building.." → "building...") so the user sees motion
+    # while the per-service action is in flight. Cycles at 3 Hz off the
+    # main poll tick.
+    _PHASE_PALETTE = {
+        "stopping": ("◐", "yellow"),
+        "building": ("⚙", "cyan"),
+        "starting": ("⚠", "yellow"),
+    }
+
+    def _phase_text(self, phase: str) -> Text:
+        sym, style = self._PHASE_PALETTE.get(phase, ("⚠", "yellow"))
+        dots = "." * (int(time.monotonic() * 3) % 3 + 1)
+        return Text(f"{phase}{dots}", style=style)
+
+    def _refresh_table(self) -> None:
+        table = self.query_one(DataTable)
+        for svc in SERVICES:
+            phase = self.live.phases.get(svc.name, "")
+            if svc.type == "docker":
+                ds, dstatus = self.live.docker.get(svc.name, ("", ""))
+                state = docker_state(ds, dstatus)
+                pid = "—"
+            else:
+                pid = self.live.pids.get(svc.name) or "—"
+                state = "running" if self.live.pids.get(svc.name) else "stopped"
+            # If the shell signalled a transitional phase AND we haven't
+            # observed the service as fully running yet, render the phase
+            # instead. Once the poller confirms "running" we trust that
+            # over a stale stop/build/start marker.
+            if phase and state != "running":
+                sym, style = self._PHASE_PALETTE.get(phase, ("⚠", "yellow"))
+                state_cell_text = self._phase_text(phase)
+            else:
+                sym, style = STATE_STYLE.get(state, ("○", "grey50"))
+                state_cell_text = Text(state, style=style)
+            mtime = self.live.mtimes.get(svc.name) or "—"
+            dirty = self.live.dirty.get(svc.name, False)
+
+            # BUILD column tells the user whether they need to do anything to
+            # bring the running service in sync with the current source.
+            #   ★ (yellow) — content/lock changed since last build; needs action.
+            #   ↻ (cyan)   — service auto-rebuilds on file change (ng serve /
+            #               bun --watch). Reassurance that no manual step is
+            #               needed for source edits.
+            #   (blank)    — built and up-to-date.
+            if dirty:
+                src_cell: Text | str = Text("★", style="bold yellow")
+            elif svc.type in WATCH_TYPES:
+                src_cell = Text("↻", style="cyan")
+            else:
+                src_cell = "  "
+
+            uptime = self.live.uptimes.get(svc.name, "—")
+            cpu    = self.live.cpu_pct.get(svc.name, "—")
+            mem    = self.live.mem_use.get(svc.name, "—")
+            table.update_cell(svc.name, "sym",    Text(sym, style=style))
+            table.update_cell(svc.name, "port",   f":{svc.port}")
+            table.update_cell(svc.name, "pid",    str(pid))
+            table.update_cell(svc.name, "uptime", uptime)
+            table.update_cell(svc.name, "cpu",    cpu)
+            table.update_cell(svc.name, "mem",    mem)
+            table.update_cell(svc.name, "mtime",  mtime)
+            table.update_cell(svc.name, "src",    src_cell)
+            table.update_cell(svc.name, "state",  state_cell_text)
+
+    # ── Polling tasks (Textual will run them on the event loop) ──
+    @work(exclusive=True, group="state")
+    async def _tick_state(self) -> None:
+        # Polling cost ≈ 1 docker compose ps + lsof × N native services, run
+        # concurrently.  ~200 ms total on this box.
+        docker_task = asyncio.create_task(docker_ps_all())
+        native_tasks = {
+            s.name: asyncio.create_task(lsof_port_pid(s.port))
+            for s in SERVICES if s.type != "docker"
+        }
+        docker_map = await docker_task
+        pids = {name: await task for name, task in native_tasks.items()}
+
+        # Dirty check is more expensive; only re-do every DIRTY_INTERVAL_S.
+        now = time.monotonic()
+        if now - self._last_dirty_check >= DIRTY_INTERVAL_S:
+            loop = asyncio.get_running_loop()
+            self._cached_dirty = {
+                s.name: await loop.run_in_executor(None, is_dirty, s) for s in SERVICES
+            }
+            self._cached_mtimes = {s.name: artifact_mtime_str(s) for s in SERVICES}
+            self._last_dirty_check = now
+
+        # Per-service uptime / cpu / mem.
+        # Native: cheap `ps -o etime,pcpu,rss` per pid (<5ms each).
+        # Docker: cached via `docker stats --no-stream` refreshed by a
+        #         background worker every _DOCKER_STATS_TTL seconds (the
+        #         call itself takes ~2s, too slow to run in-line each tick).
+        uptimes: dict[str, str] = {}
+        cpus:    dict[str, str] = {}
+        mems:    dict[str, str] = {}
+        for s in SERVICES:
+            if s.type == "docker":
+                state = docker_state(*docker_map.get(s.name, ("", "")))
+                if state == "running":
+                    uptimes[s.name] = container_uptime(s.name)
+                    container = _docker_container_for(s.name)
+                    cpu_mem = _DOCKER_STATS_CACHE.get(container)
+                    if cpu_mem:
+                        cpus[s.name], mems[s.name] = cpu_mem
+                    else:
+                        cpus[s.name] = "…"; mems[s.name] = "…"
+                else:
+                    uptimes[s.name] = "—"; cpus[s.name] = "—"; mems[s.name] = "—"
+            else:
+                pid = pids.get(s.name)
+                if pid:
+                    up, cpu, mem = proc_uptime_cpu_mem(pid)
+                    uptimes[s.name] = up; cpus[s.name] = cpu; mems[s.name] = mem
+                else:
+                    uptimes[s.name] = "—"; cpus[s.name] = "—"; mems[s.name] = "—"
+        # Kick a docker-stats refresh if the cache is stale; runs in
+        # background so it never blocks the tick.
+        if time.time() - _DOCKER_STATS_TS >= _DOCKER_STATS_TTL:
+            self._refresh_docker_stats_worker()
+
+        phases = {s.name: (read_svc_phase(s.name) or "") for s in SERVICES}
+        new_state = LiveState(
+            docker=docker_map,
+            pids=pids,
+            dirty=dict(self._cached_dirty),
+            mtimes=dict(self._cached_mtimes),
+            uptimes=uptimes,
+            cpu_pct=cpus,
+            mem_use=mems,
+            phases=phases,
+        )
+        self.live = new_state
+        self._refresh_table()
+        self._update_status_bar()
+
+    @work(exclusive=True, group="docker_stats")
+    async def _refresh_docker_stats_worker(self) -> None:
+        loop = asyncio.get_running_loop()
+        await loop.run_in_executor(None, _refresh_docker_stats)
+
+    def _tick_banner(self) -> None:
+        self._update_banner()
+
+    def _tick_phase_animation(self) -> None:
+        """Refresh only the STATE cells whose phase is mid-transition, so
+        the trailing dots animate at 3 Hz without a full poll."""
+        active = {
+            name: phase for name, phase in self.live.phases.items() if phase
+        }
+        if not active:
+            return
+        table = self.query_one(DataTable)
+        for name, phase in active.items():
+            # Skip if the poller has already declared the service running —
+            # don't fight the steady-state render.
+            svc = SERVICES_BY_NAME.get(name)
+            if svc is None:
+                continue
+            if svc.type == "docker":
+                ds, dstatus = self.live.docker.get(name, ("", ""))
+                if docker_state(ds, dstatus) == "running":
+                    continue
+            else:
+                if self.live.pids.get(name):
+                    continue
+            with contextlib.suppress(Exception):
+                table.update_cell(name, "state", self._phase_text(phase))
+
+    @work(exclusive=True, group="log")
+    async def _tick_log(self) -> None:
+        if not REPL_LOG.exists():
+            return
+        try:
+            size = REPL_LOG.stat().st_size
+        except OSError:
+            return
+        if size < self.log_log_position:
+            # File was truncated (new command).  Reset and reread tail.
+            self.log_log_position = 0
+            self.query_one("#log", RichLog).clear()
+        if size == self.log_log_position:
+            return
+        with REPL_LOG.open("rb") as f:
+            f.seek(self.log_log_position)
+            new_bytes = f.read()
+        self.log_log_position = size
+        text = new_bytes.decode(errors="replace")
+        log = self.query_one("#log", RichLog)
+        for raw_line in text.splitlines():
+            log.write(_strip_ansi_motion(raw_line))
+
+    # ── Command handling ──
+    def on_input_submitted(self, message: Input.Submitted) -> None:
+        cmd = message.value.strip()
+        message.input.value = ""
+        if isinstance(message.input, HistoricInput) and cmd:
+            message.input.push(cmd)
+        if not cmd:
+            return
+        if cmd in ("q", "quit", "exit"):
+            self.exit()
+            return
+        if cmd in ("h", "?", "help"):
+            self._show_help()
+            return
+        if cmd in ("r", "refresh"):
+            self.call_later(self._tick_state)
+            return
+        if cmd in ("clear",):
+            self.query_one("#log", RichLog).clear()
+            self._set_log_visible(False)
+            return
+        if cmd in ("log",):
+            # Toggle log pane visibility manually.
+            self._set_log_visible(not self._log_visible)
+            return
+        if cmd in ("banner",):
+            self.action_toggle_banner()
+            return
+        self._dispatch(cmd)
+
+    def _show_help(self) -> None:
+        log = self.query_one("#log", RichLog)
+        log.clear()
+        self._set_log_visible(True)
+        for line in [
+            "Commands:",
+            "  r           refresh state now",
+            "  u           build + start every service",
+            "  u <svc>     start one service (no rebuild)",
+            "  d           stop every service",
+            "  d <svc>     stop one service",
+            "  b           force incremental sbt + node deps",
+            "  a / auto    scan for dirty services and rebuild+bounce only those",
+            "  <svc>       rebuild that service and bounce it",
+            "  l <svc>     tail that service's log (Ctrl-C returns)",
+            "  s <svc>     stop one service",
+            "  clear       clear the log pane",
+            "  log         toggle log pane visibility",
+            "  banner      toggle banner (collapse the wordmark to save rows; Ctrl-B also works)",
+            "  q           quit",
+            "",
+            "Mouse: double-click a service row → tail its log.",
+            "       double-click the banner    → collapse / expand the wordmark.",
+            "       Enter on a focused row does the same as double-click.",
+            "",
+            "BUILD column:",
+            "  ★ (yellow)  source or deps changed since last build — rebuild needed",
+            "  ↻ (cyan)    service auto-rebuilds on save (ng serve / bun --watch)",
+            "  (blank)     built and up-to-date",
+            "",
+            f"Known services: {', '.join(s.name for s in SERVICES)}",
+        ]:
+            log.write(line)
+
+    def _dispatch(self, cmd: str) -> None:
+        if self._cmd_proc and self._cmd_proc.returncode is None:
+            log = self.query_one("#log", RichLog)
+            log.write(Text(f"busy: '{self.active_cmd}' still running. Ctrl-C in the term to abort.", style="bold yellow"))
+            return
+
+        parts = cmd.split(None, 1)
+        verb = parts[0]
+        arg = parts[1] if len(parts) > 1 else ""
+
+        # Resolve to a bin/local-dev.sh invocation.  Keeping the shell script
+        # as the canonical engine so behavior matches `bin/local-dev.sh up`
+        # from a terminal.
+        argv: Optional[list[str]] = None
+        if verb in ("u", "up"):
+            if arg:
+                if arg not in SERVICES_BY_NAME:
+                    self._log_err(f"unknown service: {arg}")
+                    return
+                argv = ["start", arg]
+            else:
+                argv = ["up"]
+        elif verb in ("d", "down"):
+            if arg:
+                if arg not in SERVICES_BY_NAME:
+                    self._log_err(f"unknown service: {arg}")
+                    return
+                argv = ["stop", arg]
+            else:
+                argv = ["down"]
+        elif verb in ("s", "stop"):
+            if not arg or arg not in SERVICES_BY_NAME:
+                self._log_err("usage: s <service>")
+                return
+            argv = ["stop", arg]
+        elif verb in ("b", "build"):
+            # Force an incremental build; the shell handles the "is this
+            # really needed" decision itself (it pre-bounces JVMs etc.).
+            argv = ["up", "--build"]
+        elif verb in ("a", "auto"):
+            # Scan for dirty services and rebuild + bounce only those.
+            argv = ["auto"]
+        elif verb in ("l", "logs", "tail"):
+            if not arg or arg not in SERVICES_BY_NAME:
+                self._log_err(f"usage: l <service>  (known: {', '.join(s.name for s in SERVICES)})")
+                return
+            argv = ["logs", arg]
+            self._spawn_logs(arg)
+            return
+        elif verb in SERVICES_BY_NAME:
+            svc_obj = SERVICES_BY_NAME[verb]
+            if svc_obj.type in WATCH_TYPES:
+                # ng serve / bun --watch rebuild on save automatically. The
+                # shell's cmd_update_one already refuses this, but we get a
+                # nicer message by intercepting here.
+                self._log_msg(
+                    f"{verb} runs in watch mode (↻) — source edits auto-reload. "
+                    f"If you really need to bounce it, run `s {verb}` then `u {verb}`."
+                )
+                return
+            argv = [verb]
+        else:
+            self._log_err(f"unknown: {verb}   (type 'h' for help)")
+            return
+
+        if argv is None:
+            return
+        self._spawn_action(verb if not arg else f"{verb} {arg}", argv)
+
+    def _log_err(self, msg: str) -> None:
+        self._set_log_visible(True)
+        self.query_one("#log", RichLog).write(Text("✗ " + msg, style="bold red"))
+
+    def _log_msg(self, msg: str) -> None:
+        self._set_log_visible(True)
+        self.query_one("#log", RichLog).write(Text("• " + msg, style="cyan"))
+        # Auto-dismiss the info pop after a few seconds.
+        if self._log_auto_hide_handle is not None:
+            with contextlib.suppress(Exception):
+                self._log_auto_hide_handle.stop()
+        self._log_auto_hide_handle = self.set_timer(4.0, self._auto_hide_log)
+
+    def _spawn_logs(self, svc: str) -> None:
+        log = self.query_one("#log", RichLog)
+        log.clear()
+        self._set_log_visible(True)
+        log.write(Text(f"── tailing {svc} (type any other command to return) ──", style="dim"))
+        self.active_cmd = f"logs {svc}"
+        self.cmd_started_at = time.monotonic()
+        self._tail_service_log(svc)
+
+    @work(exclusive=True, group="cmd")
+    async def _tail_service_log(self, svc: str) -> None:
+        log_widget = self.query_one("#log", RichLog)
+        svc_obj = SERVICES_BY_NAME[svc]
+        if svc_obj.type == "docker":
+            proc = await asyncio.create_subprocess_exec(
+                "docker", "compose", "-p", DOCKER_PROJECT, "logs", "-f", svc,
+                stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
+            )
+        else:
+            log_path = LOG_DIR / f"{svc}.log"
+            if not log_path.exists():
+                log_path.touch()
+            proc = await asyncio.create_subprocess_exec(
+                "tail", "-n", "200", "-f", str(log_path),
+                stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
+            )
+        self._cmd_proc = proc
+        try:
+            assert proc.stdout
+            async for raw in proc.stdout:
+                # If a newer action (e.g. `up`) has claimed _cmd_proc, our
+                # exclusive-group cancellation is in flight but hasn't hit
+                # the next await yet — stop writing to the widget *now* so
+                # we don't leak frontend.log lines into the up output.
+                if self._cmd_proc is not proc:
+                    break
+                log_widget.write(_strip_ansi_motion(raw.decode(errors="replace").rstrip("\n")))
+        finally:
+            # ESC fires _cancel_active_cmd which terminates this proc AND
+            # immediately clears active_cmd/_cmd_proc so the user can run
+            # another command. By the time our async-for unwinds the proc
+            # we're holding, the user may have already kicked off `up`. Only
+            # clear the global state if it still belongs to us — otherwise
+            # we'd clobber a freshly-spawned command's state to idle, and
+            # any subsequent up/down would see a stale view.
+            if self._cmd_proc is proc:
+                self.active_cmd = None
+                self._cmd_proc = None
+
+    @work(exclusive=True, group="cmd")
+    async def _spawn_action(self, label: str, argv: list[str]) -> None:
+        log = self.query_one("#log", RichLog)
+        # Cancel any pending auto-hide from a previous command.
+        if self._log_auto_hide_handle is not None:
+            with contextlib.suppress(Exception):
+                self._log_auto_hide_handle.stop()
+            self._log_auto_hide_handle = None
+        # Trip _tail_service_log's ownership check (`self._cmd_proc is not
+        # proc → break`) immediately. Otherwise the tail keeps draining
+        # frontend.log into the log widget across our log.clear() + the
+        # async point at create_subprocess_exec below, and the user sees
+        # frontend output mixed into the up panel.
+        self._cmd_proc = None
+        # Truncate REPL_LOG so the log pane only shows this command's output.
+        REPL_LOG.write_text("")
+        log.clear()
+        self._set_log_visible(True)
+        log.write(Text(f"── {label}  →  bin/local-dev.sh {' '.join(shlex.quote(a) for a in argv)} ──", style="dim"))
+        self.active_cmd = label
+        self.cmd_started_at = time.monotonic()
+        self.log_log_position = 0
+
+        with REPL_LOG.open("wb") as out:
+            proc = await asyncio.create_subprocess_exec(
+                str(LOCAL_DEV_SH), *argv,
+                stdout=out, stderr=asyncio.subprocess.STDOUT,
+                cwd=str(REPO_ROOT),
+            )
+            self._cmd_proc = proc
+            await proc.wait()
+        rc = proc.returncode or 0
+        style = "bold green" if rc == 0 else "bold red"
+        log.write(Text(f"── {label}: done (exit {rc}) ──", style=style))
+        # Same guard as _tail_service_log: only release the global state if
+        # it still points at our proc. Otherwise a faster newer command has
+        # already taken over and we mustn't reset it to idle.
+        if self._cmd_proc is proc:
+            self.active_cmd = None
+            self._cmd_proc = None
+        # Right after a command, source state likely moved — force a state poll.
+        self._last_dirty_check = 0
+        self.call_later(self._tick_state)
+
+        # Successful command → auto-hide the log so the dashboard reclaims the
+        # space.  Failure stays visible so the user can read the error.
+        if rc == 0:
+            self._log_auto_hide_handle = self.set_timer(3.0, self._auto_hide_log)
+
+    def _auto_hide_log(self) -> None:
+        # Only hide if no new command came in and the user hasn't engaged with
+        # the log (we keep it pinned during interactive log tailing).
+        if self.active_cmd is None:
+            self._set_log_visible(False)
+        self._log_auto_hide_handle = None
+
+    def action_toggle_banner(self) -> None:
+        """Collapse / expand the ASCII wordmark to reclaim ~7 rows."""
+        banner = self.query_one("#banner")
+        if banner.has_class("-collapsed"):
+            banner.remove_class("-collapsed")
+        else:
+            banner.add_class("-collapsed")
+
+    def action_clear_log(self) -> None:
+        self.query_one("#log", RichLog).clear()
+        self._set_log_visible(False)
+
+    def action_manual_refresh(self) -> None:
+        self.call_later(self._tick_state)
+
+    # ── ESC: leave whatever transient view we're in ──
+    def action_escape_view(self) -> None:
+        # If a log tail (or any running command) is up, cancel it.
+        if self.active_cmd is not None:
+            self._cancel_active_cmd()
+        # Hide the log pane regardless — ESC's main job is to give the
+        # dashboard the screen back.
+        if self._log_visible:
+            self._set_log_visible(False)
+        # Make sure the prompt has focus so the user can immediately type.
+        self.query_one("#prompt", HistoricInput).focus()
+
+    # ── Ctrl-C: cancel current work, or quit on a second tap ──
+    def action_soft_quit(self) -> None:
+        # 1. Active command (build / up / log tail …) → kill it, keep the
+        #    REPL open.
+        if self.active_cmd is not None:
+            self._cancel_active_cmd()
+            self.notify("Ctrl-C — cancelled current command", timeout=2)
+            self._last_ctrl_c_ts = 0.0
+            return
+        # 2. Idle: require a second Ctrl-C within 2 s to actually exit. This
+        #    prevents an accidental ⌃-C from killing a session you wanted to
+        #    keep.
+        now = time.monotonic()
+        if now - self._last_ctrl_c_ts < 2.0:
+            self.exit()
+            return
+        self._last_ctrl_c_ts = now
+        self.notify("Press Ctrl-C again within 2 s to quit", timeout=2)
+
+    def _cancel_active_cmd(self) -> None:
+        proc = self._cmd_proc
+        if proc and proc.returncode is None:
+            with contextlib.suppress(ProcessLookupError, OSError):
+                proc.terminate()
+        # Cancel the worker too. proc.terminate() races with stdout draining
+        # — until the worker's `async for` actually returns, Textual still
+        # considers it alive in group "cmd", which can block the next
+        # `@work(exclusive=True, group="cmd")` from ever starting. That's
+        # the bug behind "tap into a log → ESC → `down` does nothing".
+        with contextlib.suppress(Exception):
+            self.workers.cancel_group(self, "cmd")
+        self.active_cmd = None
+        self._cmd_proc = None
+
+    # ── Mouse: double-click a service row to tail its log ──
+    def on_click(self, event: events.Click) -> None:
+        widget = getattr(event, "control", None) or getattr(event, "widget", None)
+
+        # Double-click on any banner element → toggle (collapse / expand).
+        # Lets the user reclaim the wordmark's rows without remembering the
+        # Ctrl-B / `banner` aliases.
+        if event.chain == 2 and self._widget_in_banner(widget):
+            self.action_toggle_banner()
+            return
+
+        # Double-click on a service row → tail that service's log.
+        if event.chain == 2:
+            table = self.query_one(DataTable)
+            if widget is table:
+                row = table.cursor_row
+                if 0 <= row < len(SERVICES):
+                    self._spawn_logs(SERVICES[row].name)
+                    self.query_one("#prompt", Input).focus()
+
+    def _widget_in_banner(self, widget) -> bool:
+        """True when `widget` is the banner container or any of its
+        children. We walk the parent chain rather than matching IDs so a
+        click on the wordmark / title / sub line all count."""
+        node = widget
+        while node is not None:
+            if getattr(node, "id", None) == "banner":
+                return True
+            node = getattr(node, "parent", None)
+        return False
+
+    # Enter on a focused row works too (keyboard equivalent of double-click).
+    def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
+        svc = str(event.row_key.value) if event.row_key.value else ""
+        if svc and svc in SERVICES_BY_NAME:
+            self._spawn_logs(svc)
+
+
+# ─────────────────── ANSI motion stripper for log ───────────────────
+
+_CSI_NON_SGR = re.compile(r"\x1b\[[0-9;?]*[A-LN-Za-ln-z]")   # everything except SGR (m)
+_CR = re.compile(r"\r+")
+
+
+def _strip_ansi_motion(s: str) -> str:
+    """Strip cursor-motion / erase-screen CSI sequences while keeping SGR colors,
+    and collapse \\r so spinner frames don't pile up in the log pane."""
+    s = _CSI_NON_SGR.sub("", s)
+    s = _CR.sub("", s)
+    return s
+
+
+# ─────────────────── Entry point ───────────────────
+
+def main() -> None:
+    if not LOCAL_DEV_SH.exists():
+        print(f"FATAL: {LOCAL_DEV_SH} not found", file=__import__("sys").stderr)
+        raise SystemExit(1)
+    if shutil.which("docker") is None:
+        print("FATAL: docker not on PATH", file=__import__("sys").stderr)
+        raise SystemExit(1)
+    LocalDevApp().run()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/bin/local-dev.sh b/bin/local-dev.sh
new file mode 100755
index 0000000000..a89eb29a4d
--- /dev/null
+++ b/bin/local-dev.sh
@@ -0,0 +1,2536 @@
+#!/usr/bin/env bash
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# bin/local-dev.sh -- Manage the Texera local dev stack from a single script.
+#
+# Subcommands:
+#   bin/local-dev.sh                          DEFAULT — one-shot text
+#                                             dashboard (same as `status`).
+#                                             Non-interactive, prints once and
+#                                             exits — safe in scripts/CI.
+#   bin/local-dev.sh -i  | --interactive      Launch the Textual TUI dashboard
+#                                             (live states, SRC dirty
+#                                             indicator, command prompt,
+#                                             double-click for logs, ↑/↓
+#                                             history, Ctrl-C twice to quit).
+#                                             Requires Python + textual.
+#   bin/local-dev.sh status                   same as no-arg invocation.
+#   bin/local-dev.sh up   [--fresh|--build|--no-build] [--skip=svc1,svc2]
+#                                             Default: skip build if no source/lock
+#                                             changes since last build. --build forces
+#                                             incremental sbt dist + yarn/bun install.
+#                                             --fresh runs `sbt clean dist`. --no-build
+#                                             skips the build step entirely.
+#   bin/local-dev.sh down [--skip=svc1,svc2]  stop every non-skipped service.
+#   bin/local-dev.sh start <service>          start one service (no rebuild).
+#   bin/local-dev.sh stop  <service>          stop one service.
+#   bin/local-dev.sh <service>                rebuild only that service incrementally
+#                                             (sbt <Project>/dist), then bounce it.
+#                                             frontend / agent-service are refused
+#                                             (they have their own watch mode).
+#   bin/local-dev.sh logs <service>           tail this service's log.
+#   bin/local-dev.sh w | watch [interval]     Hands-off monitor: redraw the
+#                                             dashboard every <interval>s
+#                                             (default 2). No prompt; Ctrl-C
+#                                             to exit.
+#
+# Managed services (start order):
+#   config-service                 :9094  JVM (sbt ConfigService)
+#   access-control-service         :9096  JVM (sbt AccessControlService)
+#   file-service                   :9092  JVM (sbt FileService)
+#   workflow-compiling-service     :9090  JVM (sbt WorkflowCompilingService)
+#   computing-unit-managing-service :8082 JVM (sbt ComputingUnitManagingService)
+#   texera-web                     :8080  JVM (sbt WorkflowExecutionService, amber)
+#   computing-unit-master          :8085  JVM (rides amber dist; no own sbt project)
+#   agent-service                  :3001  Bun --watch (cd agent-service && bun run dev)
+#   frontend                       :4200  ng serve via cd frontend && yarn start
+#
+# Docker infra (postgres / minio / lakefs / lakekeeper / litellm) is NOT managed
+# here -- bring those up yourself before `up`. The script will warn if expected
+# ports are unreachable.
+#
+# Logs and pid book-keeping live under: ${TEXERA_LOCAL_DEV_DIR:-/tmp/texera-local-dev}
+
+set -euo pipefail
+# Unmatched globs in bash default to the literal pattern (we handle that
+# in-place at every glob site). `failglob` / `nullglob` are opt-in per glob
+# via `( shopt -s nullglob; ... )` subshells where we need empty-on-no-match.
+
+REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$REPO_ROOT"
+
+STATE_DIR="${TEXERA_LOCAL_DEV_DIR:-/tmp/texera-local-dev}"
+LOG_DIR="$STATE_DIR/logs"
+BUILD_STAMP_DIR="$STATE_DIR/build-stamps"
+# Per-service phase markers: shell writes `<phase>\t<epoch>` here as it
+# walks each service through stop → build → start; the TUI reads them
+# every tick and renders an animated transitional state in the STATE
+# column. Removed once the service is up / on stale-after-90s.
+PHASE_DIR="$STATE_DIR/svc-phase"
+mkdir -p "$LOG_DIR" "$BUILD_STAMP_DIR" "$PHASE_DIR"
+
+# --------- associative-array shim for bash 3.2 ---------
+# Apple ships bash 3.2 at /bin/bash and we ship licensing as bash 3.2 too,
+# so we can't use `declare -A`. Every old `MAP[$key]=val` / `${MAP[$key]}`
+# is reshaped as a function call against a flat namespace of synthetic
+# variables `_amap__<MAP>__<key>`. Keys containing `-` get mangled to `_`
+# so they're valid identifier chars.
+amap_set() {
+    local _map="$1" _key="${2//-/_}" _val="$3"
+    eval "_amap__${_map}__${_key}=\$_val"
+}
+amap_get() {
+    local _map="$1" _key="${2//-/_}"
+    local _var="_amap__${_map}__${_key}"
+    eval "printf '%s' \"\${$_var:-}\""
+}
+amap_has() {
+    local _map="$1" _key="${2//-/_}"
+    local _var="_amap__${_map}__${_key}"
+    eval "[[ \${$_var+x} ]]"
+}
+amap_append() {
+    # amap_append MAP key suffix — append to existing value (or seed it).
+    local _map="$1" _key="${2//-/_}" _suffix="$3"
+    local _var="_amap__${_map}__${_key}"
+    eval "$_var=\"\${$_var:-}\$_suffix\""
+}
+
+# --------- toolchain (JDK 17 + node) ---------
+# Detect a JDK 17 installation rather than pinning one path. We try, in
+# order: (1) caller-set $JAVA_HOME if it really is 17, (2) macOS's official
+# locator `/usr/libexec/java_home -v 17`, (3) Homebrew on Apple Silicon +
+# Intel, (4) common Linux distro paths (openjdk / temurin / corretto / zulu),
+# (5) SDKMAN, (6) asdf, (7) the `java` on PATH if its `-version` says 17.
+# Fall through to a clear install hint if none match.
+_java_is_17() {
+    local home="$1"
+    [[ -x "$home/bin/java" ]] || return 1
+    "$home/bin/java" -version 2>&1 | head -1 | grep -q '"17[.]' || return 1
+    return 0
+}
+
+_find_jdk17() {
+    local cand=""
+    # 1. Respect $JAVA_HOME if the caller already set it AND it's 17.
+    if [[ -n "${JAVA_HOME:-}" ]] && _java_is_17 "$JAVA_HOME"; then
+        printf '%s\n' "$JAVA_HOME"; return 0
+    fi
+    # 2. macOS native locator (works for any vendor installed via /Library).
+    if command -v /usr/libexec/java_home >/dev/null 2>&1; then
+        cand=$(/usr/libexec/java_home -v 17 2>/dev/null) || cand=""
+        if [[ -n "$cand" ]] && _java_is_17 "$cand"; then
+            printf '%s\n' "$cand"; return 0
+        fi
+    fi
+    # 3. Homebrew — try `brew --prefix openjdk@17` first, then both well-
+    #    known prefixes as a fallback (script may run without brew on PATH
+    #    if /etc/zprofile didn't fire).
+    if command -v brew >/dev/null 2>&1; then
+        cand=$(brew --prefix openjdk@17 2>/dev/null) || cand=""
+        [[ -n "$cand" ]] && _java_is_17 "$cand" && { printf '%s\n' "$cand"; return 0; }
+    fi
+    for cand in /opt/homebrew/opt/openjdk@17 /usr/local/opt/openjdk@17; do
+        _java_is_17 "$cand" && { printf '%s\n' "$cand"; return 0; }
+    done
+    # 4. Linux distro layouts. Glob first match.
+    # bash equivalents of zsh's `*(N)` "null on no match" qualifier: enable
+    # `shopt -s nullglob` locally so an unmatched pattern expands to zero
+    # words. We localize it in a subshell so it doesn't leak out and break
+    # the unquoted globs elsewhere (e.g. unzip's zip glob).
+    local matched=""
+    matched=$(shopt -s nullglob; \
+        for cand in \
+            /usr/lib/jvm/java-17-openjdk* \
+            /usr/lib/jvm/temurin-17-jdk* \
+            /usr/lib/jvm/java-17-amazon-corretto* \
+            /usr/lib/jvm/zulu-17* \
+            /usr/lib/jvm/jdk-17*; do
+            printf '%s\n' "$cand"
+        done)
+    while IFS= read -r cand; do
+        [[ -z "$cand" ]] && continue
+        _java_is_17 "$cand" && { printf '%s\n' "$cand"; return 0; }
+    done <<< "$matched"
+    # 5. SDKMAN (`sdk install java 17.x-...`) — pick the lex-largest 17.* dir.
+    if [[ -d "$HOME/.sdkman/candidates/java" ]]; then
+        matched=$(shopt -s nullglob; \
+            for cand in "$HOME"/.sdkman/candidates/java/17.*; do
+                printf '%s\n' "$cand"
+            done)
+        while IFS= read -r cand; do
+            [[ -z "$cand" ]] && continue
+            _java_is_17 "$cand" && { printf '%s\n' "$cand"; return 0; }
+        done <<< "$matched"
+    fi
+    # 6. asdf.
+    if [[ -d "$HOME/.asdf/installs/java" ]]; then
+        matched=$(shopt -s nullglob; \
+            for cand in "$HOME"/.asdf/installs/java/*17*; do
+                printf '%s\n' "$cand"
+            done)
+        while IFS= read -r cand; do
+            [[ -z "$cand" ]] && continue
+            _java_is_17 "$cand" && { printf '%s\n' "$cand"; return 0; }
+        done <<< "$matched"
+    fi
+    # 7. Whatever `java` is on PATH, IF it's 17 — covers cases like Docker
+    #    images or distro-managed defaults.
+    cand=$(command -v java 2>/dev/null) || cand=""
+    if [[ -n "$cand" ]]; then
+        cand="$(dirname "$(dirname "$cand")")"
+        _java_is_17 "$cand" && { printf '%s\n' "$cand"; return 0; }
+    fi
+    return 1
+}
+
+# Print the current java environment alongside detection results so the
+# user can see *why* JDK 17 lookup failed (wrong version pinned, JAVA_HOME
+# pointing at JDK 21, etc) — not just "couldn't find one".
+_diagnose_jdk17() {
+    echo "" >&2
+    echo "  current java environment:" >&2
+    if [[ -n "${JAVA_HOME:-}" ]]; then
+        local jhver=""
+        jhver=$("$JAVA_HOME/bin/java" -version 2>&1 | head -1 || echo "(unreadable)")
+        echo "    \$JAVA_HOME = $JAVA_HOME" >&2
+        echo "                ↳ $jhver" >&2
+    else
+        echo "    \$JAVA_HOME (unset)" >&2
+    fi
+    local path_java=""
+    path_java=$(command -v java 2>/dev/null) || true
+    if [[ -n "$path_java" ]]; then
+        local pver=""
+        pver=$("$path_java" -version 2>&1 | head -1 || echo "(unreadable)")
+        echo "    \`java\` on PATH → $path_java" >&2
+        echo "                ↳ $pver" >&2
+    else
+        echo "    \`java\` on PATH (not found)" >&2
+    fi
+    if command -v /usr/libexec/java_home >/dev/null 2>&1; then
+        local jh17=""
+        jh17=$(/usr/libexec/java_home -v 17 2>/dev/null) || jh17="(none registered)"
+        echo "    /usr/libexec/java_home -v 17 → $jh17" >&2
+    fi
+    if command -v brew >/dev/null 2>&1; then
+        local brewp=""
+        brewp=$(brew --prefix openjdk@17 2>/dev/null) || brewp="(not installed)"
+        echo "    brew --prefix openjdk@17 → $brewp" >&2
+    fi
+    echo "" >&2
+    echo "  likely cause:" >&2
+    if [[ -n "${JAVA_HOME:-}" ]] && [[ -n "$path_java" ]]; then
+        echo "    JAVA_HOME points at the wrong JDK (probably not 17)." >&2
+        echo "    Either fix JAVA_HOME or unset it to let auto-detect try other paths." >&2
+    elif [[ -n "$path_java" ]]; then
+        echo "    \`java\` on PATH is not JDK 17. Install a 17 sibling or set JAVA_HOME=/path/to/jdk-17." >&2
+    else
+        echo "    No JDK is installed anywhere we know to look." >&2
+    fi
+    echo "" >&2
+}
+
+JAVA_HOME_DETECTED="$(_find_jdk17)" || {
+    echo "FATAL: could not find a JDK 17 install." >&2
+    _diagnose_jdk17
+    echo "  fix:" >&2
+    echo "    macOS:   brew install openjdk@17" >&2
+    echo "    Linux:   apt install openjdk-17-jdk    # or yum/dnf equivalent" >&2
+    echo "    SDKMAN:  sdk install java 17.0.13-tem" >&2
+    echo "    or set JAVA_HOME=/path/to/jdk-17 explicitly" >&2
+    echo "" >&2
+    exit 1
+}
+export JAVA_HOME="$JAVA_HOME_DETECTED"
+export PATH="$JAVA_HOME/bin:$PATH"
+
+# Node: source the user's version manager (if any) so the right `node` is on
+# PATH for yarn/bun/ng. Try nvm, fnm, volta in that order; `command -v node`
+# remains the ultimate fallback.
+if [[ -z "${NVM_DIR:-}" && -d "$HOME/.nvm" ]]; then
+    export NVM_DIR="$HOME/.nvm"
+fi
+if [[ -n "${NVM_DIR:-}" && -s "$NVM_DIR/nvm.sh" ]]; then
+    # shellcheck disable=SC1091
+    \. "$NVM_DIR/nvm.sh" >/dev/null 2>&1 || true
+elif command -v fnm >/dev/null 2>&1; then
+    eval "$(fnm env --use-on-cd 2>/dev/null)" || true
+elif [[ -s "$HOME/.volta/load.sh" ]]; then
+    # shellcheck disable=SC1091
+    \. "$HOME/.volta/load.sh" >/dev/null 2>&1 || true
+fi
+
+# --------- runtime env for backend ---------
+# Detect the host's primary LAN IP so we can use it as the MinIO endpoint.
+# It has to be the same string from both directions:
+#   • host-native JVMs need it to reach localhost-published port 9000
+#   • the lakekeeper container needs it to do server-side S3 ops (validation,
+#     compaction) AND to return URLs to clients that *they* can reach
+# `localhost` only works for the host. `texera-minio` only works inside the
+# docker network. The host's LAN IP works from BOTH (host loopback for the
+# host, docker NAT'd out-and-back for the container).
+_detect_host_lan_ip() {
+    local iface="" ip=""
+    # 1. The interface backing the default route — most reliable on a
+    #    laptop that may have wifi + thunderbolt + tailscale all active.
+    iface=$(route get default 2>/dev/null | awk '/interface:/{print $2; exit}')
+    if [[ -n "$iface" ]]; then
+        ip=$(ipconfig getifaddr "$iface" 2>/dev/null)
+        [[ -n "$ip" && "$ip" != 127.* ]] && { printf '%s\n' "$ip"; return 0; }
+    fi
+    # 2. Fallback: linux `hostname -I`-equivalent walk over en*.
+    for iface in en0 en1 en2 en3 en4 en5 en6 en7 en8 en9 en10; do
+        ip=$(ipconfig getifaddr "$iface" 2>/dev/null)
+        [[ -n "$ip" && "$ip" != 127.* ]] && { printf '%s\n' "$ip"; return 0; }
+    done
+    return 1
+}
+# Lazy resolver — called from subcommands that actually need to publish a
+# host-reachable S3 endpoint (cmd_up, cmd_auto). Subcommands like
+# `version`, `status`, `--help`, or `-i` don't talk to MinIO and shouldn't
+# refuse to run just because the laptop is offline.
+_require_host_lan_ip() {
+    [[ -n "${HOST_LAN_IP:-}" ]] && return 0
+    HOST_LAN_IP="$(_detect_host_lan_ip)" || HOST_LAN_IP=""
+    if [[ -z "$HOST_LAN_IP" ]]; then
+        echo "FATAL: could not detect a host LAN IP." >&2
+        echo "       MinIO needs an address reachable from both docker (lakekeeper" >&2
+        echo "       does S3 ops) and the host (JVMs read signed URLs back); none" >&2
+        echo "       of \`route get default\` / en0-en10 had a non-loopback IPv4." >&2
+        echo "       Connect to a network or export HOST_LAN_IP=<your-IP> explicitly." >&2
+        exit 1
+    fi
+    export HOST_LAN_IP
+    # STORAGE_S3_ENDPOINT below uses the parameter-default form so it
+    # picks up HOST_LAN_IP once we set it here.
+    export STORAGE_S3_ENDPOINT="${STORAGE_S3_ENDPOINT:-http://$HOST_LAN_IP:9000}"
+}
+
+export STORAGE_JDBC_URL="${STORAGE_JDBC_URL:-jdbc:postgresql://localhost:5432/texera_db?currentSchema=texera_db,public}"
+export STORAGE_JDBC_USERNAME="${STORAGE_JDBC_USERNAME:-texera}"
+export STORAGE_JDBC_PASSWORD="${STORAGE_JDBC_PASSWORD:-password}"
+# STORAGE_S3_ENDPOINT is set lazily by _require_host_lan_ip — only the
+# subcommands that actually touch MinIO (infra_up + cmd_up + cmd_auto)
+# trigger that detection, so `version` / `status` / `-i` work offline.
+export STORAGE_S3_AUTH_USERNAME="${STORAGE_S3_AUTH_USERNAME:-texera_minio}"
+export STORAGE_S3_AUTH_PASSWORD="${STORAGE_S3_AUTH_PASSWORD:-password}"
+export STORAGE_S3_REGION="${STORAGE_S3_REGION:-us-west-2}"
+export STORAGE_ICEBERG_CATALOG_TYPE="${STORAGE_ICEBERG_CATALOG_TYPE:-rest}"
+export STORAGE_ICEBERG_CATALOG_REST_URI="${STORAGE_ICEBERG_CATALOG_REST_URI:-http://localhost:8181/catalog}"
+export STORAGE_ICEBERG_CATALOG_REST_WAREHOUSE_NAME="${STORAGE_ICEBERG_CATALOG_REST_WAREHOUSE_NAME:-texera}"
+export STORAGE_ICEBERG_CATALOG_REST_S3_BUCKET="${STORAGE_ICEBERG_CATALOG_REST_S3_BUCKET:-texera-iceberg}"
+export STORAGE_ICEBERG_CATALOG_POSTGRES_USERNAME="${STORAGE_ICEBERG_CATALOG_POSTGRES_USERNAME:-texera}"
+export STORAGE_ICEBERG_CATALOG_POSTGRES_PASSWORD="${STORAGE_ICEBERG_CATALOG_POSTGRES_PASSWORD:-password}"
+export STORAGE_ICEBERG_CATALOG_POSTGRES_URI_WITHOUT_SCHEME="${STORAGE_ICEBERG_CATALOG_POSTGRES_URI_WITHOUT_SCHEME:-localhost:5432/texera_iceberg_catalog}"
+export STORAGE_LAKEFS_ENDPOINT="${STORAGE_LAKEFS_ENDPOINT:-http://localhost:8000/api/v1}"
+export STORAGE_LAKEFS_AUTH_USERNAME="${STORAGE_LAKEFS_AUTH_USERNAME:-AKIAIOSFOLKFSSAMPLES}"
+export STORAGE_LAKEFS_AUTH_PASSWORD="${STORAGE_LAKEFS_AUTH_PASSWORD:-wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY}"
+export STORAGE_LAKEFS_AUTH_API_SECRET="${STORAGE_LAKEFS_AUTH_API_SECRET:-random_string_for_lakefs}"
+export UDF_PYTHON_PATH="${UDF_PYTHON_PATH:-$(command -v python3 2>/dev/null || command -v python 2>/dev/null)}"
+export TEXERA_DASHBOARD_SERVICE_ENDPOINT="${TEXERA_DASHBOARD_SERVICE_ENDPOINT:-http://localhost:8080}"
+export WORKFLOW_COMPILING_SERVICE_ENDPOINT="${WORKFLOW_COMPILING_SERVICE_ENDPOINT:-http://localhost:9090}"
+export WORKFLOW_EXECUTION_SERVICE_ENDPOINT="${WORKFLOW_EXECUTION_SERVICE_ENDPOINT:-http://localhost:8085}"
+export FILE_SERVICE_GET_PRESIGNED_URL_ENDPOINT="${FILE_SERVICE_GET_PRESIGNED_URL_ENDPOINT:-http://localhost:9092/api/dataset/presign-download}"
+export FILE_SERVICE_UPLOAD_ONE_FILE_TO_DATASET_ENDPOINT="${FILE_SERVICE_UPLOAD_ONE_FILE_TO_DATASET_ENDPOINT:-http://localhost:9092/api/dataset/did/upload}"
+export LITELLM_BASE_URL="${LITELLM_BASE_URL:-http://localhost:4000}"
+export LITELLM_MASTER_KEY="${LITELLM_MASTER_KEY:-sk-texera-internal-do-not-share}"
+export LLM_ENDPOINT="${LLM_ENDPOINT:-http://localhost:8080}"
+export LLM_API_KEY="${LLM_API_KEY:-dummy}"
+
+# --------- texera version (dynamic) ---------
+# The sbt-native-packager dist directory and jar names embed the project
+# version (e.g. target/config-service-<VERSION>/...). That version moves
+# across branches (1.3.0-incubating-SNAPSHOT on main, 1.2.0-incubating on
+# release/v1.2, …) so resolve it from build.sbt at startup rather than
+# hardcoding. Override via the TEXERA_VERSION env var to target a sibling
+# tree or if the build.sbt parse fails.
+_texera_version() {
+    grep -E '^[[:space:]]*ThisBuild[[:space:]]*/[[:space:]]*version[[:space:]]*:=[[:space:]]*"' \
+        "$REPO_ROOT/build.sbt" 2>/dev/null \
+        | head -1 \
+        | sed -E 's/.*"([^"]+)".*/\1/'
+}
+TEXERA_VERSION="${TEXERA_VERSION:-$(_texera_version)}"
+if [[ -z "$TEXERA_VERSION" ]]; then
+    # tui_warn isn't defined yet at this point in the script; print raw.
+    printf "FATAL: could not detect texera version from %s/build.sbt\n" "$REPO_ROOT" >&2
+    printf "       Set the TEXERA_VERSION env var to bypass.\n" >&2
+    exit 1
+fi
+
+# --------- sbt dependency graph (parsed from build.sbt) ---------
+# We parse the build.sbt's project + .dependsOn() declarations so that the
+# per-service "did anything that affects me change?" check can prune to
+# the actual transitive closure. Without this, every JVM service watches
+# every common/* dir and `cmd_auto` rebuilds half the stack whenever
+# common/workflow-operator changes, even though config-service has zero
+# dependency on it.
+#
+# Populates two amap maps keyed by sbt project name plus a parallel
+# indexed-array of keys so we can iterate without `${!MAP[@]}`:
+#   amap_get SBT_PATH ConfigService → "config-service"
+#   amap_get SBT_DEPS ConfigService → "Auth Config"   (space-separated)
+#   SBT_DEPS_KEYS=(ConfigService AccessControlService …)
+SBT_DEPS_KEYS=()
+
+_parse_sbt() {
+    local file="$REPO_ROOT/build.sbt"
+    [[ -f "$file" ]] || return 1
+    local current="" line=""
+    local decl_re='^lazy[[:space:]]+val[[:space:]]+([A-Z][A-Za-z0-9]*)[[:space:]]*=[[:space:]]*\(project[[:space:]]+in[[:space:]]+file\("([^"]+)"\)\)'
+    local deps_re='\.dependsOn\(([^)]*)\)'
+
+    # Helper: split a comma-list of dependsOn args, dropping test-scope
+    # and non-project tokens, then append unique main-scope refs.
+    _absorb() {
+        local args="$1"
+        local IFS_OLD="$IFS"
+        IFS=','
+        # shellcheck disable=SC2206
+        local parts=($args)
+        IFS="$IFS_OLD"
+        local arg="" existing=""
+        for arg in "${parts[@]}"; do
+            arg="${arg// /}"
+            arg="${arg//$'\t'/}"
+            # Drop ONLY test-scope deps. The previous catch-all `*"%"*`
+            # match silently swallowed any future `%`-scoped main dep
+            # (e.g. `X % "compile->compile"`, `X % Provided`) and would
+            # have broken dirty-detection on it.
+            if [[ "$arg" =~ %\"?test(-\>[^\"]*)?\"? ]] || [[ "$arg" =~ %Test$ ]]; then
+                continue
+            fi
+            [[ "$arg" =~ ^[A-Z] ]] || continue
+            existing=$(amap_get SBT_DEPS "$current")
+            case " $existing " in
+                *" $arg "*) ;;
+                *) amap_append SBT_DEPS "$current" " $arg" ;;
+            esac
+        done
+    }
+
+    while IFS= read -r line; do
+        local rest="$line"
+        if [[ "$line" =~ $decl_re ]]; then
+            current="${BASH_REMATCH[1]}"
+            amap_set SBT_PATH "$current" "${BASH_REMATCH[2]}"
+            amap_set SBT_DEPS "$current" ""
+            SBT_DEPS_KEYS+=("$current")
+            # Don't `continue` — `.dependsOn(...)` can chain on the SAME
+            # line as the declaration (WorkflowOperator's one-liner does
+            # this). Fall through so the loop below catches it.
+        elif [[ "$line" =~ ^lazy[[:space:]]+val ]]; then
+            # Other `lazy val` (settings, etc) breaks attribution.
+            current=""
+            continue
+        fi
+        [[ -z "$current" ]] && continue
+        # Scan ALL `.dependsOn(...)` matches on this line — some lines
+        # chain `.dependsOn(A).dependsOn(B % "test->test")` and we must
+        # see both to filter correctly. Snapshot BASH_REMATCH BEFORE
+        # calling `_absorb` — that helper does its own `[[ … =~ … ]]`
+        # for the test-scope filter, which clobbers `BASH_REMATCH[0]`
+        # under us. Without the snapshot the `#*…` trim referenced the
+        # wrong value, the strip silently no-op'd, and the outer while
+        # looped forever on the first `.dependsOn(…)`.
+        local _match="" _captured=""
+        while [[ "$rest" =~ $deps_re ]]; do
+            _match="${BASH_REMATCH[0]}"
+            _captured="${BASH_REMATCH[1]}"
+            _absorb "$_captured"
+            rest="${rest#*"$_match"}"
+        done
+    done < "$file"
+    # Trim leading space on every entry.
+    local p="" cur=""
+    for p in "${SBT_DEPS_KEYS[@]}"; do
+        cur=$(amap_get SBT_DEPS "$p")
+        amap_set SBT_DEPS "$p" "${cur# }"
+    done
+    return 0
+}
+_parse_sbt || true
+
+# BFS from $1 over SBT_DEPS, emit one `<path>/src` per visited project.
+# Caller can also pass an explicit fallback list for the unparseable case.
+_sbt_transitive_src_dirs() {
+    local root="$1"
+    [[ -z "$root" ]] && return 1
+    amap_has SBT_PATH "$root" || return 1
+    # `_visited_` namespace is a per-call amap. We can't easily clear all
+    # entries (bash 3.2 has no `${!_visited_*}` glob over set vars without
+    # `compgen`, but `compgen -v` IS available). We unset matching vars
+    # at function exit so successive calls start clean.
+    local queue=("$root")
+    local p="" path="" d="" key="" var=""
+    while ((${#queue[@]} > 0)); do
+        p="${queue[0]}"
+        queue=("${queue[@]:1}")
+        key="${p//-/_}"
+        var="_amap__visited__${key}"
+        eval "[[ \${$var+x} ]]" && continue
+        eval "$var=1"
+        path=$(amap_get SBT_PATH "$p")
+        [[ -n "$path" ]] && printf '%s/src\n' "$path"
+        local deps=""
+        deps=$(amap_get SBT_DEPS "$p")
+        for d in $deps; do
+            queue+=("$d")
+        done
+    done
+    # Clean up our visited markers so the next caller starts fresh.
+    local v=""
+    for v in $(compgen -v _amap__visited__ 2>/dev/null); do
+        unset "$v"
+    done
+    return 0
+}
+
+# --------- service catalog ---------
+SERVICES=(
+    postgres
+    minio
+    lakefs
+    lakekeeper
+    litellm
+    config-service
+    access-control-service
+    file-service
+    workflow-compiling-service
+    computing-unit-master
+    computing-unit-managing-service
+    texera-web
+    agent-service
+    frontend
+)
+
+# Service catalog. Under bash 3.2 this would normally be `typeset -A` maps;
+# we use the `amap_*` helpers defined above instead. Each amap_set call
+# stores into a synthetic var name (e.g. `_amap__SVC_TYPE__postgres`).
+
+# Each docker service is now its own row in the dashboard. start/stop still
+# batch through infra_up/infra_down because `docker compose up -d` and
+# `docker compose down` operate at the project level.
+amap_set SVC_TYPE postgres   docker; amap_set SVC_PORT postgres   5432; amap_set SVC_CWD postgres   "."
+amap_set SVC_TYPE minio      docker; amap_set SVC_PORT minio      9000; amap_set SVC_CWD minio      "."
+amap_set SVC_TYPE lakefs     docker; amap_set SVC_PORT lakefs     8000; amap_set SVC_CWD lakefs     "."
+amap_set SVC_TYPE lakekeeper docker; amap_set SVC_PORT lakekeeper 8181; amap_set SVC_CWD lakekeeper "."
+amap_set SVC_TYPE litellm    docker; amap_set SVC_PORT litellm    4000; amap_set SVC_CWD litellm    "."
+
+amap_set SVC_TYPE       config-service jvm
+amap_set SVC_PORT       config-service 9094
+amap_set SVC_SBT        config-service ConfigService
+amap_set SVC_LAUNCHER   config-service "target/config-service-${TEXERA_VERSION}/bin/config-service"
+amap_set SVC_CWD        config-service "."
+amap_set SVC_ZIP_GLOB   config-service "config-service/target/universal/config-service-*.zip"
+amap_set SVC_UNZIP_DEST config-service "target/"
+amap_set SVC_HEALTH     config-service "/api/healthcheck"
+
+amap_set SVC_TYPE       access-control-service jvm
+amap_set SVC_PORT       access-control-service 9096
+amap_set SVC_SBT        access-control-service AccessControlService
+amap_set SVC_LAUNCHER   access-control-service "target/access-control-service-${TEXERA_VERSION}/bin/access-control-service"
+amap_set SVC_CWD        access-control-service "."
+amap_set SVC_ZIP_GLOB   access-control-service "access-control-service/target/universal/access-control-service-*.zip"
+amap_set SVC_UNZIP_DEST access-control-service "target/"
+amap_set SVC_HEALTH     access-control-service "/api/healthcheck"
+
+amap_set SVC_TYPE       file-service jvm
+amap_set SVC_PORT       file-service 9092
+amap_set SVC_SBT        file-service FileService
+amap_set SVC_LAUNCHER   file-service "target/file-service-${TEXERA_VERSION}/bin/file-service"
+amap_set SVC_CWD        file-service "."
+amap_set SVC_ZIP_GLOB   file-service "file-service/target/universal/file-service-*.zip"
+amap_set SVC_UNZIP_DEST file-service "target/"
+amap_set SVC_HEALTH     file-service "/api/healthcheck"
+
+amap_set SVC_TYPE       workflow-compiling-service jvm
+amap_set SVC_PORT       workflow-compiling-service 9090
+amap_set SVC_SBT        workflow-compiling-service WorkflowCompilingService
+amap_set SVC_LAUNCHER   workflow-compiling-service "target/workflow-compiling-service-${TEXERA_VERSION}/bin/workflow-compiling-service"
+amap_set SVC_CWD        workflow-compiling-service "."
+amap_set SVC_ZIP_GLOB   workflow-compiling-service "workflow-compiling-service/target/universal/workflow-compiling-service-*.zip"
+amap_set SVC_UNZIP_DEST workflow-compiling-service "target/"
+amap_set SVC_HEALTH     workflow-compiling-service "/api/healthcheck"
+
+amap_set SVC_TYPE       computing-unit-managing-service jvm
+amap_set SVC_PORT       computing-unit-managing-service 8082
+amap_set SVC_SBT        computing-unit-managing-service ComputingUnitManagingService
+amap_set SVC_LAUNCHER   computing-unit-managing-service "target/computing-unit-managing-service-${TEXERA_VERSION}/bin/computing-unit-managing-service"
+amap_set SVC_CWD        computing-unit-managing-service "."
+amap_set SVC_ZIP_GLOB   computing-unit-managing-service "computing-unit-managing-service/target/universal/computing-unit-managing-service-*.zip"
+amap_set SVC_UNZIP_DEST computing-unit-managing-service "target/"
+amap_set SVC_HEALTH     computing-unit-managing-service ""
+
+amap_set SVC_TYPE       texera-web jvm
+amap_set SVC_PORT       texera-web 8080
+amap_set SVC_SBT        texera-web WorkflowExecutionService
+amap_set SVC_LAUNCHER   texera-web "target/amber-${TEXERA_VERSION}/bin/texera-web-application"
+amap_set SVC_CWD        texera-web "amber"
+amap_set SVC_ZIP_GLOB   texera-web "amber/target/universal/amber-*.zip"
+amap_set SVC_UNZIP_DEST texera-web "amber/target/"
+amap_set SVC_HEALTH     texera-web "/api/healthcheck"
+
+# computing-unit-master shares the amber dist with texera-web: sbt-native-
+# packager emits both `bin/texera-web-application` and `bin/computing-unit-master`
+# launchers under `amber/target/amber-<VERSION>/`. We register it as a separate
+# service for status/start/stop but leave SVC_SBT / SVC_ZIP_GLOB empty so the
+# build pipeline knows to skip it (the texera-web build path already produces
+# its launcher). Source-dir and canary-jar lookups treat it identically to
+# texera-web — see _svc_src_dirs / svc_src_changed / svc_artifact_mtime.
+amap_set SVC_TYPE       computing-unit-master jvm
+amap_set SVC_PORT       computing-unit-master 8085
+amap_set SVC_SBT        computing-unit-master ""
+amap_set SVC_LAUNCHER   computing-unit-master "target/amber-${TEXERA_VERSION}/bin/computing-unit-master"
+amap_set SVC_CWD        computing-unit-master "amber"
+amap_set SVC_ZIP_GLOB   computing-unit-master ""
+amap_set SVC_UNZIP_DEST computing-unit-master ""
+amap_set SVC_HEALTH     computing-unit-master ""
+
+amap_set SVC_TYPE   agent-service bun
+amap_set SVC_PORT   agent-service 3001
+amap_set SVC_CWD    agent-service "agent-service"
+amap_set SVC_HEALTH agent-service "/api/healthcheck"
+
+amap_set SVC_TYPE   frontend yarn
+amap_set SVC_PORT   frontend 4200
+amap_set SVC_CWD    frontend "frontend"
+amap_set SVC_HEALTH frontend ""
+
+# --------- docker infra config ---------
+DOCKER_PROJECT="texera-local-dev"
+DOCKER_COMPOSE_FILE="$REPO_ROOT/bin/single-node/docker-compose.yml"
+DOCKER_OVERLAY_FILE="$REPO_ROOT/bin/local-dev/docker-compose.override.yml"
+DOCKER_ENV_FILE="$REPO_ROOT/bin/single-node/.env"
+DOCKER_INFRA_SERVICES=(postgres minio minio-init lakefs lakekeeper-migrate lakekeeper lakekeeper-init litellm)
+DOCKER_INFRA_LONGLIVED=(postgres minio lakefs lakekeeper litellm)  # exclude one-shot init jobs
+
+# Build the array of -f flags: base single-node compose + local-dev overlay
+# (the overlay publishes infra ports to the host, which the upstream compose
+# intentionally does not do).
+docker_compose_files() {
+    local args=(-f "$DOCKER_COMPOSE_FILE")
+    [[ -f "$DOCKER_OVERLAY_FILE" ]] && args+=(-f "$DOCKER_OVERLAY_FILE")
+    printf '%s\n' "${args[@]}"
+}
+
+# --------- TUI helpers ---------
+if [[ -t 1 ]] && [[ "${TERM:-}" != "dumb" ]]; then
+    BOLD=$'\e[1m'; DIM=$'\e[2m'
+    RED=$'\e[31m'; GREEN=$'\e[32m'; YELLOW=$'\e[33m'
+    BLUE=$'\e[34m'; MAGENTA=$'\e[35m'; CYAN=$'\e[36m'
+    GRAY=$'\e[90m'; BRIGHT=$'\e[97m'
+    RESET=$'\e[0m'
+else
+    BOLD="" DIM="" RED="" GREEN="" YELLOW="" BLUE="" MAGENTA="" CYAN="" GRAY="" BRIGHT="" RESET=""
+fi
+
+TUI_WIDTH=$(tput cols 2>/dev/null || echo 80)
+[[ -z "$TUI_WIDTH" || "$TUI_WIDTH" -lt 60 ]] && TUI_WIDTH=80
+(( TUI_WIDTH > 100 )) && TUI_WIDTH=100   # cap for readability
+
+# Symbols
+SYM_RUN="●"; SYM_STOP="○"; SYM_WARN="⚠"; SYM_OK="✓"; SYM_ERR="✗"
+SYM_SECT="▸"; SYM_LIST="•"; SYM_PROG="→"
+
+tui_hline() {
+    local ch="${1:-─}" w="${2:-$TUI_WIDTH}"
+    printf "${ch}%.0s" $(seq 1 "$w")
+}
+
+tui_trunc() {
+    local s="$1" n="$2"
+    if (( ${#s} > n )); then
+        printf "%s…" "${s:0:$((n-1))}"
+    else
+        printf "%s" "$s"
+    fi
+}
+
+tui_banner() {
+    local title="$1" subtitle="${2:-}"
+    local w=$TUI_WIDTH
+    # Row layout: │ + 2 spaces + content + 2 spaces + │ = w  →  inner = w - 6
+    local inner=$((w - 6))
+    title="$(tui_trunc "$title" "$inner")"
+    subtitle="$(tui_trunc "$subtitle" "$inner")"
+    printf "${BLUE}╭"; tui_hline "─" $((w-2)); printf "╮${RESET}\n"
+    printf "${BLUE}│${RESET}  ${BOLD}${BRIGHT}%-*s${RESET}  ${BLUE}│${RESET}\n" "$inner" "$title"
+    if [[ -n "$subtitle" ]]; then
+        printf "${BLUE}│${RESET}  ${DIM}%-*s${RESET}  ${BLUE}│${RESET}\n" "$inner" "$subtitle"
+    fi
+    printf "${BLUE}╰"; tui_hline "─" $((w-2)); printf "╯${RESET}\n"
+}
+
+tui_section() {
+    printf "\n${BOLD}${MAGENTA}${SYM_SECT}${RESET} ${BOLD}%s${RESET}\n" "$1"
+}
+
+tui_ok()    { printf "  ${GREEN}${SYM_OK}${RESET}  %s\n" "$*"; }
+tui_err()   { printf "  ${RED}${SYM_ERR}${RESET}  %s\n" "$*"; }
+tui_warn()  { printf "  ${YELLOW}${SYM_WARN}${RESET}  %s\n" "$*"; }
+tui_info()  { printf "  ${CYAN}${SYM_LIST}${RESET}  %s\n" "$*"; }
+tui_step()  { printf "  ${DIM}${SYM_PROG}${RESET}  ${DIM}%s${RESET}\n" "$*"; }
+tui_skip()  { printf "  ${GRAY}${SYM_STOP}${RESET}  ${GRAY}%s${RESET}\n" "$*"; }
+
+tui_state_symbol() {
+    case "$1" in
+        running)              printf "${GREEN}${SYM_RUN}${RESET}" ;;
+        starting)             printf "${YELLOW}${SYM_WARN}${RESET}" ;;
+        unhealthy|failed)     printf "${RED}${SYM_ERR}${RESET}" ;;
+        partial:*|external:*) printf "${YELLOW}${SYM_WARN}${RESET}" ;;
+        exited)               printf "${GRAY}${SYM_OK}${RESET}" ;;
+        *)                    printf "${GRAY}${SYM_STOP}${RESET}" ;;
+    esac
+}
+
+tui_state_color() {
+    case "$1" in
+        running)              echo "$GREEN" ;;
+        starting)             echo "$YELLOW" ;;
+        unhealthy|failed)     echo "$RED" ;;
+        partial:*|external:*) echo "$YELLOW" ;;
+        exited)               echo "$GRAY" ;;
+        *)                    echo "$GRAY" ;;
+    esac
+}
+
+# Show a spinner next to $msg while $pid runs. Caller is responsible for
+# `wait $pid` afterwards to capture exit code.
+tui_spinner() {
+    local pid="$1" msg="$2"
+    if [[ ! -t 1 ]]; then
+        printf "  ${BLUE}${SYM_PROG}${RESET}  ${DIM}%s (no-TTY, no spinner)${RESET}\n" "$msg"
+        return
+    fi
+    # Use an array (vs a single multibyte string + byte indexing) because
+    # bash's `${str:i:1}` is byte-wise — each braille glyph is 3 UTF-8
+    # bytes, so a byte index would print broken half-chars.
+    local frames=(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏)
+    local n=${#frames[@]}
+    local i=0
+    local start_ts=$SECONDS
+    printf "\e[?25l"   # hide cursor
+    while kill -0 "$pid" 2>/dev/null; do
+        local elapsed=$((SECONDS - start_ts))
+        local frame="${frames[i % n]}"
+        printf "\r  ${BLUE}%s${RESET}  ${DIM}%s${RESET} ${GRAY}(%ds)${RESET}     " \
+            "$frame" "$msg" "$elapsed"
+        sleep 0.1
+        i=$((i+1))
+    done
+    printf "\r%-${TUI_WIDTH}s\r" " "
+    printf "\e[?25h"   # show cursor
+}
+
+# Run a command in the background with output captured to $log, show spinner,
+# return command's exit code.
+tui_run_with_spinner() {
+    local log="$1" msg="$2"
+    shift 2
+    "$@" >"$log" 2>&1 &
+    local pid=$!
+    tui_spinner "$pid" "$msg"
+    wait "$pid"
+}
+
+# In-place panel that polls each non-skipped service's port and redraws the
+# whole panel until all are healthy or timed out. Sets a trap so Ctrl-C
+# restores the cursor.
+tui_wait_panel() {
+    local svcs=()
+    for svc in "${SERVICES[@]}"; do
+        is_skipped "$svc" && continue
+        svcs+=("$svc")
+    done
+    local n=${#svcs[@]}
+    (( n == 0 )) && return 0
+
+    local frames=(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏)
+    local n_frames=${#frames[@]}
+    local start_ts=$SECONDS
+    # JVM + docker services bind their port within ~10-30s; 90s leaves slack
+    # for cold-cache machines. The frontend's `ng serve` does a full Angular
+    # compile before listening — on a fresh checkout that's 90-180s easily,
+    # so give yarn-typed services 5 minutes. bun is in between.
+    local timeout_default=90
+    local timeout_yarn=300
+    local timeout_bun=120
+    local frame_idx=0
+    local first_render=true
+    local n_done=0
+    local n_failed=0
+    local svc="" i=0 state="" state_color="" state_sym="" port_str="" elapsed=0 spinner_frame=""
+
+    if [[ ! -t 1 ]]; then
+        # Non-TTY: redrawing would just spam lines. Poll each service to
+        # completion (or per-service timeout) and print one line per
+        # transition. Crucially we WAIT for docker containers to leave
+        # `starting`/`unhealthy` rather than treating an in-flight state as
+        # a failure — the previous one-shot check exited rc=1 within ~20s
+        # of `up` if e.g. lakefs hadn't reached `running` yet, which is its
+        # normal cold-start state.
+        local n_done=0 n_failed=0
+        local svc_timeout=0 waited=0 final_state=""
+        for svc in "${svcs[@]}"; do
+            svc_timeout=$timeout_default
+            case "$(amap_get SVC_TYPE "$svc")" in
+                yarn) svc_timeout=$timeout_yarn ;;
+                bun)  svc_timeout=$timeout_bun  ;;
+            esac
+            waited=0
+            final_state=""
+            while (( waited < svc_timeout )); do
+                if [[ "$(amap_get SVC_TYPE "$svc")" == "docker" ]]; then
+                    case "$(docker_svc_state "$svc")" in
+                        running|exited)        final_state="ok";    break ;;
+                        unhealthy|failed)      final_state="bad";   break ;;
+                        # starting / created / restarting / paused / "" → keep waiting
+                    esac
+                else
+                    [[ -n "$(listen_pid_for_port "$(amap_get SVC_PORT "$svc")")" ]] && { final_state="ok"; break; }
+                fi
+                sleep 1
+                waited=$((waited+1))
+            done
+            case "$final_state" in
+                ok)
+                    printf "  %s  %-32s :%-6s  %s\n" "$SYM_OK" "$svc" "$(amap_get SVC_PORT "$svc")" "healthy"
+                    n_done=$((n_done+1)) ;;
+                bad)
+                    printf "  %s  %-32s :%-6s  %s\n" "$SYM_ERR" "$svc" "$(amap_get SVC_PORT "$svc")" "unhealthy"
+                    n_failed=$((n_failed+1)) ;;
+                *)  # timed out without ever reaching a terminal state
+                    printf "  %s  %-32s :%-6s  %s\n" "$SYM_ERR" "$svc" "$(amap_get SVC_PORT "$svc")" "timeout (${waited}s)"
+                    n_failed=$((n_failed+1)) ;;
+            esac
+        done
+        return $((n_failed > 0))
+    fi
+
+    printf "\e[?25l"   # hide cursor
+    trap 'printf "\e[?25h"' EXIT INT TERM
+
+    while true; do
+        elapsed=$((SECONDS - start_ts))
+        n_done=0
+        n_failed=0
+
+        if ! $first_render && [[ -t 1 ]]; then
+            printf "\e[${n}A"   # move cursor to top of panel
+        fi
+        first_render=false
+
+        spinner_frame="${frames[frame_idx % n_frames]}"
+
+        for svc in "${svcs[@]}"; do
+            state_color="" state_sym=""
+            # Per-service wait budget — yarn (ng serve cold compile) gets the
+            # most slack, bun a moderate amount, everything else the default.
+            local svc_timeout=$timeout_default
+            case "$(amap_get SVC_TYPE "$svc")" in
+                yarn) svc_timeout=$timeout_yarn ;;
+                bun)  svc_timeout=$timeout_bun  ;;
+            esac
+            if [[ "$(amap_get SVC_TYPE "$svc")" == "docker" ]]; then
+                local dstate=""
+                dstate=$(docker_svc_state "$svc")
+                case "$dstate" in
+                    running|exited)
+                        state="$dstate"
+                        state_color="$GREEN"; state_sym="${GREEN}${SYM_OK}${RESET}"
+                        n_done=$((n_done+1)) ;;
+                    starting)
+                        state="starting (${elapsed}s)"
+                        state_color="$YELLOW"; state_sym="${YELLOW}${spinner_frame}${RESET}" ;;
+                    unhealthy|failed)
+                        state="$dstate"
+                        state_color="$RED"; state_sym="${RED}${SYM_ERR}${RESET}"
+                        n_failed=$((n_failed+1)) ;;
+                    *)
+                        if (( elapsed >= svc_timeout )); then
+                            state="timeout"
+                            state_color="$RED"; state_sym="${RED}${SYM_ERR}${RESET}"
+                            n_failed=$((n_failed+1))
+                        else
+                            state="${dstate} (${elapsed}s)"
+                            state_color="$YELLOW"; state_sym="${YELLOW}${spinner_frame}${RESET}"
+                        fi ;;
+                esac
+                port_str=":$(amap_get SVC_PORT "$svc")"
+            else
+                if [[ -n "$(listen_pid_for_port "$(amap_get SVC_PORT "$svc")")" ]]; then
+                    state="healthy"
+                    state_color="$GREEN"; state_sym="${GREEN}${SYM_OK}${RESET}"
+                    n_done=$((n_done+1))
+                elif (( elapsed >= svc_timeout )); then
+                    state="timeout — see bin/local-dev.sh logs $svc"
+                    state_color="$RED"; state_sym="${RED}${SYM_ERR}${RESET}"
+                    n_failed=$((n_failed+1))
+                else
+                    state="starting (${elapsed}s)"
+                    state_color="$YELLOW"; state_sym="${YELLOW}${spinner_frame}${RESET}"
+                fi
+                port_str=":$(amap_get SVC_PORT "$svc")"
+            fi
+
+            [[ -t 1 ]] && printf "\e[2K"
+            printf "  %s  %-32s ${DIM}%-7s${RESET}  ${state_color}%s${RESET}\n" \
+                "$state_sym" "$svc" "$port_str" "$state"
+        done
+
+        if (( n_done + n_failed == n )); then
+            break
+        fi
+
+        frame_idx=$((frame_idx + 1))
+        sleep 0.2
+    done
+
+    if [[ -t 1 ]]; then
+        printf "\e[?25h"
+        trap - EXIT INT TERM
+    fi
+
+    return $((n_failed > 0))
+}
+
+# --------- toolchain install hints ---------
+# Print install instructions for a missing toolchain. Used by both startup
+# detection failures and runtime "command not found" surfaces. Keeps the
+# guidance in one place so every failure mode looks the same.
+_install_hint() {
+    local tool="$1"
+    case "$tool" in
+        java)
+            printf "  ${BOLD}install JDK 17:${RESET}\n"
+            printf "    macOS:   brew install openjdk@17\n"
+            printf "    Linux:   apt install openjdk-17-jdk    ${DIM}# or yum/dnf equivalent${RESET}\n"
+            printf "    SDKMAN:  sdk install java 17.0.13-tem\n"
+            printf "  or set JAVA_HOME=/path/to/jdk-17 explicitly\n"
+            ;;
+        python)
+            printf "  ${BOLD}install Python 3.10+ and the TUI deps:${RESET}\n"
+            printf "    macOS:   brew install python@3.12\n"
+            printf "    Linux:   apt install python3 python3-pip\n"
+            printf "    then:    python3 -m pip install -r %s/amber/dev-requirements.txt\n" "$REPO_ROOT"
+            printf "  or set TEXERA_PYTHON=/path/to/python explicitly (must have textual installed)\n"
+            ;;
+        node)
+            printf "  ${BOLD}install Node 20+ (needed for frontend & agent-service):${RESET}\n"
+            printf "    macOS:   brew install node\n"
+            printf "    nvm:     nvm install --lts && nvm use --lts\n"
+            printf "    fnm:     fnm install --lts\n"
+            printf "    volta:   volta install node\n"
+            ;;
+        yarn)
+            printf "  ${BOLD}install yarn (needed for the frontend):${RESET}\n"
+            printf "    macOS:   brew install yarn\n"
+            printf "    npm:     npm install -g yarn\n"
+            printf "    corepack: corepack enable\n"
+            ;;
+        bun)
+            printf "  ${BOLD}install bun (needed for agent-service):${RESET}\n"
+            printf "    macOS:   brew install oven-sh/bun/bun\n"
+            printf "    curl:    curl -fsSL https://bun.sh/install | bash\n"
+            ;;
+        sbt)
+            printf "  ${BOLD}install sbt (needed to build the JVM services):${RESET}\n"
+            printf "    macOS:   brew install sbt\n"
+            printf "    Linux:   see https://www.scala-sbt.org/download.html\n"
+            ;;
+        docker)
+            printf "  ${BOLD}install Docker (needed for postgres/minio/lakefs/lakekeeper/litellm):${RESET}\n"
+            printf "    macOS:   download Docker Desktop from https://docker.com/products/docker-desktop\n"
+            printf "    Linux:   apt install docker.io docker-compose-plugin\n"
+            ;;
+        *)
+            printf "  ${DIM}no install hint for: %s${RESET}\n" "$tool"
+            ;;
+    esac
+}
+
+# Inspect the user's Node/yarn/bun setup and print what we actually find,
+# so "yarn not found" reads as "you have node 18 but no yarn" instead of
+# the generic install hint. Called from start_one's frontend/agent paths.
+_diagnose_node() {
+    printf "\n  ${BOLD}node environment:${RESET}\n"
+    for tool in node npm yarn bun corepack; do
+        local p=""
+        p=$(command -v "$tool" 2>/dev/null) || true
+        if [[ -n "$p" ]]; then
+            local v=""
+            v=$("$tool" --version 2>&1 | head -1) || v="(no --version)"
+            printf "    ${GREEN}✓${RESET} %-8s → %s  ${DIM}(%s)${RESET}\n" "$tool" "$p" "$v"
+        else
+            printf "    ${RED}✗${RESET} %-8s ${DIM}not on PATH${RESET}\n" "$tool"
+        fi
+    done
+    printf "\n  ${BOLD}version manager:${RESET}\n"
+    if [[ -n "${NVM_DIR:-}" && -s "${NVM_DIR}/nvm.sh" ]]; then
+        printf "    nvm  → ${DIM}%s${RESET}\n" "$NVM_DIR"
+    elif command -v fnm >/dev/null 2>&1; then
+        printf "    fnm  → ${DIM}%s${RESET}\n" "$(command -v fnm)"
+    elif [[ -s "$HOME/.volta/load.sh" ]]; then
+        printf "    volta → ${DIM}%s${RESET}\n" "$HOME/.volta"
+    else
+        printf "    ${DIM}none detected (using whatever \`node\` is on PATH)${RESET}\n"
+    fi
+    printf "\n"
+}
+
+# --------- helpers ---------
+listen_pid_for_port() {
+    # || true so pipefail doesn't kill us when nothing is listening
+    lsof -nP -iTCP:"$1" -sTCP:LISTEN -t 2>/dev/null | head -1 || true
+}
+
+# Returns the count of long-lived infra services currently running under our project.
+infra_running_count() {
+    docker compose -p "$DOCKER_PROJECT" ps --services --filter status=running 2>/dev/null | grep -cxE "$(IFS=\|; echo "${DOCKER_INFRA_LONGLIVED[*]}")" || true
+}
+
+# Returns one of: stopped | running | partial:N | external — aggregate view
+# across all infra containers, used by infra_up/infra_down and the wait panel.
+infra_state() {
+    local running=""
+    running=$(infra_running_count)
+    if [[ "$running" -eq ${#DOCKER_INFRA_LONGLIVED[@]} ]]; then
+        echo "running"
+    elif [[ "$running" -gt 0 ]]; then
+        echo "partial:$running"
+    else
+        local taken=0
+        for port in 5432 9000 8000 8181 4000; do
+            [[ -n "$(listen_pid_for_port "$port")" ]] && taken=$((taken+1))
+        done
+        if [[ "$taken" -gt 0 ]]; then
+            echo "external:$taken/5"
+        else
+            echo "stopped"
+        fi
+    fi
+}
+
+# Cached snapshot of `docker compose ps -a`. Refreshed at most once per second
+# (5 docker services × per-row ~200 ms `docker ps` call would otherwise tank
+# the render).
+_docker_states_cache=""
+_docker_states_cache_ts=-1
+
+_refresh_docker_states_cache() {
+    if (( _docker_states_cache_ts < 0 || SECONDS - _docker_states_cache_ts >= 1 )); then
+        _docker_states_cache=$(docker compose -p "$DOCKER_PROJECT" ps -a \
+            --format '{{.Service}}|{{.State}}|{{.Status}}' 2>/dev/null)
+        _docker_states_cache_ts=$SECONDS
+    fi
+}
+
+# Per-service state for any of postgres/minio/lakefs/lakekeeper/litellm.
+# Returns one of: running | starting | unhealthy | exited | failed | stopped
+docker_svc_state() {
+    local svc="$1"
+    _refresh_docker_states_cache
+    local line=""
+    line=$(printf '%s\n' "$_docker_states_cache" | grep "^${svc}|" | head -1 || true)
+    if [[ -z "$line" ]]; then
+        echo "stopped"
+        return
+    fi
+    local rest="${line#*|}"
+    local dstate="${rest%%|*}"     # NB: not `status` — zsh reserves $status
+    local dstatus="${rest#*|}"
+    case "$dstate" in
+        running)
+            if [[ "$dstatus" == *'(healthy)'* ]]; then echo "running"
+            elif [[ "$dstatus" == *'(health: starting)'* ]]; then echo "starting"
+            elif [[ "$dstatus" == *'(unhealthy)'* ]]; then echo "unhealthy"
+            else echo "running"
+            fi ;;
+        exited)
+            if [[ "$dstatus" == 'Exited (0)'* ]]; then echo "exited"
+            else echo "failed"
+            fi ;;
+        created|restarting|paused|removing) echo "starting" ;;
+        *) echo "stopped" ;;
+    esac
+}
+
+infra_up() {
+    # Resolve the host LAN IP now (lazy) — both the docker compose stack
+    # (lakekeeper-init reads STORAGE_S3_ENDPOINT) and the host JVMs about
+    # to start need it pointing at a host-reachable MinIO.
+    _require_host_lan_ip
+    if [[ "$(infra_state)" == external:* ]]; then
+        tui_err "infra: ports already taken by non-script containers"
+        printf "  ${DIM}Likely an old project (e.g. \`texera-dev\`) is running. Stop it first:${RESET}\n"
+        printf "  ${DIM}  docker compose -p texera-dev down${RESET}\n"
+        return 1
+    fi
+    local files=($(docker_compose_files))
+    tui_step "infra: docker compose up -d  ${DIM}(in-place TTY progress)${RESET}"
+    # No stdout redirect → docker compose detects TTY and renders an in-place
+    # progress panel that overwrites itself instead of appending event lines.
+    # --progress=tty forces it even if stdout looks like a pipe.
+    docker compose --progress auto -p "$DOCKER_PROJECT" --env-file "$DOCKER_ENV_FILE" "${files[@]}" \
+        up -d "${DOCKER_INFRA_SERVICES[@]}"
+    tui_ok "infra: 5 containers up"
+}
+
+infra_down() {
+    tui_step "infra: docker compose -p $DOCKER_PROJECT down  ${DIM}(in-place TTY progress)${RESET}"
+    docker compose --progress auto -p "$DOCKER_PROJECT" down || true
+    tui_ok "infra: stopped"
+}
+
+# Ensure the texera_db schema exists in the postgres container. The compose
+# file mounts sql/*.sql to /docker-entrypoint-initdb.d, but Postgres only
+# runs those on first init (empty data dir). If the volume was carried over
+# from an older texera version (e.g. before the `feedback` table was added)
+# the schema will be missing relations that current code references, the
+# jOOQ codegen produces an incomplete Tables.java, and sbt compile fails on
+# `not found: value FEEDBACK`. Probe for a canonical table and re-run
+# texera_ddl.sql if it's absent.
+infra_ensure_db_schema() {
+    local pg="texera-postgres"
+    # Wait briefly for postgres to be ready — `up -d` returned but the
+    # container may still be running its own init sequence.
+    local i=0
+    while (( i < 30 )); do
+        if docker exec "$pg" pg_isready -U texera -d texera_db -q 2>/dev/null; then
+            break
+        fi
+        sleep 1
+        i=$((i+1))
+    done
+    if (( i >= 30 )); then
+        tui_warn "postgres: not ready after 30s -- skipping schema check"
+        return 0
+    fi
+    # `feedback` is one of the newer tables; if it exists we assume the
+    # whole schema is current. (texera_ddl.sql is idempotent with
+    # CREATE TABLE IF NOT EXISTS, so re-applying it is safe even if some
+    # tables already exist, but skipping the copy + exec is faster.)
+    local has_feedback=""
+    has_feedback=$(docker exec "$pg" psql -U texera -d texera_db -tAc \
+        "SELECT 1 FROM pg_tables WHERE schemaname='texera_db' AND tablename='feedback'" \
+        2>/dev/null || true)
+    if [[ "$has_feedback" == "1" ]]; then
+        tui_skip "postgres: schema already current"
+        return 0
+    fi
+    tui_step "postgres: applying sql/texera_ddl.sql (one-time bootstrap)"
+    local ddl="$REPO_ROOT/sql/texera_ddl.sql"
+    if [[ ! -f "$ddl" ]]; then
+        tui_warn "postgres: $ddl not found -- skipping (jOOQ codegen may fail)"
+        return 0
+    fi
+    docker cp "$ddl" "$pg":/tmp/texera_ddl.sql >/dev/null
+    if docker exec -u postgres "$pg" psql -U texera -f /tmp/texera_ddl.sql >/dev/null 2>&1; then
+        tui_ok "postgres: schema bootstrapped"
+    else
+        tui_warn "postgres: ddl exec returned non-zero (check container logs)"
+    fi
+}
+
+svc_running_pid() {
+    listen_pid_for_port "$(amap_get SVC_PORT "$1")"
+}
+
+# Compact-format a duration in seconds:
+#   "—" for <0, "12s", "5m 23s", "2h 14m", "3d 4h"
+_format_uptime() {
+    local s="${1:-0}"
+    if (( s < 0 ));     then printf "—"; return; fi
+    if (( s < 60 ));    then printf "%ds" "$s"; return; fi
+    if (( s < 3600 ));  then printf "%dm %ds" $((s/60)) $((s%60)); return; fi
+    if (( s < 86400 )); then printf "%dh %dm" $((s/3600)) $(((s%3600)/60)); return; fi
+    printf "%dd %dh" $((s/86400)) $(((s%86400)/3600))
+}
+
+# Translate ps -o etime output (`[[DD-]hh:]mm:ss`) to whole seconds.
+_etime_to_seconds() {
+    local et="$1"
+    # bash quirk: in `local a=$x b=$a`, the right-hand `$a` resolves
+    # against the *outer* scope (before this `local` declares either).
+    # Putting `rest` on its own line ensures it sees the fresh `et`.
+    local rest="$et" days=0 h=0 m=0 s=0
+    if [[ "$rest" == *-* ]]; then
+        days="${rest%%-*}"
+        rest="${rest#*-}"
+    fi
+    # `IFS=: read -ra` reliably splits on `:` without the local-IFS-vs-
+    # array-literal trap.
+    local parts=()
+    IFS=: read -ra parts <<< "$rest"
+    case "${#parts[@]}" in
+        3) h=${parts[0]}; m=${parts[1]}; s=${parts[2]} ;;
+        2) m=${parts[0]}; s=${parts[1]} ;;
+        *) s=${parts[0]} ;;
+    esac
+    # Force base-10 so "08" / "09" don't blow up under bash's octal default.
+    printf '%d' "$((10#${days:-0}*86400 + 10#${h:-0}*3600 + 10#${m:-0}*60 + 10#${s:-0}))"
+}
+
+# Translate a number of bytes to a 4-char-ish human string ("85M", "1.2G").
+_format_bytes() {
+    local b="${1:-0}"
+    if (( b < 1024 ));         then printf "%dB"   "$b"; return; fi
+    if (( b < 1048576 ));      then printf "%dK"   $((b/1024)); return; fi
+    if (( b < 1073741824 ));   then printf "%dM"   $((b/1048576)); return; fi
+    # GB → 1 decimal place
+    local g_int=$((b / 1073741824)) g_frac=$(( (b % 1073741824) * 10 / 1073741824 ))
+    printf "%d.%dG" "$g_int" "$g_frac"
+}
+
+# Compact "8.2%" formatter from a string like "0.50" or "8.2".
+_format_pct() {
+    local p="${1:-0}"
+    # Strip optional trailing %, normalise weird inputs
+    p="${p%%%}"
+    [[ -z "$p" || "$p" == "-" ]] && { printf "—"; return; }
+    # Round to 1 decimal place using awk to avoid bash's lack of float math
+    printf '%s%%' "$(awk -v v="$p" 'BEGIN { printf "%.1f", v }' 2>/dev/null)"
+}
+
+# Container name for a docker-typed service. Most use `texera-<svc>`, but
+# `litellm` ships unprefixed in upstream's compose.
+_docker_container_for() {
+    case "$1" in
+        litellm) printf "litellm" ;;
+        *)       printf "texera-%s" "$1" ;;
+    esac
+}
+
+# Uptime for a service. Native: `ps -o etime` (POSIX, works on mac+linux).
+# Docker: parse the container's ISO-8601 .State.StartedAt against `date -u`.
+# Echoes "—" when not running.
+svc_uptime() {
+    local svc="$1"
+    if [[ "$(amap_get SVC_TYPE "$svc")" == "docker" ]]; then
+        local container="" since=""
+        container=$(_docker_container_for "$svc")
+        since=$(docker inspect -f '{{.State.StartedAt}}' "$container" 2>/dev/null) || { printf "—"; return; }
+        [[ -z "$since" || "$since" == "0001-01-01T00:00:00Z" ]] && { printf "—"; return; }
+        local now=0 started=0
+        now=$(date -u +%s 2>/dev/null) || { printf "—"; return; }
+        # macOS `date -j` parses ISO without fractional seconds — trim them.
+        started=$(date -u -j -f "%Y-%m-%dT%H:%M:%S" "${since%%.*}" +%s 2>/dev/null) || {
+            # GNU date fallback
+            started=$(date -u -d "${since%%.*}" +%s 2>/dev/null) || { printf "—"; return; }
+        }
+        local elapsed=$((now - started))
+        (( elapsed < 0 )) && elapsed=0
+        _format_uptime "$elapsed"
+        return
+    fi
+    local pid=""
+    pid=$(svc_running_pid "$svc") || true
+    [[ -z "$pid" ]] && { printf "—"; return; }
+    local et=""
+    et=$(ps -p "$pid" -o etime= 2>/dev/null | tr -d ' ')
+    [[ -z "$et" ]] && { printf "—"; return; }
+    _format_uptime "$(_etime_to_seconds "$et")"
+}
+
+# CPU% + RSS for a running native process. Returns "cpu%|rss_bytes".
+# Cheap enough (<5ms) that we can call it per service per tick.
+svc_proc_cpu_mem() {
+    local pid=""
+    pid=$(svc_running_pid "$1") || true
+    [[ -z "$pid" ]] && { printf "—|—"; return; }
+    local cpu="" rss=""
+    # macOS ps emits RSS in KB. Linux's `ps -o rss=` is also KB.
+    read -r cpu rss <<< "$(ps -p "$pid" -o pcpu=,rss= 2>/dev/null | tr -s ' ')"
+    [[ -z "$cpu" || -z "$rss" ]] && { printf "—|—"; return; }
+    printf "%s|%s" "$(_format_pct "$cpu")" "$(_format_bytes $((rss * 1024)))"
+}
+
+# Bulk docker stats — one slow `docker stats --no-stream` call returns CPU
+# + memory for every running container. Callers can grep their service out
+# of the result instead of paying the 1-2 second cost N times. Format:
+#   <container>|<cpu%>|<used-bytes>
+_docker_stats_snapshot() {
+    docker stats --no-stream --format '{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}' 2>/dev/null \
+        | while IFS='|' read -r name cpu memu; do
+            # MemUsage is like "85.2MiB / 1.95GiB" — keep only the first
+            # half and translate IEC units to bytes.
+            local used="${memu%% *}"   # "85.2MiB"
+            local val="${used%[A-Za-z]*}"
+            local unit="${used##*[0-9.]}"
+            local bytes=0
+            case "$unit" in
+                KB|KiB) bytes=$(awk -v v="$val" 'BEGIN { printf "%d", v * 1024 }') ;;
+                MB|MiB) bytes=$(awk -v v="$val" 'BEGIN { printf "%d", v * 1048576 }') ;;
+                GB|GiB) bytes=$(awk -v v="$val" 'BEGIN { printf "%d", v * 1073741824 }') ;;
+                *)      bytes=$(awk -v v="$val" 'BEGIN { printf "%d", v }') ;;
+            esac
+            printf "%s|%s|%s\n" "$name" "$(_format_pct "${cpu%%%}")" "$(_format_bytes "$bytes")"
+        done
+}
+
+svc_artifact_mtime() {
+    local svc="$1" type=""
+    type=$(amap_get SVC_TYPE "$svc")
+    case "$type" in
+        jvm)
+            local launcher="$(amap_get SVC_CWD "$svc")/$(amap_get SVC_LAUNCHER "$svc")"
+            launcher="${launcher#./}"
+            local jar_dir=""
+            jar_dir="$(dirname "$(dirname "$launcher")")/lib"
+            if [[ -d "$jar_dir" ]]; then
+                # `shopt -s nullglob` localised in a subshell so an
+                # unmatched glob yields no words instead of the literal
+                # pattern. Capture into a string then split into an array.
+                local globbed=""
+                globbed=$(shopt -s nullglob; \
+                    printf '%s\n' "$jar_dir"/org.apache.texera."${svc}"-*.jar)
+                local main_jars=()
+                while IFS= read -r _f; do
+                    [[ -n "$_f" ]] && main_jars+=("$_f")
+                done <<< "$globbed"
+                if [[ ${#main_jars[@]} -eq 0 && ( "$svc" == "texera-web" || "$svc" == "computing-unit-master" ) ]]; then
+                    globbed=$(shopt -s nullglob; \
+                        printf '%s\n' "$jar_dir"/org.apache.texera.amber-*.jar)
+                    while IFS= read -r _f; do
+                        [[ -n "$_f" ]] && main_jars+=("$_f")
+                    done <<< "$globbed"
+                fi
+                if [[ ${#main_jars[@]} -gt 0 ]]; then
+                    stat -f "%Sm" -t "%Y-%m-%d %H:%M" "${main_jars[0]}"
+                    return
+                fi
+            fi
+            echo "—"
+            ;;
+        bun)    stat -f "%Sm" -t "%Y-%m-%d %H:%M" "$(amap_get SVC_CWD "$svc")/bun.lock" 2>/dev/null || echo "—" ;;
+        yarn)   stat -f "%Sm" -t "%Y-%m-%d %H:%M" "$(amap_get SVC_CWD "$svc")/yarn.lock" 2>/dev/null || echo "—" ;;
+        docker) echo "—" ;;
+    esac
+}
+
+is_skipped() {
+    [[ ",${SKIP_LIST:-}," == *",$1,"* ]]
+}
+
+wait_for_port() {
+    local port="$1" timeout="${2:-90}" i=0
+    while (( i < timeout )); do
+        [[ -n "$(listen_pid_for_port "$port")" ]] && return 0
+        sleep 1
+        i=$((i+1))
+    done
+    return 1
+}
+
+# Write a transitional phase for $svc so the TUI dashboard can render it
+# in the STATE column with animated dots. Cleared by phase_clear or after
+# 90s (TUI side stale check).
+phase_set() {
+    local svc="$1" phase="$2"
+    printf '%s\t%s\n' "$phase" "$(date +%s)" > "$PHASE_DIR/$svc" 2>/dev/null || true
+}
+phase_clear() {
+    local svc="$1"
+    rm -f "$PHASE_DIR/$svc" 2>/dev/null || true
+}
+
+stop_one() {
+    local svc="$1"
+    if [[ "$(amap_get SVC_TYPE "$svc")" == "docker" ]]; then
+        phase_set "$svc" stopping
+        tui_step "$svc: docker compose stop $svc"
+        docker compose -p "$DOCKER_PROJECT" stop "$svc" >/dev/null 2>&1 || true
+        phase_clear "$svc"
+        tui_ok "$svc: stopped"
+        return
+    fi
+    local pid=""
+    pid=$(svc_running_pid "$svc")
+    if [[ -z "$pid" ]]; then
+        tui_skip "$svc: already stopped"
+        return 0
+    fi
+    phase_set "$svc" stopping
+    tui_step "$svc: stopping PID $pid"
+    kill "$pid" 2>/dev/null || true
+    local i=0
+    while (( i < 30 )) && kill -0 "$pid" 2>/dev/null; do
+        sleep 0.5
+        i=$((i+1))
+    done
+    if kill -0 "$pid" 2>/dev/null; then
+        tui_warn "$svc: SIGKILL $pid"
+        kill -9 "$pid" 2>/dev/null || true
+    fi
+    phase_clear "$svc"
+    tui_ok "$svc: stopped"
+}
+
+start_one() {
+    local svc="$1"
+    local type=""
+    type=$(amap_get SVC_TYPE "$svc")
+    # JVMs and docker services need STORAGE_S3_ENDPOINT exported before
+    # launch (JVM clients dial MinIO; lakekeeper bakes the URL into the
+    # warehouse storage profile). yarn/bun watch services don't.
+    if [[ "$type" == "jvm" || "$type" == "docker" ]]; then
+        _require_host_lan_ip
+    fi
+    if [[ "$type" == "docker" ]]; then
+        local dstate=""
+        dstate=$(docker_svc_state "$svc")
+        if [[ "$dstate" == "running" ]]; then
+            tui_ok "$svc: already running"
+            return 0
+        fi
+        phase_set "$svc" starting
+        tui_step "$svc: docker compose up -d $svc"
+        local files=()
+        while read -r f; do files+=("$f"); done < <(docker_compose_files | tr ' ' '\n')
+        docker compose --progress auto -p "$DOCKER_PROJECT" --env-file "$DOCKER_ENV_FILE" \
+            "${files[@]}" up -d "$svc" >/dev/null 2>&1
+        # Don't clear the phase yet — docker `up -d` returns before the
+        # container is healthy. The TUI's docker_state poller will flip
+        # the row to "running" once the container reports healthy; the
+        # stale-after-90s rule covers cleanup if that never happens.
+        tui_ok "$svc: started"
+        return
+    fi
+    if [[ -n "$(svc_running_pid "$svc")" ]]; then
+        tui_ok "$svc: already running ${DIM}(PID $(svc_running_pid "$svc"))${RESET}"
+        return 0
+    fi
+    local cwd="" log="$LOG_DIR/$svc.log"
+    cwd=$(amap_get SVC_CWD "$svc")
+    phase_set "$svc" starting
+    tui_step "$svc: starting ${DIM}(log: $log)${RESET}"
+    case "$type" in
+        jvm)
+            local launcher=""
+            launcher=$(amap_get SVC_LAUNCHER "$svc")
+            if [[ ! -x "$cwd/$launcher" ]]; then
+                phase_clear "$svc"
+                tui_err "$svc: launcher missing at $cwd/$launcher -- run \`bin/local-dev.sh up\` to build first"
+                return 1
+            fi
+            ( cd "$cwd" && nohup "./$launcher" >"$log" 2>&1 </dev/null & )
+            ;;
+        bun)
+            if ! command -v bun >/dev/null 2>&1; then
+                phase_clear "$svc"
+                tui_err "$svc: \`bun\` not found on PATH"
+                _diagnose_node
+                _install_hint bun
+                return 1
+            fi
+            ( cd "$cwd" && nohup bun run dev >"$log" 2>&1 </dev/null & )
+            ;;
+        yarn)
+            if ! command -v yarn >/dev/null 2>&1; then
+                phase_clear "$svc"
+                tui_err "$svc: \`yarn\` not found on PATH"
+                _diagnose_node
+                if ! command -v node >/dev/null 2>&1; then
+                    _install_hint node
+                else
+                    _install_hint yarn
+                fi
+                return 1
+            fi
+            ( cd "$cwd" && nohup yarn start >"$log" 2>&1 </dev/null & )
+            ;;
+    esac
+}
+
+build_one_jvm() {
+    local svc="$1" proj=""
+    proj=$(amap_get SVC_SBT "$svc")
+    local log="$LOG_DIR/sbt-${svc}.log"
+    # Empty SVC_SBT means this service rides another service's dist (e.g.
+    # computing-unit-master shares amber's). Nothing to build directly — the
+    # launcher is produced when its sibling builds. Stamp `svc` so the dirty
+    # indicator can clear if amber/src actually matches.
+    if [[ -z "$proj" ]]; then
+        tui_skip "$svc: no own sbt project (built with its sibling)"
+        svc_source_hash "$svc" > "$BUILD_STAMP_DIR/$svc" 2>/dev/null || true
+        return 0
+    fi
+    phase_set "$svc" building
+    if tui_run_with_spinner "$log" "sbt $proj/dist  ${DIM}(log: $log)${RESET}" \
+        sbt -no-colors "$proj/dist"; then
+        local zip_glob="" unzip_dest=""
+        zip_glob=$(amap_get SVC_ZIP_GLOB "$svc")
+        unzip_dest=$(amap_get SVC_UNZIP_DEST "$svc")
+        tui_step "unzip ${zip_glob} → ${unzip_dest}"
+        # shellcheck disable=SC2086
+        unzip -oq ${zip_glob} -d "${unzip_dest}"
+        # Stamp = SHA-1 of the source we just built from. Clears the `*` and
+        # lets us tell content-vs-mtime apart on the next dirty check.
+        svc_source_hash "$svc" > "$BUILD_STAMP_DIR/$svc"
+        tui_ok "$svc: build done"
+        # Don't clear the phase yet — the caller (cmd_update_one) will
+        # transition us through stop_one/start_one which overwrite it.
+        # If something else is the caller, the TUI's "phase cleared once
+        # poller sees running" rule covers us.
+    else
+        phase_clear "$svc"
+        tui_err "$svc: sbt $proj/dist FAILED  ${DIM}(tail -f $log)${RESET}"
+        return 1
+    fi
+}
+
+# True if ANY JVM service's source changed (content-hash) since its last build.
+# This is what `up`/`build` use to decide whether to skip the sbt step. It
+# is the same check the dashboard's SRC `*` indicator uses, just OR'd across
+# all JVM services.
+any_jvm_src_changed() {
+    local svc=""
+    for svc in "${SERVICES[@]}"; do
+        [[ "$(amap_get SVC_TYPE "$svc")" == "jvm" ]] || continue
+        if svc_src_changed "$svc" 2>/dev/null; then
+            return 0
+        fi
+    done
+    return 1
+}
+
+needs_yarn_install() {
+    [[ ! -f frontend/node_modules/.yarn-state.yml ]] && return 0
+    [[ frontend/yarn.lock -nt frontend/node_modules/.yarn-state.yml ]] && return 0
+    [[ frontend/package.json -nt frontend/node_modules/.yarn-state.yml ]] && return 0
+    return 1
+}
+
+needs_bun_install() {
+    [[ ! -d agent-service/node_modules ]] && return 0
+    [[ agent-service/bun.lock -nt agent-service/node_modules ]] && return 0
+    [[ agent-service/package.json -nt agent-service/node_modules ]] && return 0
+    return 1
+}
+
+# Cache of per-service transitive src dirs, populated once at startup by
+# _precompute_src_dirs(). Each entry (in the SVC_SRC_DIRS amap) is a
+# newline-separated list — callers consume via `while read` (same shape
+# as the old _svc_src_dirs output).
+
+_precompute_src_dirs() {
+    local svc="" entry="" dirs=""
+    for svc in "${SERVICES[@]}"; do
+        [[ "$(amap_get SVC_TYPE "$svc")" == "jvm" ]] || continue
+        entry=$(amap_get SVC_SBT "$svc")
+        # computing-unit-master rides amber's dist (no SVC_SBT of its
+        # own); enter the graph at the producing project explicitly.
+        if [[ -z "$entry" && "$svc" == "computing-unit-master" ]]; then
+            entry="WorkflowExecutionService"
+        fi
+        if [[ -z "$entry" ]]; then
+            echo "FATAL: service '$svc' has no SVC_SBT entry and no" >&2
+            echo "       SHARED_SBT_PROJECT mapping — can't compute its" >&2
+            echo "       transitive source-dir closure." >&2
+            exit 1
+        fi
+        if ! amap_has SBT_PATH "$entry"; then
+            echo "FATAL: sbt project '$entry' (for service '$svc') is not" >&2
+            echo "       in the parsed build.sbt graph. Either build.sbt" >&2
+            echo "       parsing failed or the project was renamed/removed." >&2
+            exit 1
+        fi
+        dirs="$(_sbt_transitive_src_dirs "$entry")"
+        if [[ -z "$dirs" ]]; then
+            echo "FATAL: transitive source-dir closure for '$svc' is empty." >&2
+            exit 1
+        fi
+        amap_set SVC_SRC_DIRS "$svc" "$dirs"
+    done
+}
+
+# Look up the pre-populated cache.
+_svc_src_dirs() {
+    local svc="$1"
+    local cached=""
+    cached=$(amap_get SVC_SRC_DIRS "$svc")
+    if [[ -z "$cached" ]]; then
+        echo "FATAL: _svc_src_dirs: '$svc' missing from SVC_SRC_DIRS" >&2
+        echo "       (did _precompute_src_dirs run?)" >&2
+        exit 1
+    fi
+    printf '%s\n' "$cached"
+}
+
+# Compute a SHA-1 over the content of every .scala/.java/.proto file that
+# matters for this service. ~100 ms; called only on the slow path of
+# svc_src_changed and from the post-build stamp write.
+svc_source_hash() {
+    local svc="$1"
+    local dirs=()
+    local d=""
+    while IFS= read -r d; do
+        [[ -n "$d" ]] && dirs+=("$d")
+    done < <(_svc_src_dirs "$svc")
+    find "${dirs[@]}" \
+        \( -name "*.scala" -o -name "*.java" -o -name "*.proto" \) \
+        -type f -print0 2>/dev/null \
+        | sort -z \
+        | xargs -0 cat 2>/dev/null \
+        | shasum -a 1 \
+        | awk '{print $1}'
+}
+
+# Per-service dirty check (the SRC * indicator). Two-stage:
+#   Fast path  (~22 ms): is any tracked source newer than the stamp file's
+#                        mtime? If not, definitely clean.
+#   Slow path (~100 ms): compute current source hash and compare to the hash
+#                        we stored at last build time. If they match, the
+#                        mtime moved without content moving (typical for git
+#                        checkout) — refresh the stamp mtime so we skip the
+#                        slow path next tick. If they differ, dirty.
+svc_src_changed() {
+    local svc="$1"
+    case "$(amap_get SVC_TYPE "$svc")" in
+        jvm)
+            local stamp="$BUILD_STAMP_DIR/$svc"
+            # Lazy seed: if we have a jar but no stamp, assume the jar matches
+            # current sources and seed with the hash. First REPL after a fresh
+            # checkout pays this once (~100 ms) and is clean afterwards.
+            if [[ ! -s "$stamp" ]]; then
+                local jar=""
+                if [[ "$svc" == "texera-web" || "$svc" == "computing-unit-master" ]]; then
+                    jar="amber/target/amber-${TEXERA_VERSION}/lib/org.apache.texera.amber-${TEXERA_VERSION}.jar"
+                else
+                    jar="target/${svc}-${TEXERA_VERSION}/lib/org.apache.texera.${svc}-${TEXERA_VERSION}.jar"
+                fi
+                [[ ! -f "$jar" ]] && return 0   # no jar, definitely dirty
+                svc_source_hash "$svc" > "$stamp"
+                return 1
+            fi
+
+            # Fast path: any tracked source mtime newer than stamp's mtime?
+            local dirs=() d=""
+            while IFS= read -r d; do
+                [[ -n "$d" ]] && dirs+=("$d")
+            done < <(_svc_src_dirs "$svc")
+            local newer=""
+            newer=$(find "${dirs[@]}" \
+                \( -name "*.scala" -o -name "*.java" -o -name "*.proto" \) \
+                -newer "$stamp" -type f -print 2>/dev/null | head -1)
+            if [[ -z "$newer" ]]; then
+                return 1   # nothing changed since last stamp → clean
+            fi
+
+            # Slow path: did the content actually change, or just mtimes?
+            local current_hash="" stored_hash=""
+            current_hash=$(svc_source_hash "$svc")
+            stored_hash=$(cat "$stamp" 2>/dev/null)
+            if [[ "$current_hash" == "$stored_hash" ]]; then
+                # Same content, just newer mtimes (git checkout, touch, etc.).
+                # Refresh stamp mtime to skip the slow path next tick.
+                touch "$stamp"
+                return 1
+            fi
+            return 0   # content really changed → dirty
+            ;;
+        yarn)   needs_yarn_install ;;
+        bun)    needs_bun_install ;;
+        docker) return 1 ;;
+    esac
+}
+
+build_all() {
+    BUILD_DID_RUN=false
+    local log="$LOG_DIR/sbt-dist.log"
+    if [[ "${FRESH:-false}" == "true" ]]; then
+        if tui_run_with_spinner "$log" "sbt clean dist  ${DIM}(log: $log)${RESET}" \
+            sbt -no-colors clean dist; then
+            tui_ok "sbt: clean dist done"
+            BUILD_DID_RUN=true
+        else
+            tui_err "sbt: clean dist FAILED  ${DIM}(tail -f $log)${RESET}"
+            return 1
+        fi
+    elif [[ "${BUILD:-auto}" == "auto" ]] && ! any_jvm_src_changed; then
+        tui_skip "sbt dist: skipped (no source changes since last build)"
+        return 0
+    else
+        if tui_run_with_spinner "$log" "sbt dist  ${DIM}(log: $log)${RESET}" \
+            sbt -no-colors dist; then
+            tui_ok "sbt: dist done"
+            BUILD_DID_RUN=true
+        else
+            tui_err "sbt: dist FAILED  ${DIM}(tail -f $log)${RESET}"
+            return 1
+        fi
+    fi
+    # Stop any running JVMs BEFORE unzip — overwriting jars under a live JVM
+    # corrupts its lazy class loads and the service silently dies later.
+    if [[ "$BUILD_DID_RUN" == "true" ]]; then
+        local svc="" pid=""
+        for svc in "${SERVICES[@]}"; do
+            [[ "$(amap_get SVC_TYPE "$svc")" == "jvm" ]] || continue
+            pid=$(svc_running_pid "$svc")
+            [[ -z "$pid" ]] && continue
+            tui_step "$svc: pre-bouncing PID $pid (jars about to change)"
+            kill "$pid" 2>/dev/null || true
+        done
+        # Wait briefly for them to actually exit
+        local waited=0
+        while (( waited < 10 )); do
+            local still_up=0
+            for svc in "${SERVICES[@]}"; do
+                [[ "$(amap_get SVC_TYPE "$svc")" == "jvm" ]] || continue
+                [[ -n "$(svc_running_pid "$svc")" ]] && still_up=$((still_up+1))
+            done
+            (( still_up == 0 )) && break
+            sleep 0.5
+            waited=$((waited+1))
+        done
+    fi
+    tui_step "unzipping dist artifacts"
+    local zip_glob="" unzip_dest=""
+    for svc in "${SERVICES[@]}"; do
+        [[ "$(amap_get SVC_TYPE "$svc")" == "jvm" ]] || continue
+        zip_glob=$(amap_get SVC_ZIP_GLOB "$svc")
+        unzip_dest=$(amap_get SVC_UNZIP_DEST "$svc")
+        # Sibling services (empty ZIP_GLOB) share another service's dist —
+        # just stamp them as clean since the unzip already happened for the
+        # twin holding the build.
+        if [[ -z "$zip_glob" ]]; then
+            svc_source_hash "$svc" > "$BUILD_STAMP_DIR/$svc" 2>/dev/null || true
+            continue
+        fi
+        # shellcheck disable=SC2086
+        if unzip -oq ${zip_glob} -d "${unzip_dest}" 2>/dev/null; then
+            svc_source_hash "$svc" > "$BUILD_STAMP_DIR/$svc"
+        else
+            tui_warn "${zip_glob} not produced"
+        fi
+    done
+    tui_ok "artifacts unzipped"
+}
+
+refresh_node_deps() {
+    if ! is_skipped frontend; then
+        if [[ "${BUILD:-auto}" == "auto" ]] && ! needs_yarn_install; then
+            tui_skip "yarn install: skipped (lock unchanged)"
+        else
+            local log="$LOG_DIR/yarn-install.log"
+            if tui_run_with_spinner "$log" "yarn install (frontend)  ${DIM}(log: $log)${RESET}" \
+                bash -c "cd frontend && yarn install"; then
+                tui_ok "yarn: deps refreshed"
+            else
+                tui_warn "yarn install failed  ${DIM}(tail -f $log)${RESET}"
+            fi
+        fi
+    fi
+    if ! is_skipped agent-service; then
+        if [[ "${BUILD:-auto}" == "auto" ]] && ! needs_bun_install; then
+            tui_skip "bun install: skipped (lock unchanged)"
+        else
+            local log="$LOG_DIR/bun-install.log"
+            if tui_run_with_spinner "$log" "bun install (agent-service)  ${DIM}(log: $log)${RESET}" \
+                bash -c "cd agent-service && bun install"; then
+                tui_ok "bun: deps refreshed"
+            else
+                tui_warn "bun install failed  ${DIM}(tail -f $log)${RESET}"
+            fi
+        fi
+    fi
+}
+
+# --------- subcommands ---------
+cmd_status() {
+    local branch="" sha=""
+    branch=$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "?")
+    sha=$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo "?")
+    tui_banner "Texera Local Dev" "branch: $branch  @  $sha"
+
+    # One docker stats call up front — paying the ~2s docker-API cost once
+    # is cheaper than running it per docker service. Indexed by container
+    # name → "cpu%|mem" so the per-row formatting is just a lookup.
+    # `_DSTATS` is an amap (bash 3.2: no associative arrays).
+    while IFS='|' read -r name cpu mem; do
+        [[ -n "$name" ]] && amap_set _DSTATS "$name" "$cpu|$mem"
+    done < <(_docker_stats_snapshot)
+
+    printf "\n"
+    printf "    ${BOLD}%-32s %-6s %-9s %-10s %-7s %-7s %s${RESET}\n" \
+        "SERVICE" "PORT" "PID" "UPTIME" "CPU%" "MEM" "STATE"
+    printf "    ${GRAY}"
+    tui_hline "─" 32; printf " "
+    tui_hline "─" 6;  printf " "
+    tui_hline "─" 9;  printf " "
+    tui_hline "─" 10; printf " "
+    tui_hline "─" 7;  printf " "
+    tui_hline "─" 7;  printf " "
+    tui_hline "─" 12; printf "${RESET}\n"
+
+    local n_running=0 n_total=0 frontend_up=false
+    for svc in "${SERVICES[@]}"; do
+        n_total=$((n_total+1))
+        local pid="—" state="stopped" uptime="—" cpu="—" mem="—"
+        if [[ "$(amap_get SVC_TYPE "$svc")" == "docker" ]]; then
+            state=$(docker_svc_state "$svc")
+            if [[ "$state" == "running" ]]; then
+                local container="" dstat=""
+                container=$(_docker_container_for "$svc")
+                dstat=$(amap_get _DSTATS "$container")
+                if [[ -n "$dstat" ]]; then
+                    cpu="${dstat%|*}"
+                    mem="${dstat#*|}"
+                fi
+                uptime=$(svc_uptime "$svc")
+            fi
+        else
+            local found_pid=""
+            found_pid=$(svc_running_pid "$svc")
+            if [[ -n "$found_pid" ]]; then
+                state="running"; pid="$found_pid"
+                uptime=$(svc_uptime "$svc")
+                local cm=""
+                cm=$(svc_proc_cpu_mem "$svc")
+                cpu="${cm%|*}"
+                mem="${cm#*|}"
+            fi
+        fi
+        [[ "$state" == "running" ]] && n_running=$((n_running+1))
+        [[ "$svc" == "frontend" && "$state" == "running" ]] && frontend_up=true
+
+        local sym="" color=""
+        sym=$(tui_state_symbol "$state")
+        color=$(tui_state_color "$state")
+        printf "  %s " "$sym"
+        printf "${color}%-32s${RESET} %-6s ${DIM}%-9s${RESET} %-10s %-7s %-7s ${color}%s${RESET}\n" \
+            "$svc" "$(amap_get SVC_PORT "$svc")" "$pid" "$uptime" "$cpu" "$mem" "$state"
+    done
+    printf "\n"
+
+    local summary_color="$YELLOW"
+    (( n_running == n_total )) && summary_color="$GREEN"
+    (( n_running == 0 )) && summary_color="$GRAY"
+    printf "  ${BOLD}Status${RESET}: ${summary_color}%d of %d services running${RESET}\n" "$n_running" "$n_total"
+
+    printf "\n"
+    printf "  ${CYAN}${SYM_LIST}${RESET}  Logs:    ${DIM}%s${RESET}\n" "$LOG_DIR/<service>.log"
+    printf "  ${CYAN}${SYM_LIST}${RESET}  Docker:  ${DIM}docker compose -p %s ps${RESET}\n" "$DOCKER_PROJECT"
+    if $frontend_up; then
+        printf "  ${CYAN}${SYM_LIST}${RESET}  Open:    ${BOLD}${GREEN}http://localhost:4200${RESET}  ${GREEN}(frontend live)${RESET}\n"
+    else
+        printf "  ${CYAN}${SYM_LIST}${RESET}  Open:    ${DIM}http://localhost:4200  (frontend not running yet)${RESET}\n"
+    fi
+
+    printf "\n  ${BOLD}Common operations${RESET}\n"
+    printf "    ${BOLD}bin/local-dev.sh up${RESET}            ${DIM}# bring up the whole stack (build + start)${RESET}\n"
+    printf "    ${BOLD}bin/local-dev.sh down${RESET}          ${DIM}# stop every service${RESET}\n"
+    printf "    ${BOLD}bin/local-dev.sh auto${RESET}          ${DIM}# rebuild + bounce only the services whose source changed${RESET}\n"
+    printf "    ${BOLD}bin/local-dev.sh <svc>${RESET}         ${DIM}# rebuild that one JVM service and bounce it${RESET}\n"
+    printf "    ${BOLD}bin/local-dev.sh start <svc>${RESET}   ${DIM}# start one service without rebuilding${RESET}\n"
+    printf "    ${BOLD}bin/local-dev.sh stop  <svc>${RESET}   ${DIM}# stop one service${RESET}\n"
+    printf "    ${BOLD}bin/local-dev.sh logs  <svc>${RESET}   ${DIM}# tail a service's log${RESET}\n"
+    printf "    ${BOLD}bin/local-dev.sh -i${RESET}            ${DIM}# open the live TUI (needs Python + textual)${RESET}\n"
+    printf "    ${BOLD}bin/local-dev.sh --help${RESET}        ${DIM}# full reference${RESET}\n"
+    printf "\n"
+}
+
+cmd_up() {
+    SKIP_LIST=""
+    FRESH=false
+    BUILD=auto       # auto (skip if no source change) | force | no
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --skip=*)   SKIP_LIST="${1#--skip=}" ;;
+            --fresh)    FRESH=true; BUILD=force ;;
+            --build)    BUILD=force ;;
+            --no-build) BUILD=no ;;
+            *) tui_err "unknown flag: $1" >&2; exit 2 ;;
+        esac
+        shift
+    done
+
+    local n_skip=0
+    [[ -n "$SKIP_LIST" ]] && n_skip=$(echo "$SKIP_LIST" | tr ',' '\n' | wc -l | tr -d ' ')
+    local skip_label="none"
+    (( n_skip > 0 )) && skip_label="$n_skip service(s)"
+    tui_banner "Texera Local Dev — bringing stack up" "JDK 17 · skip=$skip_label · build=$BUILD"
+
+    # ── Pre-flight short-circuit ───────────────────────────────────────────
+    # If nothing's changed AND every service is already running, just say so
+    # and exit. Saves the user from scrolling through 30+ "already running"
+    # lines for the common "I just want to check" case.
+    if [[ "$BUILD" == "auto" && -z "$SKIP_LIST" ]]; then
+        local nothing_to_build=true
+        any_jvm_src_changed   && nothing_to_build=false
+        needs_yarn_install    && nothing_to_build=false
+        needs_bun_install     && nothing_to_build=false
+
+        local all_running=true svc=""
+        for svc in "${SERVICES[@]}"; do
+            if [[ "$(amap_get SVC_TYPE "$svc")" == "docker" ]]; then
+                [[ "$(docker_svc_state "$svc")" == "running" ]] || { all_running=false; break; }
+            else
+                [[ -n "$(svc_running_pid "$svc")" ]] || { all_running=false; break; }
+            fi
+        done
+
+        if $nothing_to_build && $all_running; then
+            tui_section "Pre-flight"
+            tui_ok "no source/lock changes since last build"
+            tui_ok "all ${#SERVICES[@]} services already running"
+            printf "\n  ${BOLD}${GREEN}${SYM_OK} nothing to do${RESET}  ${DIM}(use \`u --build\` to force a rebuild, or \`<svc>\` to bounce just one)${RESET}\n\n"
+            return 0
+        fi
+    fi
+
+    if [[ "$BUILD" != "no" ]]; then
+        tui_section "Build"
+        build_all
+        refresh_node_deps
+    else
+        tui_section "Build"
+        tui_skip "build: --no-build (using existing artifacts)"
+    fi
+
+    # ▸ Services -- docker compose has its own TTY panel; native services we
+    # kick off silently in the background, then a single redrawing panel below
+    # shows progress for ALL of them.
+    tui_section "Services  ${DIM}(launching)${RESET}"
+    local svc="" cwd="" log="" type="" launcher=""
+
+    # One project-level `docker compose up -d` for every non-skipped docker
+    # row — much faster than five separate calls.
+    local has_docker_targets=false
+    for svc in "${SERVICES[@]}"; do
+        [[ "$(amap_get SVC_TYPE "$svc")" == "docker" ]] || continue
+        is_skipped "$svc" && continue
+        has_docker_targets=true
+        break
+    done
+    if $has_docker_targets; then
+        case "$(infra_state)" in
+            running)    tui_ok "infra: already running" ;;
+            external:*) tui_err "infra: ports taken by non-script containers"
+                        printf "  ${DIM}docker compose -p texera-dev down${RESET}\n" ;;
+            *)          infra_up || true ;;
+        esac
+        # Whether infra is fresh or already up, make sure the texera_db
+        # schema is current — JVMs about to start expect it (jOOQ + Iceberg
+        # both need real tables).
+        infra_ensure_db_schema
+    fi
+
+    for svc in "${SERVICES[@]}"; do
+        is_skipped "$svc" && { tui_skip "$svc: --skip"; continue; }
+        type=$(amap_get SVC_TYPE "$svc")
+        [[ "$type" == "docker" ]] && continue   # already handled by infra_up
+        if [[ -n "$(svc_running_pid "$svc")" ]]; then
+            tui_ok "$svc: already running ${DIM}(PID $(svc_running_pid "$svc"))${RESET}"
+            continue
+        fi
+        cwd=$(amap_get SVC_CWD "$svc")
+        log="$LOG_DIR/$svc.log"
+        tui_step "$svc: launching → ${DIM}$log${RESET}"
+        case "$type" in
+            jvm)
+                launcher=$(amap_get SVC_LAUNCHER "$svc")
+                if [[ ! -x "$cwd/$launcher" ]]; then
+                    tui_err "$svc: launcher missing at $cwd/$launcher"
+                    continue
+                fi
+                ( cd "$cwd" && nohup "./$launcher" >"$log" 2>&1 </dev/null & ) ;;
+            bun)  ( cd "$cwd" && nohup bun run dev >"$log" 2>&1 </dev/null & ) ;;
+            yarn) ( cd "$cwd" && nohup yarn start  >"$log" 2>&1 </dev/null & ) ;;
+        esac
+    done
+
+    tui_section "Health  ${DIM}(refreshing in place)${RESET}"
+    local ec=0
+    tui_wait_panel || ec=$?
+
+    printf "\n"
+    local ok=0 total=0 failed=0
+    for svc in "${SERVICES[@]}"; do
+        is_skipped "$svc" && continue
+        total=$((total+1))
+        if [[ "$(amap_get SVC_TYPE "$svc")" == "docker" ]]; then
+            case "$(docker_svc_state "$svc")" in
+                running|exited) ok=$((ok+1)) ;;
+                *)              failed=$((failed+1)) ;;
+            esac
+        else
+            [[ -n "$(svc_running_pid "$svc")" ]] && ok=$((ok+1)) || failed=$((failed+1))
+        fi
+    done
+    if (( failed == 0 )); then
+        printf "  ${BOLD}${GREEN}${SYM_OK} %d of %d services healthy${RESET}\n" "$ok" "$total"
+    else
+        printf "  ${BOLD}${YELLOW}${SYM_WARN} %d of %d services healthy${RESET}  ${RED}(%d failed)${RESET}\n" \
+            "$ok" "$total" "$failed"
+    fi
+    printf "\n"
+
+    cmd_status
+    [[ $ec -eq 0 ]]
+}
+
+# `auto`: the minimal "make the running services match my current source" path.
+# Walks every service, identifies what's actually dirty (content-hash for JVM,
+# lock mtime for yarn/bun, never for docker), and only touches those:
+#   - dirty JVM, currently running   → rebuild + bounce
+#   - dirty JVM, currently stopped   → rebuild only (don't auto-start)
+#   - dirty yarn (frontend lock)     → yarn install + bounce frontend if up
+#   - dirty bun (agent-service lock) → bun install (bun --watch reloads itself)
+# Clean services are left alone — no pre-bounce, no restart.
+cmd_auto() {
+    SKIP_LIST=""
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --skip=*) SKIP_LIST="${1#--skip=}" ;;
+            *) tui_err "unknown flag: $1" >&2; exit 2 ;;
+        esac
+        shift
+    done
+
+    tui_banner "Texera Local Dev — auto bounce" \
+        "rebuild + bounce only what changed since last build"
+
+    # ── Scan ──────────────────────────────────────────────────────────────
+    tui_section "Scan"
+    local svc=""
+    local dirty_jvms=()
+    local need_yarn=false
+    local need_bun=false
+    for svc in "${SERVICES[@]}"; do
+        is_skipped "$svc" && continue
+        case "$(amap_get SVC_TYPE "$svc")" in
+            jvm)
+                if svc_src_changed "$svc"; then
+                    dirty_jvms+=("$svc")
+                    tui_warn "$svc: source changed since last build"
+                fi ;;
+            yarn)
+                if needs_yarn_install; then
+                    need_yarn=true
+                    tui_warn "frontend: yarn.lock newer than node_modules — needs install"
+                fi ;;
+            bun)
+                if needs_bun_install; then
+                    need_bun=true
+                    tui_warn "agent-service: bun.lock newer than node_modules — needs install"
+                fi ;;
+        esac
+    done
+
+    if (( ${#dirty_jvms[@]} == 0 )) && ! $need_yarn && ! $need_bun; then
+        tui_ok "everything up-to-date — nothing to bounce"
+        printf "\n"
+        return 0
+    fi
+
+    # ── Build ─────────────────────────────────────────────────────────────
+    # One `sbt dist` covers every dirty JVM in a single sbt invocation; sbt's
+    # own incremental compiler only recompiles the subprojects that need it,
+    # and we only unzip + bounce the dirty ones below — clean services don't
+    # get pre-bounced just because the build ran.
+    if (( ${#dirty_jvms[@]} > 0 )); then
+        tui_section "Build  ${DIM}(${#dirty_jvms[@]} JVM service(s) dirty)${RESET}"
+        # Mark each dirty service as "building" so the TUI shows the
+        # animation across the whole sbt window (~30s+). Without this the
+        # dashboard stays on the prior STATE during the slow build.
+        local _s=""
+        for _s in "${dirty_jvms[@]}"; do
+            phase_set "$_s" building
+        done
+        local log="$LOG_DIR/sbt-dist.log"
+        if ! tui_run_with_spinner "$log" "sbt dist  ${DIM}(log: $log)${RESET}" \
+                sbt -no-colors dist; then
+            for _s in "${dirty_jvms[@]}"; do
+                phase_clear "$_s"
+            done
+            tui_err "sbt dist failed  ${DIM}(tail -f $log)${RESET}"
+            return 1
+        fi
+        tui_ok "sbt: dist done"
+    fi
+
+    # ── Bounce dirty JVMs ────────────────────────────────────────────────
+    # Two passes so siblings can coexist:
+    #   1) stop + (maybe-)unzip + stamp every dirty service
+    #   2) start the ones that were running when we entered
+    #
+    # Pass 1 needs to finish before pass 2 because computing-unit-master
+    # shares amber's dist with texera-web: cu-master has empty SVC_ZIP_GLOB
+    # / SVC_UNZIP_DEST, so its "rebuild" is just waiting for texera-web's
+    # iteration to unzip amber. If we kept the previous single-pass loop
+    # we'd `continue` past start_one whenever ZIP_GLOB was empty and the
+    # sibling never came back up.
+    local n_bounced=0 n_rebuilt=0
+    # `_was_running` is an amap (bash 3.2: no associative arrays). Keyed by
+    # svc → the PID we observed at the start of pass 1, "" if not running.
+    # Cleared with `unset` at the top so a re-invocation (e.g. interactive
+    # REPL would have, if we still had one) starts fresh.
+    local _wr_var=""
+    for _wr_var in $(compgen -v _amap___was_running__ 2>/dev/null); do
+        unset "$_wr_var"
+    done
+    if (( ${#dirty_jvms[@]} > 0 )); then
+        tui_section "Bounce"
+        # Pass 1: stop running pids; unzip own dist if we have one.
+        for svc in "${dirty_jvms[@]}"; do
+            local pid=""
+            pid=$(svc_running_pid "$svc")
+            amap_set _was_running "$svc" "$pid"
+            if [[ -n "$pid" ]]; then
+                # Flip the dashboard from `building` to `stopping` while we
+                # SIGTERM/SIGKILL the JVM, then back to `building` for the
+                # unzip step below. Without these the user only sees one
+                # state during the whole bounce.
+                phase_set "$svc" stopping
+                tui_step "$svc: stopping PID $pid before unzip"
+                kill "$pid" 2>/dev/null || true
+                local i=0
+                while (( i < 30 )) && kill -0 "$pid" 2>/dev/null; do
+                    sleep 0.5
+                    i=$((i+1))
+                done
+                kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null
+            fi
+            local zip_glob="" unzip_dest=""
+            zip_glob=$(amap_get SVC_ZIP_GLOB "$svc")
+            unzip_dest=$(amap_get SVC_UNZIP_DEST "$svc")
+            if [[ -z "$zip_glob" ]]; then
+                # Sibling service (e.g. computing-unit-master): no own dist
+                # to unzip — its launcher comes from the twin's unzip later
+                # in this pass. Just stamp it clean.
+                phase_set "$svc" building
+                svc_source_hash "$svc" > "$BUILD_STAMP_DIR/$svc" 2>/dev/null || true
+                n_rebuilt=$((n_rebuilt+1))
+                continue
+            fi
+            phase_set "$svc" building
+            # shellcheck disable=SC2086
+            if unzip -oq ${zip_glob} -d "${unzip_dest}" 2>/dev/null; then
+                svc_source_hash "$svc" > "$BUILD_STAMP_DIR/$svc"
+                n_rebuilt=$((n_rebuilt+1))
+            else
+                tui_warn "$svc: ${zip_glob} not produced — skipping"
+            fi
+        done
+        # amber's two siblings (texera-web + computing-unit-master) are
+        # *intended* to run together — they share the dist and they're
+        # both required for a working stack. If either was running before
+        # the auto pass, treat the whole group as "should be running" so
+        # the other doesn't end up silently dead just because it had
+        # already crashed / been left stopped from an earlier session.
+        local AMBER_SIBLINGS=(texera-web computing-unit-master)
+        local amber_group_active=false
+        local s=""
+        for s in "${AMBER_SIBLINGS[@]}"; do
+            [[ -n "$(amap_get _was_running "$s")" ]] && amber_group_active=true
+        done
+
+        # Pass 2: start the ones that had been running, plus any amber
+        # sibling that is dirty when the group is "active". By now every
+        # sibling's launcher is on disk (the twin's unzip in pass 1
+        # populated `amber/target/amber-<VERSION>/bin/*`).
+        for svc in "${dirty_jvms[@]}"; do
+            local should_start=false
+            if [[ -n "$(amap_get _was_running "$svc")" ]]; then
+                should_start=true
+            elif $amber_group_active && { [[ "$svc" == "texera-web" ]] || [[ "$svc" == "computing-unit-master" ]]; }; then
+                should_start=true
+                tui_step "$svc: was stopped but its sibling is active — starting too"
+            fi
+            if $should_start; then
+                start_one "$svc"
+                n_bounced=$((n_bounced+1))
+            else
+                # Pass 1 left phase=building set so the dashboard animates
+                # during sbt + unzip; clear it now since we won't be
+                # starting (otherwise the row spins building… until the
+                # 90s stale rule kicks in).
+                phase_clear "$svc"
+                tui_skip "$svc: was stopped — rebuilt but not started"
+            fi
+        done
+    fi
+
+    # ── Node deps ────────────────────────────────────────────────────────
+    if $need_yarn; then
+        tui_section "Frontend deps"
+        local log="$LOG_DIR/yarn-install.log"
+        if tui_run_with_spinner "$log" "yarn install  ${DIM}(log: $log)${RESET}" \
+                bash -c "cd frontend && yarn install"; then
+            tui_ok "yarn: deps refreshed"
+            # ng serve doesn't pick up dependency-tree changes from a running
+            # process; bounce if it was up.
+            if [[ -n "$(svc_running_pid frontend)" ]]; then
+                stop_one frontend
+                start_one frontend
+                n_bounced=$((n_bounced+1))
+            else
+                tui_skip "frontend: was stopped — deps refreshed but not started"
+            fi
+        else
+            tui_err "yarn install failed  ${DIM}(tail -f $log)${RESET}"
+        fi
+    fi
+
+    if $need_bun; then
+        tui_section "Agent-service deps"
+        local log="$LOG_DIR/bun-install.log"
+        if tui_run_with_spinner "$log" "bun install  ${DIM}(log: $log)${RESET}" \
+                bash -c "cd agent-service && bun install"; then
+            tui_ok "bun: deps refreshed"
+            # bun --watch reloads itself when node_modules changes; no manual
+            # bounce needed.
+            if [[ -n "$(svc_running_pid agent-service)" ]]; then
+                tui_skip "agent-service: bun --watch will reload"
+            else
+                tui_skip "agent-service: was stopped — deps refreshed but not started"
+            fi
+        else
+            tui_err "bun install failed  ${DIM}(tail -f $log)${RESET}"
+        fi
+    fi
+
+    # ── Summary + final dashboard ────────────────────────────────────────
+    printf "\n"
+    printf "  ${BOLD}${GREEN}${SYM_OK} auto bounce done${RESET}: %d rebuilt, %d bounced\n\n" \
+        "$n_rebuilt" "$n_bounced"
+    cmd_status
+}
+
+cmd_down() {
+    SKIP_LIST=""
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --skip=*) SKIP_LIST="${1#--skip=}" ;;
+            *) tui_err "unknown flag: $1" >&2; exit 2 ;;
+        esac
+        shift
+    done
+    tui_banner "Texera Local Dev — stopping stack" "skip=${SKIP_LIST:-none}"
+    tui_section "Stopping (reverse order)"
+    local svc=""
+    # Stop native services first (reverse declaration order)
+    # bash arrays are 0-indexed: last element is at N-1.
+    for (( i=${#SERVICES[@]} - 1; i>=0; i-- )); do
+        svc="${SERVICES[i]}"
+        [[ "$(amap_get SVC_TYPE "$svc")" == "docker" ]] && continue
+        if is_skipped "$svc"; then
+            tui_skip "$svc: --skip"
+            continue
+        fi
+        stop_one "$svc"
+    done
+    # Then one project-level docker compose down for any docker target.
+    local has_docker_targets=false
+    for svc in "${SERVICES[@]}"; do
+        [[ "$(amap_get SVC_TYPE "$svc")" == "docker" ]] || continue
+        if is_skipped "$svc"; then
+            tui_skip "$svc: --skip"
+            continue
+        fi
+        has_docker_targets=true
+    done
+    $has_docker_targets && infra_down
+    printf "\n"
+}
+
+cmd_update_one() {
+    local svc="$1"
+    if ! amap_has SVC_TYPE "$svc"; then
+        tui_err "unknown service: ${BOLD}$svc${RESET}"
+        printf "  ${DIM}Known:${RESET} ${SERVICES[*]}\n"
+        exit 1
+    fi
+    local type=""
+    type=$(amap_get SVC_TYPE "$svc")
+    case "$type" in
+        docker)
+            tui_banner "Restarting ${svc}" "docker compose restart $svc"
+            tui_step "$svc: docker compose restart $svc"
+            docker compose -p "$DOCKER_PROJECT" restart "$svc" >/dev/null 2>&1 \
+                && tui_ok "$svc: restarted" \
+                || { tui_err "$svc: restart failed"; exit 1; }
+            exit 0
+            ;;
+        yarn)
+            tui_warn "frontend uses ng's watch -- source changes hot-reload automatically."
+            printf "  ${DIM}For dep changes: kill PID ${RESET}$(svc_running_pid frontend)${DIM}; then bin/local-dev.sh up${RESET}\n"
+            exit 0
+            ;;
+        bun)
+            tui_banner "Updating ${svc}" "bun install + bounce"
+            tui_section "Deps"
+            ( cd "$(amap_get SVC_CWD "$svc")" && bun install )
+            tui_section "Bounce"
+            stop_one "$svc"
+            start_one "$svc"
+            ;;
+        jvm)
+            local _sbt_proj=""
+            _sbt_proj=$(amap_get SVC_SBT "$svc")
+            if [[ -n "$_sbt_proj" ]]; then
+                tui_banner "Updating ${svc}" "sbt ${_sbt_proj}/dist + bounce"
+            else
+                tui_banner "Updating ${svc}" "bounce only (shares dist with its sibling)"
+            fi
+            tui_section "Build"
+            build_one_jvm "$svc"
+            tui_section "Bounce"
+            # amber's two siblings share a dist — rebuilding one moves the
+            # jar bytes underneath the other. Always bounce them together
+            # so neither ends up running stale code (or silently dead).
+            local sibling=""
+            case "$svc" in
+                texera-web)            sibling="computing-unit-master" ;;
+                computing-unit-master) sibling="texera-web" ;;
+            esac
+            local sibling_was_running=false
+            if [[ -n "$sibling" ]] && [[ -n "$(svc_running_pid "$sibling")" ]]; then
+                sibling_was_running=true
+                tui_step "$sibling: stopping (shares amber dist with $svc)"
+                stop_one "$sibling"
+            fi
+            stop_one "$svc"
+            start_one "$svc"
+            if $sibling_was_running; then
+                start_one "$sibling"
+            fi
+            ;;
+    esac
+    tui_section "Health"
+    local _port=""
+    _port=$(amap_get SVC_PORT "$svc")
+    if wait_for_port "$_port" 60; then
+        printf "  ${GREEN}${SYM_OK}${RESET}  %-32s ${DIM}:%s${RESET}\n" "$svc" "$_port"
+    else
+        printf "  ${RED}${SYM_ERR}${RESET}  %-32s ${DIM}:%s${RESET}  ${RED}timeout${RESET}  ${DIM}(bin/local-dev.sh logs %s)${RESET}\n" \
+            "$svc" "$_port" "$svc"
+        exit 1
+    fi
+    printf "\n"
+}
+
+cmd_logs() {
+    local svc="${1:?usage: bin/local-dev.sh logs <service>}"
+    if ! amap_has SVC_TYPE "$svc"; then
+        echo "Unknown service: $svc" >&2
+        exit 1
+    fi
+    if [[ "$(amap_get SVC_TYPE "$svc")" == "docker" ]]; then
+        exec docker compose -p "$DOCKER_PROJECT" logs -f "$svc"
+    fi
+    exec tail -f "$LOG_DIR/$svc.log"
+}
+
+# Render the interactive dashboard panel (banner + service table + hint + summary).
+tui_render_dashboard() {
+    printf "\e[2J\e[H"   # clear screen + home cursor (scrollback preserved)
+    local branch="" sha=""
+    branch=$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "?")
+    sha=$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo "?")
+    tui_banner "Texera Local Dev — interactive" "branch: $branch @ $sha · $(date '+%H:%M:%S') · type ? for help"
+    printf "\n"
+
+    printf "    ${BOLD}%-32s %-7s %-9s %-18s %-3s %s${RESET}\n" \
+        "SERVICE" "PORT" "PID" "ARTIFACT MTIME" "SRC" "STATE"
+    printf "    ${GRAY}"; tui_hline "─" 32; printf " "
+    tui_hline "─" 7; printf " "; tui_hline "─" 9; printf " "
+    tui_hline "─" 18; printf " "; tui_hline "─" 3; printf " "
+    tui_hline "─" 12; printf "${RESET}\n"
+
+    local n_run=0 n_total=0 n_dirty=0
+    local svc=""
+    for svc in "${SERVICES[@]}"; do
+        n_total=$((n_total+1))
+        local pid="—" state="stopped" mtime="—" port_str="—" src_disp="   "
+        if [[ "$(amap_get SVC_TYPE "$svc")" == "docker" ]]; then
+            state=$(docker_svc_state "$svc")
+            mtime="docker"
+            port_str=":$(amap_get SVC_PORT "$svc")"
+        else
+            local found_pid=""
+            found_pid=$(svc_running_pid "$svc")
+            if [[ -n "$found_pid" ]]; then state="running"; pid="$found_pid"; fi
+            mtime=$(svc_artifact_mtime "$svc")
+            port_str=":$(amap_get SVC_PORT "$svc")"
+        fi
+        [[ "$state" == "running" ]] && n_run=$((n_run+1))
+
+        if svc_src_changed "$svc" 2>/dev/null; then
+            src_disp="${YELLOW}${BOLD}*${RESET}  "
+            n_dirty=$((n_dirty+1))
+        fi
+
+        local sym="" color=""
+        sym=$(tui_state_symbol "$state")
+        color=$(tui_state_color "$state")
+        printf "  %s " "$sym"
+        printf "${color}%-32s${RESET} %-7s ${DIM}%-9s${RESET} %-18s %s ${color}%s${RESET}\n" \
+            "$svc" "$port_str" "$pid" "$mtime" "$src_disp" "$state"
+    done
+
+    printf "\n"
+    local sum_color="$YELLOW"
+    (( n_run == n_total )) && sum_color="$GREEN"
+    (( n_run == 0 ))       && sum_color="$GRAY"
+    printf "  ${BOLD}${sum_color}%d of %d running${RESET}" "$n_run" "$n_total"
+    if (( n_dirty > 0 )); then
+        printf "    ${YELLOW}${BOLD}*${RESET} ${DIM}%d with source changes${RESET}" "$n_dirty"
+    fi
+    printf "\n\n"
+    printf "  ${DIM}Commands:${RESET}  "
+    printf "${BOLD}r${RESET}efresh${DIM} (or just ↩)${RESET} · "
+    printf "${BOLD}u${RESET}p · ${BOLD}d${RESET}own · "
+    printf "${BOLD}b${RESET}uild · "
+    printf "${BOLD}<svc>${RESET}${DIM}=rebuild+bounce${RESET} · "
+    printf "${BOLD}l${RESET}ogs ${DIM}<svc>${RESET} · "
+    printf "${BOLD}s${RESET}top ${DIM}<svc>${RESET} · "
+    printf "${BOLD}q${RESET}uit\n\n"
+}
+
+# Pure monitoring mode: redraw the dashboard every $1 seconds, no prompt.
+# Ctrl-C to exit. Useful when watching a build/restart from another terminal.
+cmd_watch() {
+    if [[ ! -t 1 ]]; then
+        tui_err "watch mode requires a TTY"
+        exit 1
+    fi
+    local interval="${1:-2}"
+    trap 'printf "\e[?25h\n${DIM}bye${RESET}\n"; exit 0' EXIT INT TERM
+    printf "\e[?25l"   # hide cursor
+    while true; do
+        tui_render_dashboard
+        printf "  ${DIM}watch: refreshing every %ss · Ctrl-C to exit${RESET}\n" "$interval"
+        sleep "$interval"
+    done
+}
+
+# Pause and let the user read command output before re-rendering the dashboard.
+
+# Print the ordered list of Python interpreters we consider for launching the
+# Textual TUI: an explicit override, then any active venv, then the canonical
+# texera dev venv, then whatever `python3`/`python` happen to resolve to. We
+# de-duplicate as we go so the diagnostic doesn't show the same path twice.
+_probed_pythons() {
+    local seen=""
+    local cand=""
+    local raw=(
+        "${TEXERA_PYTHON:-}"
+        "${VIRTUAL_ENV:+$VIRTUAL_ENV/bin/python}"
+        "$(command -v python3 2>/dev/null)"
+        "$(command -v python  2>/dev/null)"
+    )
+    for cand in "${raw[@]}"; do
+        [[ -z "$cand" ]] && continue
+        case ":$seen:" in *":$cand:"*) continue ;; esac
+        seen="$seen:$cand"
+        printf '%s\n' "$cand"
+    done
+}
+
+# Walk `_probed_pythons` and return the first interpreter where `import
+# textual` succeeds, or empty string if none.
+_find_python_with_textual() {
+    local cand=""
+    while IFS= read -r cand; do
+        [[ -x "$cand" ]] || continue
+        if "$cand" -c "import textual" >/dev/null 2>&1; then
+            printf '%s\n' "$cand"
+            return 0
+        fi
+    done < <(_probed_pythons)
+    return 1
+}
+
+# Show every candidate Python the picker considered, its version, and
+# whether it can `import textual`. Pinpoints "I have the right python on
+# PATH but textual isn't installed THERE" vs "no python found at all".
+_diagnose_python() {
+    printf "\n  ${BOLD}candidate interpreters:${RESET}\n"
+    local any=0
+    while IFS= read -r cand; do
+        any=1
+        if [[ ! -x "$cand" ]]; then
+            printf "    ${RED}✗${RESET} %s  ${DIM}(not executable)${RESET}\n" "$cand"
+            continue
+        fi
+        local ver=""
+        ver=$("$cand" --version 2>&1 | head -1) || ver="(unreadable)"
+        if "$cand" -c "import textual; print(textual.__version__)" >/dev/null 2>&1; then
+            local tv=""
+            tv=$("$cand" -c "import textual; print(textual.__version__)" 2>/dev/null) || tv="?"
+            printf "    ${GREEN}✓${RESET} %s  ${DIM}(%s, textual %s)${RESET}\n" "$cand" "$ver" "$tv"
+        else
+            printf "    ${YELLOW}!${RESET} %s  ${DIM}(%s, textual MISSING)${RESET}\n" "$cand" "$ver"
+        fi
+    done < <(_probed_pythons)
+    if (( any == 0 )); then
+        printf "    ${RED}✗${RESET} ${DIM}no candidate python on PATH or in TEXERA_PYTHON / VIRTUAL_ENV${RESET}\n"
+    fi
+    printf "\n  ${BOLD}env:${RESET}\n"
+    printf "    \$TEXERA_PYTHON = ${DIM}%s${RESET}\n" "${TEXERA_PYTHON:-(unset)}"
+    printf "    \$VIRTUAL_ENV   = ${DIM}%s${RESET}\n" "${VIRTUAL_ENV:-(unset)}"
+    printf "\n"
+}
+
+# Hand off to the Python + Textual TUI. Hard requirement now (no more zsh
+# REPL fallback) — if we can't find a Python with `textual` installed,
+# print install instructions and exit non-zero. Use the non-interactive
+# `status` (or any other subcommand) when you don't have Python set up.
+cmd_interactive() {
+    if [[ ! -t 0 || ! -t 1 ]]; then
+        tui_err "interactive mode requires a TTY"
+        exit 1
+    fi
+    # zsh + `set -e` kills the script when a command substitution's command
+    # exits non-zero (`var=$(false)` → silent abort). Suppress with || true
+    # so the error path below actually gets to print the install hint.
+    local py=""
+    py="$(_find_python_with_textual)" || true
+    if [[ -z "$py" ]]; then
+        tui_err "interactive mode requires Python with the ${BOLD}textual${RESET} package"
+        _diagnose_python
+        _install_hint python
+        exit 1
+    fi
+    exec "$py" "$REPO_ROOT/bin/local-dev-tui.py"
+}
+
+# --------- main ---------
+# Warm the per-service source-dir cache so dirty checks below skip the
+# repeated BFS over the sbt graph. ~5ms of one-time cost; saves N×BFS
+# work per `cmd_up` / `cmd_auto` / status loop.
+_precompute_src_dirs
+
+case "${1:-}" in
+    ""|status)        cmd_status ;;             # default: one-shot dashboard (safe in scripts/CI)
+    -i|--interactive) cmd_interactive ;;        # opt in to the live TUI
+    up)               shift; cmd_up "$@" ;;
+    auto)             shift; cmd_auto "$@" ;;
+    down)             shift; cmd_down "$@" ;;
+    start)            shift; start_one "${1:?need service name}" ;;
+    stop)             shift; stop_one "${1:?need service name}" ;;
+    logs)             shift; cmd_logs "${1:-}" ;;
+    w|watch)          shift; cmd_watch "${1:-2}" ;;
+    version)          printf "%s\n" "$TEXERA_VERSION" ;;
+    -h|--help)        sed -n '18,67p' "$0" ;;
+    *)                cmd_update_one "$1" ;;
+esac
diff --git a/bin/local-dev/docker-compose.override.yml b/bin/local-dev/docker-compose.override.yml
new file mode 100644
index 0000000000..94156e9e56
--- /dev/null
+++ b/bin/local-dev/docker-compose.override.yml
@@ -0,0 +1,56 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Overlay for bin/local-dev.sh on top of bin/single-node/docker-compose.yml.
+# bin/single-node was designed so the texera microservices run inside the same
+# compose network as the infra — postgres/lakefs/lakekeeper/litellm therefore
+# don't publish their ports to the host. The local-dev flow runs the JVM and
+# Node services *natively* on the host, so we expose each backend port here.
+
+services:
+  postgres:
+    ports:
+      - "5432:5432"
+
+  lakefs:
+    ports:
+      - "8000:8000"
+
+  lakekeeper:
+    ports:
+      - "8181:8181"
+
+  litellm:
+    ports:
+      - "4000:4000"
+
+  # The default .env hardcodes STORAGE_S3_ENDPOINT=http://texera-minio:9000
+  # — only routable inside the docker compose network. lakekeeper-init bakes
+  # that string into the warehouse's storage profile, which Lakekeeper then
+  # both dials itself (for s3 ops) AND returns to clients. The host-native
+  # JVMs in local-dev mode can't resolve `texera-minio`.
+  #
+  # Use whatever STORAGE_S3_ENDPOINT the parent process exported. The
+  # wrapper script (bin/local-dev.sh) detects the host's LAN IP at startup
+  # and exports e.g. http://192.168.1.x:9000 — reachable from both sides:
+  #   • lakekeeper container: out via docker bridge → host's published :9000
+  #   • host JVM:             straight to host's published :9000
+  # If the env wasn't exported (script not used), fall back to localhost
+  # which at least keeps the warehouse creation from blowing up.
+  lakekeeper-init:
+    environment:
+      STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT:-http://localhost:9000}
diff --git a/amber/dev-requirements.txt b/bin/local-dev/tests/__init__.py
similarity index 56%
copy from amber/dev-requirements.txt
copy to bin/local-dev/tests/__init__.py
index 4ab14960cb..13a83393a9 100644
--- a/amber/dev-requirements.txt
+++ b/bin/local-dev/tests/__init__.py
@@ -14,21 +14,3 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-
-# Test- and dev-only Python dependencies. Installed in CI after the
-# LICENSE-binary snapshot is taken, so packages here never appear in
-# pip-licenses output and never need to be tracked in LICENSE-binary
-# / NOTICE-binary. Not installed by packaging.
-
-# pytest test runner and plugins.
-pytest==9.0.3
-pytest-reraise==2.1.2
-pytest-timeout==2.4.0
-
-# Coverage instrumentation for pytest; emits coverage.xml consumed by
-# Codecov's Phase 1 upload.
-pytest-cov==7.1.0
-
-# protoc plugin for bin/python-proto-gen.sh; runtime needs only the
-# base `betterproto` in requirements.txt.
-betterproto[compiler]==2.0.0b7
diff --git a/bin/local-dev/tests/conftest.py b/bin/local-dev/tests/conftest.py
new file mode 100644
index 0000000000..7525cf3a87
--- /dev/null
+++ b/bin/local-dev/tests/conftest.py
@@ -0,0 +1,44 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""pytest fixtures for the local-dev TUI tests.
+
+The TUI lives as `bin/local-dev-tui.py` rather than a package, so we load it
+once here with `importlib.util` and expose the resulting module via the
+`tui` fixture. `sys.modules` registration is required so the module's
+`@dataclass` decorators can resolve `__module__` lookups."""
+
+from __future__ import annotations
+
+import importlib.util
+import sys
+from pathlib import Path
+
+import pytest
+
+REPO_ROOT = Path(__file__).resolve().parents[3]
+TUI_PATH = REPO_ROOT / "bin" / "local-dev-tui.py"
+
+
+@pytest.fixture(scope="session")
+def tui():
+    spec = importlib.util.spec_from_file_location("local_dev_tui", TUI_PATH)
+    assert spec is not None and spec.loader is not None
+    module = importlib.util.module_from_spec(spec)
+    sys.modules["local_dev_tui"] = module
+    spec.loader.exec_module(module)
+    return module
diff --git a/bin/local-dev/tests/test_local_dev_sh.sh b/bin/local-dev/tests/test_local_dev_sh.sh
new file mode 100755
index 0000000000..e467259b13
--- /dev/null
+++ b/bin/local-dev/tests/test_local_dev_sh.sh
@@ -0,0 +1,167 @@
+#!/usr/bin/env bash
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Smoke tests for bin/local-dev.sh. Run from the repo root:
+#   bash bin/local-dev/tests/test_local_dev_sh.sh
+# Exits 0 if every check passes, 1 otherwise.
+#
+# Kept deliberately small: bringing up the actual stack needs Docker /
+# sbt / a Mac and is out of scope for CI here. We cover the things that
+# regress quietly — script syntax, version-detection, the subcommand
+# dispatch, and graceful failure on garbage input.
+
+set -u
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
+SCRIPT="$REPO_ROOT/bin/local-dev.sh"
+
+PASS=0
+FAIL=0
+
+_pass() { printf "  \e[32m✓\e[0m %s\n" "$1"; PASS=$((PASS+1)); }
+_fail() {
+    printf "  \e[31m✗\e[0m %s\n" "$1"
+    [[ $# -ge 2 ]] && printf "      %s\n" "$2"
+    FAIL=$((FAIL+1))
+}
+
+# 1) bash -n: syntax check. Catches everything from typos to unbalanced
+#    heredocs without executing a line of the script.
+if bash -n "$SCRIPT" 2>/tmp/.local-dev-syntax.err; then
+    _pass "bash -n bin/local-dev.sh"
+else
+    _fail "bash -n bin/local-dev.sh" "$(cat /tmp/.local-dev-syntax.err)"
+fi
+rm -f /tmp/.local-dev-syntax.err
+
+# 2) `version` subcommand returns the same string we'd extract by hand
+#    from build.sbt. This is the single source of truth that all the
+#    dist / launcher / canary-jar paths in the script and the TUI build
+#    off of, so we'd rather catch a regression here.
+script_version=$("$SCRIPT" version 2>/dev/null | head -1 | tr -d '[:space:]')
+sbt_version=$(
+    grep -E '^[[:space:]]*ThisBuild[[:space:]]*/[[:space:]]*version[[:space:]]*:=[[:space:]]*"' \
+        "$REPO_ROOT/build.sbt" 2>/dev/null \
+        | head -1 \
+        | sed -E 's/.*"([^"]+)".*/\1/' \
+        | tr -d '[:space:]'
+)
+if [[ -n "$script_version" && "$script_version" == "$sbt_version" ]]; then
+    _pass "version matches build.sbt ($script_version)"
+else
+    _fail "version mismatch" "script=$script_version  build.sbt=$sbt_version"
+fi
+
+# 3) TEXERA_VERSION env var should override.
+override=$(TEXERA_VERSION="9.9.9-TEST" "$SCRIPT" version 2>/dev/null | head -1 | tr -d '[:space:]')
+if [[ "$override" == "9.9.9-TEST" ]]; then
+    _pass "TEXERA_VERSION env var overrides build.sbt"
+else
+    _fail "env override didn't take" "got: $override"
+fi
+
+# 4) `--help` prints usage.
+help_out=$("$SCRIPT" --help 2>&1 | head -20)
+if [[ "$help_out" == *"local-dev.sh"* && "$help_out" == *"Subcommands"* ]]; then
+    _pass "--help shows usage"
+else
+    _fail "--help didn't show usage" "$(echo "$help_out" | head -3)"
+fi
+
+# 5) An unknown service name routes through cmd_update_one and exits
+#    non-zero rather than silently doing nothing.
+out=$("$SCRIPT" definitely-not-a-real-service 2>&1)
+rc=$?
+if (( rc != 0 )) && [[ "$out" == *"unknown service"* || "$out" == *"Unknown service"* ]]; then
+    _pass "unknown service exits non-zero with clear error"
+else
+    _fail "unknown service didn't error properly" "rc=$rc out=$out"
+fi
+
+# 6) `start` with no service name fails immediately (zsh parameter expansion
+#    `${1:?...}` exits with the message).
+out=$("$SCRIPT" start 2>&1)
+rc=$?
+if (( rc != 0 )) && [[ "$out" == *"need service name"* ]]; then
+    _pass "start without arg refuses cleanly"
+else
+    _fail "start without arg should refuse" "rc=$rc out=$out"
+fi
+
+# 7) No-arg invocation must be non-interactive (= `status`). Previously the
+#    default launched the TUI, which made the script unsafe to drop into
+#    cron jobs or CI smoke tests. Anything that prints the banner without
+#    hanging counts.
+out=$("$SCRIPT" 2>&1 | head -5)
+rc=$?
+if (( rc == 0 )) && [[ "$out" == *"Texera Local Dev"* ]]; then
+    _pass "no-arg invocation prints status (non-interactive)"
+else
+    _fail "no-arg invocation didn't print status" "rc=$rc out=$(echo "$out" | head -1)"
+fi
+
+# 8) `-i` without a TTY must refuse cleanly, not crash or hang. We pipe
+#    stdin from /dev/null so the TTY check fires. Avoid piping into `head`
+#    here — that masks the script's exit code under zsh.
+out=$("$SCRIPT" -i </dev/null 2>&1)
+rc=$?
+if (( rc != 0 )) && [[ "$out" == *"requires a TTY"* || "$out" == *"requires Python"* ]]; then
+    _pass "-i refuses cleanly without a TTY or Python"
+else
+    _fail "-i didn't refuse cleanly" "rc=$rc out=$(echo "$out" | head -1)"
+fi
+
+# 9) Regression: when no Python on the candidate list has textual, the
+#    error message + install hint must actually print. The bug it
+#    guards against: zsh's `set -e` aborts the script silently when a
+#    command substitution's command exits non-zero (`var=$(returns 1)`),
+#    so the install-hint code below the assignment never ran. `script`
+#    allocates a pty so the TTY check passes and we get to the python
+#    check.
+#
+# `script` has two incompatible invocation styles:
+#   macOS BSD:  script -q OUT_FILE CMD ARGS...
+#   util-linux: script -qc "CMD ARGS..."  OUT_FILE
+# Probe with `script --version`: util-linux supports it, BSD doesn't.
+if command -v script >/dev/null 2>&1; then
+    bad_py="/usr/bin/python3"
+    if [[ -x "$bad_py" ]] && ! "$bad_py" -c "import textual" >/dev/null 2>&1; then
+        if script --version >/dev/null 2>&1; then
+            # util-linux dialect
+            out=$(env -i HOME="$HOME" PATH=/usr/bin:/bin TERM="${TERM:-xterm}" \
+                script -qc "$SCRIPT -i; echo __rc=\$?" /dev/null </dev/null 2>&1)
+        else
+            # macOS BSD dialect
+            out=$(env -i HOME="$HOME" PATH=/usr/bin:/bin TERM="${TERM:-xterm}" \
+                script -q /dev/null sh -c "$SCRIPT -i; echo __rc=\$?" </dev/null 2>&1)
+        fi
+        if [[ "$out" == *"requires Python"* && "$out" == *"install Python"* && "$out" == *"__rc=1"* ]]; then
+            _pass "-i with no textual prints the install hint (regression for zsh set -e bug)"
+        else
+            _fail "-i with no textual didn't print install hint" \
+                "got: $(echo "$out" | head -3 | tr '\n' '|')"
+        fi
+    else
+        _pass "skip: no textual-less python available to test against"
+    fi
+else
+    _pass "skip: 'script' not on PATH"
+fi
+
+printf "\n%d passed, %d failed\n" "$PASS" "$FAIL"
+(( FAIL == 0 ))
diff --git a/bin/local-dev/tests/test_local_dev_tui.py b/bin/local-dev/tests/test_local_dev_tui.py
new file mode 100644
index 0000000000..6d61531a32
--- /dev/null
+++ b/bin/local-dev/tests/test_local_dev_tui.py
@@ -0,0 +1,376 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Unit tests for `bin/local-dev-tui.py`.
+
+We cover the pure helper functions that don't need a running Textual app:
+version detection, docker state mapping, ANSI scrubbing, source-hash
+fingerprinting, and the service catalog's structural invariants. The
+interactive widgets are exercised manually."""
+
+from __future__ import annotations
+
+import os
+import textwrap
+from pathlib import Path
+
+import pytest
+
+
+# ─────────────────── texera_version() ───────────────────
+
+def test_texera_version_parses_build_sbt(tmp_path, monkeypatch, tui):
+    sbt = tmp_path / "build.sbt"
+    sbt.write_text(textwrap.dedent("""\
+        ThisBuild / scalaVersion := "2.13.18"
+        ThisBuild / version      := "9.9.9-FIXTURE"
+        ThisBuild / organization := "org.apache.texera"
+    """))
+    monkeypatch.setattr(tui, "REPO_ROOT", tmp_path)
+    monkeypatch.delenv("TEXERA_VERSION", raising=False)
+    assert tui.texera_version() == "9.9.9-FIXTURE"
+
+
+def test_texera_version_env_var_wins(tmp_path, monkeypatch, tui):
+    (tmp_path / "build.sbt").write_text('ThisBuild / version := "ignored-by-env"\n')
+    monkeypatch.setattr(tui, "REPO_ROOT", tmp_path)
+    monkeypatch.setenv("TEXERA_VERSION", "from-env")
+    assert tui.texera_version() == "from-env"
+
+
+def test_texera_version_raises_when_build_sbt_missing(tmp_path, monkeypatch, tui):
+    # No build.sbt in tmp_path. No fallback — must raise so a stale or
+    # broken checkout doesn't silently pick the wrong version.
+    monkeypatch.setattr(tui, "REPO_ROOT", tmp_path)
+    monkeypatch.delenv("TEXERA_VERSION", raising=False)
+    with pytest.raises(RuntimeError, match="build.sbt not found"):
+        tui.texera_version()
+
+
+def test_texera_version_raises_when_version_unparseable(tmp_path, monkeypatch, tui):
+    # build.sbt exists but the `ThisBuild / version := "…"` line isn't there.
+    (tmp_path / "build.sbt").write_text("name := \"texera\"\n")
+    monkeypatch.setattr(tui, "REPO_ROOT", tmp_path)
+    monkeypatch.delenv("TEXERA_VERSION", raising=False)
+    with pytest.raises(RuntimeError, match="could not find.*version"):
+        tui.texera_version()
+
+
+# ─────────────────── docker_state() ───────────────────
+
+@pytest.mark.parametrize("state,status,expected", [
+    ("running",     "Up 5 minutes (healthy)",          "running"),
+    ("running",     "Up 1s (health: starting)",        "starting"),
+    ("running",     "Up 30s (unhealthy)",              "unhealthy"),
+    ("running",     "Up 2 minutes",                    "running"),
+    ("exited",      "Exited (0) 4 minutes ago",        "exited"),
+    ("exited",      "Exited (137) 1 minute ago",       "failed"),
+    ("created",     "Created",                         "starting"),
+    ("restarting",  "Restarting (1) 2 seconds ago",    "starting"),
+    ("paused",      "Up 10 minutes (Paused)",          "starting"),
+    ("",            "",                                "stopped"),
+    ("dead",        "Dead",                            "stopped"),
+])
+def test_docker_state_mapping(state, status, expected, tui):
+    assert tui.docker_state(state, status) == expected
+
+
+# ─────────────────── _strip_ansi_motion() ───────────────────
+
+def test_strip_ansi_keeps_sgr_drops_motion(tui):
+    raw = "\x1b[32m✓\x1b[0m hello \x1b[2K\x1b[Hworld\r\r"
+    out = tui._strip_ansi_motion(raw)
+    # SGR (\e[32m, \e[0m) must survive so colours render
+    assert "\x1b[32m" in out
+    assert "\x1b[0m" in out
+    # cursor-positioning / erase / CR must be gone
+    assert "\x1b[2K" not in out
+    assert "\x1b[H" not in out
+    assert "\r" not in out
+
+
+def test_strip_ansi_idempotent(tui):
+    plain = "no escape codes here"
+    assert tui._strip_ansi_motion(plain) == plain
+
+
+# ─────────────────── source_hash() / is_dirty() ───────────────────
+
+def _seed_jvm_layout(repo: Path, svc_own_src: str, version: str = "9.9.9-T") -> None:
+    """Drop the directory layout source_hash() / is_dirty() walk.
+
+    Six shared `common/*/src` dirs + the per-service src dir + the dist
+    artifact jar at `target/<svc>-<version>/lib/...` so the artifact-mtime
+    lookup works."""
+    for d in ["dao", "config", "auth", "workflow-core", "workflow-operator", "pybuilder"]:
+        p = repo / "common" / d / "src"
+        p.mkdir(parents=True, exist_ok=True)
+        (p / "Stub.scala").write_text(f"// stub for common/{d}\n")
+    own = repo / svc_own_src
+    own.mkdir(parents=True, exist_ok=True)
+    (own / "Main.scala").write_text("object Main\n")
+
+
+def test_source_hash_stable_across_calls(tmp_path, monkeypatch, tui):
+    monkeypatch.setattr(tui, "REPO_ROOT", tmp_path)
+    _seed_jvm_layout(tmp_path, "config-service/src")
+    svc = tui.SERVICES_BY_NAME["config-service"]
+    a = tui.source_hash(svc)
+    b = tui.source_hash(svc)
+    assert a == b
+    assert len(a) == 40  # SHA-1 hex
+
+
+def test_source_hash_changes_with_content(tmp_path, monkeypatch, tui):
+    monkeypatch.setattr(tui, "REPO_ROOT", tmp_path)
+    _seed_jvm_layout(tmp_path, "config-service/src")
+    svc = tui.SERVICES_BY_NAME["config-service"]
+    before = tui.source_hash(svc)
+    (tmp_path / "config-service/src/Main.scala").write_text("object Main { def x = 1 }\n")
+    after = tui.source_hash(svc)
+    assert before != after
+
+
+def test_is_dirty_docker_always_clean(tui):
+    svc = tui.SERVICES_BY_NAME["postgres"]
+    assert tui.is_dirty(svc) is False
+
+
+def test_is_dirty_after_seed_then_edit(tmp_path, monkeypatch, tui):
+    monkeypatch.setattr(tui, "REPO_ROOT", tmp_path)
+    monkeypatch.setattr(tui, "BUILD_STAMP_DIR", tmp_path / "stamps")
+    (tmp_path / "stamps").mkdir()
+    _seed_jvm_layout(tmp_path, "config-service/src")
+
+    # Drop in a "jar" so the lazy seed path kicks in (it just needs to exist;
+    # the file's content isn't read by the dirty check, only the stamp's).
+    svc = tui.SERVICES_BY_NAME["config-service"]
+    jar = tmp_path / svc.artifact_jar
+    jar.parent.mkdir(parents=True, exist_ok=True)
+    jar.write_bytes(b"fake-jar-bytes")
+
+    # First call seeds the stamp and reports clean.
+    assert tui.is_dirty(svc) is False
+    stamp = tmp_path / "stamps" / svc.name
+    assert stamp.exists()
+    seeded_hash = stamp.read_text().strip()
+    assert len(seeded_hash) == 40
+
+    # Edit source → next dirty check sees a hash mismatch.
+    (tmp_path / "config-service/src/Main.scala").write_text("object Main { def y = 2 }\n")
+    assert tui.is_dirty(svc) is True
+
+
+def test_is_dirty_mtime_bump_without_content_change_stays_clean(tmp_path, monkeypatch, tui):
+    """Robustness against `git checkout` touching mtimes — the whole reason we
+    moved off pure-mtime detection. After seeding the stamp, simulating a
+    checkout (re-touching source files without changing content) must NOT
+    flash dirty."""
+    monkeypatch.setattr(tui, "REPO_ROOT", tmp_path)
+    monkeypatch.setattr(tui, "BUILD_STAMP_DIR", tmp_path / "stamps")
+    (tmp_path / "stamps").mkdir()
+    _seed_jvm_layout(tmp_path, "config-service/src")
+    svc = tui.SERVICES_BY_NAME["config-service"]
+    jar = tmp_path / svc.artifact_jar
+    jar.parent.mkdir(parents=True, exist_ok=True)
+    jar.touch()
+
+    tui.is_dirty(svc)  # seed
+
+    # Simulate `git checkout` by touching all source files to "now" without
+    # changing content.
+    for f in (tmp_path / "config-service/src").rglob("*"):
+        if f.is_file():
+            os.utime(f, None)
+
+    assert tui.is_dirty(svc) is False
+
+
+# ─────────────────── Service catalog invariants ───────────────────
+
+def test_service_names_unique(tui):
+    names = [s.name for s in tui.SERVICES]
+    assert len(names) == len(set(names)), "duplicate service name in catalog"
+
+
+def test_service_ports_unique(tui):
+    ports = [s.port for s in tui.SERVICES]
+    assert len(ports) == len(set(ports)), "two services claim the same port"
+
+
+def test_jvm_services_have_sbt_project_and_src(tui):
+    for s in tui.SERVICES:
+        if s.type == "jvm":
+            # sbt_project is optional: sibling services (e.g.
+            # computing-unit-master) ride another service's dist and have
+            # no separate sbt invocation. own_src + artifact_jar are still
+            # required so dirty-detection and start_one work.
+            assert s.own_src, f"{s.name} missing own_src"
+            assert s.artifact_jar, f"{s.name} missing artifact_jar"
+            assert tui.TEXERA_VERSION in s.artifact_jar, (
+                f"{s.name}'s jar path should embed the dynamic version, got {s.artifact_jar}"
+            )
+
+
+def test_jvm_siblings_share_artifact_jar(tui):
+    """computing-unit-master is a launcher shipped inside amber's dist; it must
+    point at the same canary jar as texera-web so dirty-state stays in sync."""
+    cum = tui.SERVICES_BY_NAME["computing-unit-master"]
+    web = tui.SERVICES_BY_NAME["texera-web"]
+    assert cum.sbt_project is None, "computing-unit-master must not own an sbt project"
+    assert cum.artifact_jar == web.artifact_jar
+    assert cum.own_src == web.own_src
+
+
+def test_docker_services_have_no_jar(tui):
+    for s in tui.SERVICES:
+        if s.type == "docker":
+            assert s.artifact_jar is None
+
+
+def test_watch_types_constant(tui):
+    assert tui.WATCH_TYPES == {"yarn", "bun"}
+    # Every watch-type service must show up in the catalog
+    for t in tui.WATCH_TYPES:
+        assert any(s.type == t for s in tui.SERVICES), f"no service of type {t}"
+
+
+# ─────────────────── CommandHistory navigation ───────────────────
+
+def test_command_history_back_and_forward(tmp_path, tui):
+    h = tui.CommandHistory(history_file=tmp_path / "h")
+    for cmd in ["u", "d", "config-service"]:
+        h.push(cmd)
+    assert h._history == ["u", "d", "config-service"]
+
+    # ↑ from a fresh draft walks back from newest
+    assert h.back("draft-typing") == "config-service"
+    assert h.back("config-service") == "d"
+    assert h.back("d") == "u"
+    # At oldest, further ↑ returns None (caller keeps current value)
+    assert h.back("u") is None
+
+    # ↓ walks forward; once past newest it restores the draft
+    assert h.forward() == "d"
+    assert h.forward() == "config-service"
+    assert h.forward() == "draft-typing"
+    # At the live draft, ↓ is a no-op
+    assert h.forward() is None
+
+
+def test_command_history_dedup_consecutive(tmp_path, tui):
+    h = tui.CommandHistory(history_file=tmp_path / "h")
+    h.push("u")
+    h.push("u")            # same as previous → not appended
+    h.push("d")
+    h.push("u")            # not consecutive, gets appended
+    assert h._history == ["u", "d", "u"]
+
+
+def test_command_history_persists(tmp_path, tui):
+    f = tmp_path / "h"
+    a = tui.CommandHistory(history_file=f)
+    a.push("u")
+    a.push("d")
+
+    b = tui.CommandHistory(history_file=f)
+    assert b._history == ["u", "d"]
+
+
+def test_command_history_max_size(tmp_path, tui):
+    h = tui.CommandHistory(history_file=tmp_path / "h", max_size=3)
+    for cmd in ["a", "b", "c", "d", "e"]:
+        h.push(cmd)
+    reloaded = tui.CommandHistory(history_file=tmp_path / "h", max_size=3)
+    # On-disk cap: only the most recent 3 survive a reload.
+    assert reloaded._history == ["c", "d", "e"]
+
+
+# ─────────────────── build.sbt dependency parsing ───────────────────
+
+def test_sbt_graph_parsed_from_real_build_sbt(tui):
+    """The parser must extract at least the well-known subset of projects
+    (DAO, Config, Auth, the seven JVM services) and their direct deps from
+    the live repo's build.sbt. If any of these go missing we've drifted
+    from sbt's view of the world and dirty-detection is silently wrong."""
+    g = tui._SBT_GRAPH
+    assert "DAO" in g and "Config" in g and "Auth" in g
+    assert "ConfigService" in g
+    assert "WorkflowExecutionService" in g
+    # Auth depends on DAO + Config — anchor invariant from build.sbt.
+    assert set(g["Auth"]["deps"]) >= {"DAO", "Config"}
+    # WorkflowCore depends on DAO/Config/PyBuilder.
+    assert set(g["WorkflowCore"]["deps"]) >= {"DAO", "Config", "PyBuilder"}
+
+
+def test_sbt_transitive_closure_skips_unrelated(tui):
+    """config-service should NOT pull workflow-operator/workflow-core into
+    its dep set. That's the whole point of using build.sbt as source of
+    truth instead of a shared common/* list."""
+    dirs = tui._transitive_src_dirs("ConfigService", tui._SBT_GRAPH)
+    assert "common/workflow-operator/src" not in dirs
+    assert "common/workflow-core/src" not in dirs
+    # Should still include its own + Auth's reach (DAO/Config).
+    assert "config-service/src" in dirs
+    assert "common/auth/src" in dirs
+    assert "common/dao/src" in dirs
+    assert "common/config/src" in dirs
+
+
+def test_sbt_transitive_for_amber_includes_workflow_chain(tui):
+    """texera-web / computing-unit-master ride WorkflowExecutionService;
+    its closure must include WorkflowOperator → WorkflowCore → DAO/Config/PyBuilder."""
+    dirs = tui._transitive_src_dirs("WorkflowExecutionService", tui._SBT_GRAPH)
+    expected = {
+        "amber/src",
+        "common/workflow-operator/src",
+        "common/workflow-core/src",
+        "common/dao/src",
+        "common/config/src",
+        "common/pybuilder/src",
+        "common/auth/src",
+    }
+    assert expected <= set(dirs), f"missing: {expected - set(dirs)}"
+
+
+def test_sbt_unknown_project_raises(tui):
+    """Asking the graph for an unknown sbt project name raises rather
+    than silently falling back to a hardcoded list — that fallback would
+    mask a real build.sbt drift / typo."""
+    with pytest.raises(RuntimeError, match="not found in build.sbt graph"):
+        tui._transitive_src_dirs("NoSuchProject", tui._SBT_GRAPH)
+
+
+def test_sbt_empty_graph_raises(tui):
+    """A parse that returned no projects (very old / mangled build.sbt)
+    must raise. Silently using the prior pre-parse list would let dirty
+    detection report wrong results without anyone noticing."""
+    with pytest.raises(RuntimeError, match="graph is empty"):
+        tui._transitive_src_dirs("ConfigService", {})
+
+
+def test_sbt_test_scope_deps_ignored(tui):
+    """`X % "test->test"` is test-only — must not pollute the runtime dep
+    list (services would otherwise rebuild on test-only edits, defeating
+    the build skip)."""
+    # Auth has a `.dependsOn(DAO % "test->test")` next to its main
+    # `.dependsOn(DAO, Config)`. The test-scope reference should not
+    # appear as a second entry.
+    auth_deps = tui._SBT_GRAPH["Auth"]["deps"]
+    # DAO appears once (from the main-scope dependsOn) — not twice from
+    # the test-scope one.
+    assert auth_deps.count("DAO") == 1