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/07/05 07:08:53 UTC

(texera) 01/01: refactor(local-dev): unify the CLI surface for agent use (#6138)

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-6138-7d21555bceec552a307c0d4572ff0ee8cd477f94
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 37354af00265b4a0e521d2fb46b94efdf17b807f
Author: Yicong Huang <17...@users.noreply.github.com>
AuthorDate: Sun Jul 5 00:08:31 2026 -0700

    refactor(local-dev): unify the CLI surface for agent use (#6138)
    
    ### What changes were proposed in this PR?
    
    The local-dev CLI grew unevenly, which makes it awkward to drive from
    agent CLIs and scripts. This PR unifies the surface:
    
    1. **`--json` on every one-shot subcommand.** `status`/`up`/`down`
    already had it; `auto` and `version` now accept it too. The convention
    is unchanged: human progress goes to stderr, one machine-readable status
    JSON object goes to stdout.
    2. **`up <service>` / `down <service>` replace `start`/`stop`.** The
    single-service `up` uses the same build knob as the full-stack form:
    rebuild + bounce only if the service's source changed since the last
    build (default), `--build` forces it, `--skip-build` never builds. It
    also handles the amber-sibling dist sharing (`texera-web` /
    `computing-unit-master` bounce together) and runs the jOOQ
    postgres-readiness check before any sbt build (#6007). The old
    `start`/`stop` and the bare `<service>` shorthand now refuse with a
    pointer to the new spelling. In the TUI, `u <svc>`, `d <svc>`, `s
    <svc>`, and a bare `<svc>` map onto the new forms.
    3. **`--skip-build` replaces `--no-build`** (the old flag refuses with a
    rename hint).
    4. **Every `--json` payload carries `elapsed_seconds`** — the command's
    total wall-clock runtime, so agents can log how long an `up`/`auto` took
    without wrapping the call in their own timer.
    
    One behavioral fix that falls out of (2): a full-stack `up` with no
    selector re-decides (and resets) the persisted deploy-source pointer,
    but `up <service>` is a scoped operation, so the startup peek now leaves
    the pointer alone when a positional service argument is present — a
    single-service bounce on a deployed PR worktree no longer silently
    re-points the deployment at the main checkout.
    
    ### Any related issues, documentation, discussions?
    
    Closes #6137. The `--help` header and the `status` footer hints are
    updated in place; `bin/local-dev/README.md` needed no changes.
    
    ### How was this PR tested?
    
    - `bash bin/local-dev/tests/test_local_dev_sh.sh` — 45 passed, 0 failed.
    Updated/added cases: unknown subcommand and `up <unknown-service>`
    errors, `start`/`stop` refuse with the new spelling, `--no-build`
    refuses with the rename hint, full-stack-only knobs (`--fresh`,
    `--skip`, selectors) are rejected in the single-service form, at most
    one positional service, `cmd_auto` wires `--json`, `version --json` and
    `status --json` carry `elapsed_seconds`, and the startup peek guards the
    deploy-pointer reset (isolated `TEXERA_LOCAL_DEV_DIR` so the flag-error
    `up` invocations never touch a real deployment's state).
    - `pytest bin/local-dev/tests/test_local_dev_tui.py` — 43 passed.
    - Manual smoke on a live stack: `version --json`, `status --json` (14/14
    running, `elapsed_seconds` present), `--help` renders through the new
    end-marker `sed` range.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Claude Fable 5)
    
    Co-authored-by: Claude Fable 5 <no...@anthropic.com>
---
 bin/local-dev/main.sh                    | 330 ++++++++++++++++++++++---------
 bin/local-dev/tests/test_local_dev_sh.sh | 105 ++++++++--
 bin/local-dev/tui.py                     |  25 +--
 3 files changed, 336 insertions(+), 124 deletions(-)

diff --git a/bin/local-dev/main.sh b/bin/local-dev/main.sh
index e1c884699b..1844dfd7cb 100755
--- a/bin/local-dev/main.sh
+++ b/bin/local-dev/main.sh
@@ -33,42 +33,54 @@
 #                                             --json, print one machine-readable
 #                                             JSON object (no table) and exit 0
 #                                             iff every service is running — the
-#                                             contract for agents/scripts.
-#   bin/local-dev.sh up   [--fresh|--build|--no-build] [--skip=svc1,svc2] [--json]
-#                         [--worktree=PATH | --branch=NAME]
+#                                             contract for agents/scripts. Every
+#                                             --json payload (all subcommands)
+#                                             carries elapsed_seconds: the
+#                                             command's total wall-clock runtime.
+#   bin/local-dev.sh up [service]
+#                       [--fresh|--build|--skip-build] [--skip=svc1,svc2] [--json]
+#                       [--worktree=PATH | --branch=NAME]
 #                                             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. --json
-#                                             sends progress to stderr and the final
-#                                             status JSON to stdout.
-#                                             DEPLOY SOURCE: with no selector the stack
-#                                             is built/run from THIS checkout. Point it
-#                                             at a sibling git worktree with
+#                                             --fresh runs `sbt clean dist`.
+#                                             --skip-build skips the build step
+#                                             entirely. --json sends progress to
+#                                             stderr and the final status JSON to
+#                                             stdout.
+#                                             With a service name, act on just that
+#                                             service: rebuild it if its source
+#                                             changed (--build forces, --skip-build
+#                                             skips), then start/bounce it. The
+#                                             single-service form follows the active
+#                                             deployment; --fresh / --skip / the
+#                                             deploy selectors are full-stack only.
+#                                             DEPLOY SOURCE: with no selector a
+#                                             full-stack `up` deploys THIS checkout.
+#                                             Point it at a sibling git worktree with
 #                                             --worktree=PATH or --branch=NAME (the
 #                                             worktree that has NAME checked out) to
 #                                             deploy a PR branch without disturbing the
 #                                             main checkout. The choice is persisted, so
-#                                             later status / down / logs / <svc> / auto
-#                                             all act on it (run a plain `up` to return
-#                                             to this checkout). local-dev.sh itself
-#                                             always runs from this checkout — so if the
-#                                             target branch modifies bin/local-dev/**,
-#                                             those tooling changes are NOT in effect;
-#                                             checkout that branch and run its own
-#                                             local-dev.sh instead (a warning is printed
-#                                             when such drift is detected).
-#   bin/local-dev.sh down [--skip=svc1,svc2] [--json]
-#                                             stop every non-skipped service
-#                                             (--json: summary JSON on stdout).
-#   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).
+#                                             later status / down / logs / up <svc> /
+#                                             auto all act on it (run a plain full-stack
+#                                             `up` to return to this checkout).
+#                                             local-dev.sh itself always runs from this
+#                                             checkout — so if the target branch
+#                                             modifies bin/local-dev/**, those tooling
+#                                             changes are NOT in effect; checkout that
+#                                             branch and run its own local-dev.sh
+#                                             instead (a warning is printed when such
+#                                             drift is detected).
+#   bin/local-dev.sh down [service] [--skip=svc1,svc2] [--json]
+#                                             stop every non-skipped service, or just
+#                                             <service> (--json: summary JSON on
+#                                             stdout).
+#   bin/local-dev.sh auto [--skip=svc1,svc2] [--json]
+#                                             rebuild + bounce only the services whose
+#                                             source changed since the last build.
 #   bin/local-dev.sh logs <service>           tail this service's log.
+#   bin/local-dev.sh version [--json]         print the texera version.
 #   bin/local-dev.sh w | watch [interval]     Hands-off monitor: redraw the
 #                                             dashboard every <interval>s
 #                                             (default 2). No prompt; Ctrl-C
@@ -111,7 +123,7 @@ set -euo pipefail
 #   bin/local-dev.sh up                    deploy from this (self) checkout again
 #
 # The selection is persisted to $STATE_DIR/deploy-source, so every later
-# command (status, logs, down, single-service rebuild, auto) reads it back and
+# command (status, logs, down, single-service up/down, auto) reads it back and
 # acts on the SAME deployment. REPO_ROOT below is pointed at the source tree, which
 # is what the rest of the script keys every build/run/git operation off of —
 # only the handful of tooling-file paths are pinned to SELF_ROOT.
@@ -167,7 +179,7 @@ _worktree_for_branch() {
 }
 
 # Deploy source resolution:
-#   • Read-only commands (status / down / logs / <svc>) follow whatever the last
+#   • Read-only commands (status / down / logs / up <svc>) follow whatever the last
 #     up/auto deployed — read it back from the persisted pointer. A stale
 #     pointer (worktree removed/moved) is dropped silently.
 #   • up / auto re-decide the deployment: --worktree=PATH / --branch=NAME selects
@@ -188,6 +200,14 @@ fi
 # dep graph key off the source tree), so peek the args here — cmd_up / cmd_auto
 # re-see and no-op the selectors in their own parse loops.
 if [[ "${1:-}" == "up" || "${1:-}" == "auto" ]]; then
+    # A positional service arg makes `up` a single-service operation. That form
+    # follows the active deployment (like status / logs) rather than
+    # re-deciding it, so leave the persisted pointer alone.
+    _has_svc_arg=false
+    for _arg in "${@:2}"; do
+        case "$_arg" in -*) ;; *) _has_svc_arg=true ;; esac
+    done
+  if ! $_has_svc_arg; then
     # `up` with no selector resets to this checkout; `auto` keeps the pointer
     # value already resolved above.
     [[ "${1:-}" == "up" ]] && SOURCE_ROOT="$SELF_ROOT"
@@ -221,6 +241,7 @@ if [[ "${1:-}" == "up" || "${1:-}" == "auto" ]]; then
     else
         printf '%s\n' "$SOURCE_ROOT" > "$DEPLOY_SOURCE_FILE"
     fi
+  fi
 fi
 
 REPO_ROOT="$SOURCE_ROOT"
@@ -1705,7 +1726,7 @@ build_one_jvm() {
         # 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
+        # Don't clear the phase yet — the caller (cmd_up_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.
@@ -2077,8 +2098,10 @@ emit_status_json() {
         rows+=$(printf '{"service":"%s","port":%s,"type":"%s","pid":%s,"state":"%s"}' \
             "$svc" "$port" "$type" "$pid" "$state")
     done
-    printf '{"branch":"%s","sha":"%s","worktree":"%s","source":"%s","running":%d,"total":%d,"services":[%s]}\n' \
-        "$branch" "$sha" "$worktree" "$REPO_ROOT" "$n_running" "$n_total" "$rows"
+    # $SECONDS counts from script start, so this is the command's total
+    # wall-clock runtime — every --json payload carries it.
+    printf '{"branch":"%s","sha":"%s","worktree":"%s","source":"%s","running":%d,"total":%d,"elapsed_seconds":%d,"services":[%s]}\n' \
+        "$branch" "$sha" "$worktree" "$REPO_ROOT" "$n_running" "$n_total" "$SECONDS" "$rows"
     (( n_running == n_total ))
 }
 
@@ -2171,9 +2194,8 @@ cmd_status() {
     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 up   <svc>${RESET}    ${DIM}# bring one service up (rebuilds only if its source changed)${RESET}\n"
+    printf "    ${BOLD}bin/local-dev.sh down <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"
@@ -2185,17 +2207,23 @@ cmd_up() {
     FRESH=false
     BUILD=auto       # auto (skip if no source change) | force | no
     JSON_OUT=false
+    local one_svc="" selector_seen=false
     while [[ $# -gt 0 ]]; do
         case "$1" in
             --skip=*)   SKIP_LIST="${1#--skip=}" ;;
             --fresh)    FRESH=true; BUILD=force ;;
             --build)    BUILD=force ;;
-            --no-build) BUILD=no ;;
+            --skip-build) BUILD=no ;;
+            --no-build) tui_err "--no-build was renamed to --skip-build" >&2; exit 2 ;;
             --json)     JSON_OUT=true ;;
             # Deploy-target selectors are resolved at startup (they must precede
             # the build.sbt parse); accept and ignore them here.
-            --worktree=*|--branch=*) ;;
-            *) tui_err "unknown flag: $1" >&2; exit 2 ;;
+            --worktree=*|--branch=*) selector_seen=true ;;
+            -*) tui_err "unknown flag: $1" >&2; exit 2 ;;
+            *)  if [[ -n "$one_svc" ]]; then
+                    tui_err "up takes at most one service (got '$one_svc' and '$1')" >&2; exit 2
+                fi
+                one_svc="$1" ;;
         esac
         shift
     done
@@ -2206,6 +2234,20 @@ cmd_up() {
     # so a non-interactive caller still sees progress live on the side stream.
     if $JSON_OUT; then exec 3>&1 1>&2; fi
 
+    # Single-service form: `up <svc>` acts on the active deployment (the
+    # startup peek skipped the pointer reset). Full-stack-only knobs are
+    # rejected rather than silently ignored.
+    if [[ -n "$one_svc" ]]; then
+        if [[ -n "$SKIP_LIST" ]] || $FRESH || $selector_seen; then
+            tui_err "--skip / --fresh / --worktree / --branch apply to the full-stack \`up\` only" >&2
+            exit 2
+        fi
+        local ec=0
+        cmd_up_one "$one_svc" || ec=$?
+        $JSON_OUT && { emit_status_json >&3 || true; }
+        return $ec
+    fi
+
     local n_skip=0
     [[ -n "$SKIP_LIST" ]] && n_skip=$(echo "$SKIP_LIST" | tr ',' '\n' | wc -l | tr -d ' ')
     local skip_label="none"
@@ -2250,7 +2292,7 @@ cmd_up() {
             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"
+            printf "\n  ${BOLD}${GREEN}${SYM_OK} nothing to do${RESET}  ${DIM}(use \`up --build\` to force a rebuild, or \`up <svc>\` to bounce just one)${RESET}\n\n"
             $JSON_OUT && { emit_status_json >&3 || true; }
             return 0
         fi
@@ -2288,7 +2330,7 @@ cmd_up() {
         refresh_node_deps
     else
         tui_section "Build"
-        tui_skip "build: --no-build (using existing artifacts)"
+        tui_skip "build: --skip-build (using existing artifacts)"
     fi
 
     # ▸ Services -- native services only; docker rows were handled by the
@@ -2361,15 +2403,19 @@ cmd_up() {
 # Clean services are left alone — no pre-bounce, no restart.
 cmd_auto() {
     SKIP_LIST=""
+    JSON_OUT=false
     while [[ $# -gt 0 ]]; do
         case "$1" in
             --skip=*) SKIP_LIST="${1#--skip=}" ;;
+            --json)   JSON_OUT=true ;;
             # Deploy-target selectors are resolved at startup; accept here.
             --worktree=*|--branch=*) ;;
             *) tui_err "unknown flag: $1" >&2; exit 2 ;;
         esac
         shift
     done
+    # See cmd_up: human progress to stderr, JSON summary on real stdout (fd 3).
+    if $JSON_OUT; then exec 3>&1 1>&2; fi
 
     tui_banner "Texera Local Dev — auto bounce" \
         "rebuild + bounce only what changed since last build"
@@ -2408,6 +2454,7 @@ cmd_auto() {
     if (( ${#dirty_jvms[@]} == 0 )) && ! $need_yarn && ! $need_bun; then
         tui_ok "everything up-to-date — nothing to bounce"
         printf "\n"
+        $JSON_OUT && { emit_status_json >&3 || true; }
         return 0
     fi
 
@@ -2437,6 +2484,7 @@ cmd_auto() {
                 phase_clear "$_s"
             done
             tui_err "sbt dist failed  ${DIM}(tail -f $log)${RESET}"
+            $JSON_OUT && { emit_status_json >&3 || true; }
             return 1
         fi
         tui_ok "sbt: dist done"
@@ -2589,22 +2637,46 @@ cmd_auto() {
     printf "\n"
     printf "  ${BOLD}${GREEN}${SYM_OK} auto bounce done${RESET}: %d rebuilt, %d bounced\n\n" \
         "$n_rebuilt" "$n_bounced"
-    cmd_status
+    if $JSON_OUT; then emit_status_json >&3 || true; else cmd_status; fi
 }
 
 cmd_down() {
     SKIP_LIST=""
     JSON_OUT=false
+    local one_svc=""
     while [[ $# -gt 0 ]]; do
         case "$1" in
             --skip=*) SKIP_LIST="${1#--skip=}" ;;
             --json)   JSON_OUT=true ;;
-            *) tui_err "unknown flag: $1" >&2; exit 2 ;;
+            -*) tui_err "unknown flag: $1" >&2; exit 2 ;;
+            *)  if [[ -n "$one_svc" ]]; then
+                    tui_err "down takes at most one service (got '$one_svc' and '$1')" >&2; exit 2
+                fi
+                one_svc="$1" ;;
         esac
         shift
     done
     # See cmd_up: human progress to stderr, JSON summary on real stdout (fd 3).
     if $JSON_OUT; then exec 3>&1 1>&2; fi
+
+    # Single-service form: stop just that one (stop_one handles docker via
+    # `compose stop`, so the shared network/volumes stay up for the others).
+    if [[ -n "$one_svc" ]]; then
+        if [[ -n "$SKIP_LIST" ]]; then
+            tui_err "--skip applies to the full-stack \`down\` only" >&2; exit 2
+        fi
+        if ! amap_has SVC_TYPE "$one_svc"; then
+            tui_err "unknown service: ${BOLD}$one_svc${RESET}"
+            printf "  ${DIM}Known:${RESET} ${SERVICES[*]}\n"
+            exit 1
+        fi
+        tui_banner "Texera Local Dev — stopping ${one_svc}" "single service"
+        stop_one "$one_svc"
+        printf "\n"
+        $JSON_OUT && { emit_status_json >&3 || true; }
+        return 0
+    fi
+
     tui_banner "Texera Local Dev — stopping stack" "skip=${SKIP_LIST:-none}"
     tui_section "Stopping (reverse order)"
     local svc=""
@@ -2635,66 +2707,124 @@ cmd_down() {
     return 0
 }
 
-cmd_update_one() {
+# Single-service `up`: make one service current and running, honoring the
+# same BUILD knob as the full-stack form:
+#   auto  (default) — rebuild + bounce only if its source changed since the
+#                     last build; otherwise just start it if stopped
+#   force (--build) — rebuild + bounce unconditionally
+#   no    (--skip-build) — start with the existing artifacts, never build
+cmd_up_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
+        return 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
+            # No source to build — --build degrades to a restart, everything
+            # else to an idempotent `compose up -d`.
+            if [[ "$BUILD" == "force" ]]; then
+                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"; return 1; }
+            else
+                tui_banner "Starting ${svc}" "docker compose up -d $svc"
+                start_one "$svc" || return 1
+            fi
+            # compose returns before the container is healthy; its state is
+            # the docker poller's job, not a port wait here.
+            return 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
+            if [[ -n "$(svc_running_pid "$svc")" ]]; then
+                tui_warn "frontend uses ng's watch -- source changes hot-reload automatically."
+                printf "  ${DIM}For dep changes: bin/local-dev.sh down frontend && bin/local-dev.sh up frontend${RESET}\n"
+                return 0
+            fi
+            tui_banner "Starting ${svc}" "yarn start (ng serve)"
+            if [[ "$BUILD" != "no" ]] && needs_yarn_install; then
+                tui_section "Deps"
+                local _ylog="$LOG_DIR/yarn-install.log"
+                tui_run_with_spinner "$_ylog" "yarn install  ${DIM}(log: $_ylog)${RESET}" \
+                    bash -c "cd frontend && yarn install" \
+                    || { tui_err "yarn install failed  ${DIM}(tail -f $_ylog)${RESET}"; return 1; }
+            fi
+            start_one "$svc" || return 1
             ;;
         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"
+            if [[ "$BUILD" == "force" ]]; then
+                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"
             else
-                tui_banner "Updating ${svc}" "bounce only (shares dist with its sibling)"
+                if [[ -n "$(svc_running_pid "$svc")" ]]; then
+                    tui_ok "$svc: already running ${DIM}(bun --watch picks up source changes)${RESET}"
+                    return 0
+                fi
+                tui_banner "Starting ${svc}" "bun run dev"
+                if [[ "$BUILD" != "no" ]] && needs_bun_install; then
+                    tui_section "Deps"
+                    local _blog="$LOG_DIR/bun-install.log"
+                    tui_run_with_spinner "$_blog" "bun install  ${DIM}(log: $_blog)${RESET}" \
+                        bash -c "cd agent-service && bun install" \
+                        || { tui_err "bun install failed  ${DIM}(tail -f $_blog)${RESET}"; return 1; }
+                fi
+                start_one "$svc" || return 1
             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" ;;
+            ;;
+        jvm)
+            local rebuild=false
+            case "$BUILD" in
+                force) rebuild=true ;;
+                auto)  svc_src_changed "$svc" && rebuild=true ;;
             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"
+            if $rebuild; then
+                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"
+                # jOOQ codegen connects to postgres at sbt-compile time (#6007).
+                ensure_postgres_for_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
+            else
+                if [[ -n "$(svc_running_pid "$svc")" ]]; then
+                    tui_ok "$svc: already running and up-to-date ${DIM}(PID $(svc_running_pid "$svc"))${RESET}"
+                    return 0
+                fi
+                tui_banner "Starting ${svc}" "existing artifacts (no source change)"
+                start_one "$svc" || return 1
             fi
             ;;
     esac
@@ -2706,11 +2836,19 @@ cmd_update_one() {
     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
+        return 1
     fi
     printf "\n"
 }
 
+cmd_version() {
+    case "${1:-}" in
+        --json) printf '{"version":"%s","elapsed_seconds":%d}\n' "$TEXERA_VERSION" "$SECONDS" ;;
+        "")     printf "%s\n" "$TEXERA_VERSION" ;;
+        *)      tui_err "unknown flag: $1" >&2; exit 2 ;;
+    esac
+}
+
 cmd_logs() {
     local svc="${1:?usage: bin/local-dev.sh logs <service>}"
     if ! amap_has SVC_TYPE "$svc"; then
@@ -2781,11 +2919,10 @@ tui_render_dashboard() {
     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}u${RESET}p ${DIM}[<svc>]${RESET} · "
+    printf "${BOLD}d${RESET}own ${DIM}[<svc>]${RESET} · "
     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"
 }
 
@@ -2910,11 +3047,12 @@ case "${1:-}" in
     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}" ;;
+    start|stop)       tui_err "\`$1\` was removed — use \`bin/local-dev.sh up <service>\` / \`down <service>\`" >&2
+                      exit 2 ;;
     logs)             shift; cmd_logs "${1:-}" ;;
     w|watch)          shift; cmd_watch "${1:-2}" ;;
-    version)          printf "%s\n" "$TEXERA_VERSION" ;;
-    -h|--help)        sed -n '18,92p' "$0" ;;
-    *)                cmd_update_one "$1" ;;
+    version)          shift; cmd_version "$@" ;;
+    -h|--help)        sed -n '18,/^# Logs and pid book-keeping/p' "$0" ;;
+    *)                tui_err "unknown subcommand: ${BOLD}$1${RESET}  ${DIM}(for one service: up/down $1)${RESET}" >&2
+                      exit 2 ;;
 esac
diff --git a/bin/local-dev/tests/test_local_dev_sh.sh b/bin/local-dev/tests/test_local_dev_sh.sh
index 486a982ee1..63320386a7 100755
--- a/bin/local-dev/tests/test_local_dev_sh.sh
+++ b/bin/local-dev/tests/test_local_dev_sh.sh
@@ -94,26 +94,69 @@ 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.
+# 5) An unknown subcommand exits non-zero with a clear error rather than
+#    silently doing nothing, and the single-service form rejects unknown
+#    service names the same way.
 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"
+if (( rc != 0 )) && [[ "$out" == *"unknown subcommand"* ]]; then
+    _pass "unknown subcommand exits non-zero with clear error"
 else
-    _fail "unknown service didn't error properly" "rc=$rc out=$out"
+    _fail "unknown subcommand 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)
+# Isolated state dir: a full-stack `up` (re)persists the deploy pointer during
+# startup even when the flag parse later fails, so never point these at the
+# real deployment's state.
+_up_state=$(mktemp -d 2>/dev/null || mktemp -d -t ldup)
+out=$(TEXERA_LOCAL_DEV_DIR="$_up_state" "$SCRIPT" up definitely-not-a-real-service 2>&1)
 rc=$?
-if (( rc != 0 )) && [[ "$out" == *"need service name"* ]]; then
-    _pass "start without arg refuses cleanly"
+if (( rc != 0 )) && [[ "$out" == *"unknown service"* ]]; then
+    _pass "up <unknown-service> exits non-zero with clear error"
 else
-    _fail "start without arg should refuse" "rc=$rc out=$out"
+    _fail "up <unknown-service> didn't error properly" "rc=$rc out=$out"
 fi
 
+# 6) `start`/`stop` were replaced by `up <service>` / `down <service>` — they
+#    must refuse with a pointer to the new spelling, not silently no-op.
+for verb in start stop; do
+    out=$("$SCRIPT" "$verb" texera-web 2>&1)
+    rc=$?
+    if (( rc != 0 )) && [[ "$out" == *"up <service>"* ]]; then
+        _pass "$verb points to up/down <service>"
+    else
+        _fail "$verb should refuse with the new spelling" "rc=$rc out=$out"
+    fi
+done
+
+# 6b) Flag hygiene on the new surface: --no-build is gone (renamed), the
+#     full-stack-only knobs are rejected in the single-service form, and at
+#     most one service is accepted.
+out=$(TEXERA_LOCAL_DEV_DIR="$_up_state" "$SCRIPT" up --no-build 2>&1); rc=$?
+if (( rc == 2 )) && [[ "$out" == *"--skip-build"* ]]; then
+    _pass "up --no-build refuses and names --skip-build"
+else
+    _fail "up --no-build not rejected with rename hint" "rc=$rc out=$out"
+fi
+out=$(TEXERA_LOCAL_DEV_DIR="$_up_state" "$SCRIPT" up texera-web --fresh 2>&1); rc=$?
+if (( rc == 2 )) && [[ "$out" == *"full-stack"* ]]; then
+    _pass "up <svc> --fresh refuses (full-stack only)"
+else
+    _fail "up <svc> --fresh not rejected" "rc=$rc out=$out"
+fi
+out=$(TEXERA_LOCAL_DEV_DIR="$_up_state" "$SCRIPT" up texera-web frontend 2>&1); rc=$?
+if (( rc == 2 )) && [[ "$out" == *"at most one service"* ]]; then
+    _pass "up refuses two positional services"
+else
+    _fail "up accepted two services" "rc=$rc out=$out"
+fi
+out=$(TEXERA_LOCAL_DEV_DIR="$_up_state" "$SCRIPT" down texera-web --skip=frontend 2>&1); rc=$?
+if (( rc == 2 )) && [[ "$out" == *"full-stack"* ]]; then
+    _pass "down <svc> --skip refuses (full-stack only)"
+else
+    _fail "down <svc> --skip not rejected" "rc=$rc out=$out"
+fi
+rm -rf "$_up_state"
+
 # 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
@@ -246,6 +289,7 @@ assert 0 <= d["running"] <= d["total"], "running out of range"
 names = {s["service"] for s in d["services"]}
 need = {"texera-web", "frontend", "postgres"}
 assert need <= names, f"missing services: {need - names}"
+assert isinstance(d["elapsed_seconds"], int) and d["elapsed_seconds"] >= 0, "elapsed_seconds missing/bad"
 for s in d["services"]:
     assert isinstance(s["port"], int), "port not int"
     assert s["type"] in {"jvm", "docker", "yarn", "bun"}, "bad service type"
@@ -301,10 +345,10 @@ else
     _fail "tui_spinner missing non-TTY heartbeat loop"
 fi
 
-# 17) `up` and `down` accept --json (route the human stream to stderr, emit the
-#     JSON summary on stdout). Structural guard — invoking them for real would
-#     build/stop the stack, out of scope here.
-for fn in cmd_up cmd_down; do
+# 17) `up`, `down`, and `auto` accept --json (route the human stream to stderr,
+#     emit the JSON summary on stdout). Structural guard — invoking them for
+#     real would build/stop the stack, out of scope here.
+for fn in cmd_up cmd_down cmd_auto; do
     body=$(awk -v fn="$fn" '$0 ~ "^" fn "\\(\\)" {f=1} f{print} f&&/^}/{exit}' \
         "$REPO_ROOT/bin/local-dev/main.sh")
     if [[ "$body" == *"--json"* && "$body" == *"emit_status_json"* ]]; then
@@ -314,6 +358,35 @@ for fn in cmd_up cmd_down; do
     fi
 done
 
+# 17b) `version --json` is machine-readable and carries elapsed_seconds — the
+#      runtime field is part of every --json payload, not just status.
+if command -v python3 >/dev/null 2>&1; then
+    out=$("$SCRIPT" version --json 2>/dev/null)
+    if printf '%s' "$out" | python3 -c '
+import sys, json
+d = json.load(sys.stdin)
+assert d["version"], "version empty"
+assert isinstance(d["elapsed_seconds"], int) and d["elapsed_seconds"] >= 0
+' 2>/dev/null; then
+        _pass "version --json emits version + elapsed_seconds"
+    else
+        _fail "version --json invalid" "out=$out"
+    fi
+else
+    _pass "skip: python3 not on PATH (version --json check)"
+fi
+
+# 17c) The single-service `up <svc>` form must follow the active deployment
+#      rather than resetting the persisted worktree pointer (only a full-stack
+#      `up` re-decides the target). Structural: the startup peek skips the
+#      reset/persist block when a positional service arg is present.
+peek=$(sed -n '/self tree vs deploy source/,/^REPO_ROOT=/p' "$REPO_ROOT/bin/local-dev/main.sh")
+if [[ "$peek" == *"_has_svc_arg"* ]]; then
+    _pass "up <svc> leaves the deploy-source pointer alone"
+else
+    _fail "startup peek doesn't guard the pointer reset on up <svc>"
+fi
+
 # 18) Deploy-source: `--help` documents the worktree selectors.
 help_out=$("$SCRIPT" --help 2>&1)
 if [[ "$help_out" == *"--worktree="* && "$help_out" == *"--branch="* ]]; then
diff --git a/bin/local-dev/tui.py b/bin/local-dev/tui.py
index 3170f60077..305244307e 100644
--- a/bin/local-dev/tui.py
+++ b/bin/local-dev/tui.py
@@ -1343,7 +1343,7 @@ class LocalDevApp(App):
             "Commands:",
             "  r           refresh state now",
             "  u           build + start every service",
-            "  u <svc>     start one service (no rebuild)",
+            "  u <svc>     bring one service up (rebuilds only if its source changed)",
             "  u --branch=NAME      deploy that branch's worktree, then build+start",
             "  u --worktree=PATH    deploy that worktree, then build+start",
             "              (plain `u` deploys this checkout; works on `a`/`auto` too —",
@@ -1354,9 +1354,9 @@ class LocalDevApp(App):
             "  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",
+            "  <svc>       same as `u <svc>` but forces the rebuild + bounce",
             "  l <svc>     tail that service's log (Ctrl-C returns)",
-            "  s <svc>     stop one service",
+            "  s <svc>     stop one service (alias for `d <svc>`)",
             "  clear       clear the log pane",
             "  log         toggle log pane visibility",
             "  banner      toggle banner (collapse the wordmark to save rows; Ctrl-B also works)",
@@ -1408,7 +1408,7 @@ class LocalDevApp(App):
                 if svc not in SERVICES_BY_NAME:
                     self._log_err(f"unknown service: {svc}")
                     return
-                argv = ["start", svc]
+                argv = ["up", svc]
             else:
                 argv = ["up"]
         elif verb in ("d", "down"):
@@ -1416,14 +1416,14 @@ class LocalDevApp(App):
                 if arg not in SERVICES_BY_NAME:
                     self._log_err(f"unknown service: {arg}")
                     return
-                argv = ["stop", arg]
+                argv = ["down", 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]
+            argv = ["down", 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.).
@@ -1441,15 +1441,14 @@ class LocalDevApp(App):
         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.
+                # ng serve / bun --watch rebuild on save automatically —
+                # forcing a rebuild is never what the user wants 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}`."
+                    f"If you really need to bounce it, run `d {verb}` then `u {verb}`."
                 )
                 return
-            argv = [verb]
+            argv = ["up", verb, "--build"]
         else:
             self._log_err(f"unknown: {verb}   (type 'h' for help)")
             return
@@ -1458,7 +1457,9 @@ class LocalDevApp(App):
             return
         # A full `up`/`auto` re-decides (and re-persists) the deploy target —
         # including a plain `up`, which resets to this checkout — so re-point the
-        # banner afterward. `u <svc>` maps to `start` and leaves the target be.
+        # banner afterward. A single-service `up <svc>` / `down <svc>` follows
+        # the active target without re-deciding it; the resync is a harmless
+        # re-read in that case.
         resync = argv[0] in ("up", "auto")
         self._spawn_action(verb if not arg else f"{verb} {arg}", argv, resync=resync)