# Handoff — Unified App Deployment Page

**Session:** 2026-04-22 → 2026-04-23
**Branch:** `main` (43 commits ahead of `origin/main` before push — all committed directly per explicit user consent)
**Base commit (session start):** `1a376eb2`
**Head commit (session end):** `0a71bca7`

## What landed

Full implementation of the unified app deployment page replacing the old `CreateAppView` / `AppDetailView` split. Key artefacts:

- **Spec:** `docs/superpowers/specs/2026-04-22-app-deployment-page-design.md`
- **Plan:** `docs/superpowers/plans/2026-04-22-app-deployment-page.md`
- **Routes:** `/apps` (list, unchanged), `/apps/new` + `/apps/:slug` (both render new `AppDeploymentPage`)

### Backend delivered (cameleer-server)

- Flyway V3 adds `deployments.deployed_config_snapshot JSONB`
- `DeploymentConfigSnapshot` record: `(UUID jarVersionId, ApplicationConfig agentConfig, Map<String,Object> containerConfig, List<String> sensitiveKeys)`
- `DeploymentExecutor` captures snapshot on successful RUNNING transition (not FAILED)
- `PostgresDeploymentRepository.saveDeployedConfigSnapshot(UUID, DeploymentConfigSnapshot)` + `findLatestSuccessfulByAppAndEnv(appId, envId)`
- `ApplicationConfigController.updateConfig` accepts `?apply=staged|live` (default `live` for back-compat); staged skips SSE push; 400 on unknown
- `AppController.getDirtyState` → `GET /api/v1/environments/{envSlug}/apps/{appSlug}/dirty-state` returning `{dirty, lastSuccessfulDeploymentId, differences[]}`
- `DirtyStateCalculator` pure service (cameleer-server-core), scrubs volatile fields (`version`, `updatedAt`, `updatedBy`, `environment`, `application`) from agent-config comparison, recurses into nested objects
- Integration tests: `PostgresDeploymentRepositoryIT` (3), `DeploymentSnapshotIT` (2), `ApplicationConfigControllerIT` (6), `AppDirtyStateIT` (3), `DirtyStateCalculatorTest` (9)
- OpenAPI + `schema.d.ts` regenerated

### UI delivered (cameleer-server/ui)

New directory `ui/src/pages/AppsTab/AppDeploymentPage/`:

```
index.tsx                                 # Main composition (524 lines)
IdentitySection.tsx                       # Name + slug + env pill + JAR + Current Version
Checkpoints.tsx                           # Collapsible disclosure of past successful deploys
PrimaryActionButton.tsx                   # Save / Redeploy / Deploying… state machine
AppDeploymentPage.module.css              # Page-local styles
ConfigTabs/
  MonitoringTab.tsx                       # Engine, payload, log levels, metrics, sampling, replay, route control
  ResourcesTab.tsx                        # CPU / memory / ports / replicas / runtime / networks
  VariablesTab.tsx                        # Env vars (Table / Properties / YAML / .env via EnvEditor)
  SensitiveKeysTab.tsx                    # Per-app keys + global baseline reference
  TracesTapsTab.tsx                       # Live-apply with LiveBanner
  RouteRecordingTab.tsx                   # Live-apply with LiveBanner
  LiveBanner.tsx                          # Shared amber "changes apply immediately" banner
DeploymentTab/
  DeploymentTab.tsx                       # Composition: StatusCard + DeploymentProgress + StartupLogPanel + History
  StatusCard.tsx                          # RUNNING / STARTING / FAILED indicator + replica count + URL + actions
  HistoryDisclosure.tsx                   # Past deployments table with inline log expansion
hooks/
  useDeploymentPageState.ts               # Form-state orchestrator (monitoring, resources, variables, sensitiveKeys)
  useFormDirty.ts                         # Per-tab dirty computation via JSON.stringify compare
  useUnsavedChangesBlocker.ts             # React Router v6 useBlocker + DS AlertDialog
utils/
  deriveAppName.ts                        # Filename → app name pure function
  deriveAppName.test.ts                   # 9 Vitest cases
```

Touched shared files:
- `ui/src/components/StartupLogPanel.tsx` — accepts `className`, flex-grows in container (dropped fixed 300px maxHeight)
- `ui/src/api/queries/admin/apps.ts` — added `useDirtyState`, `Deployment.deployedConfigSnapshot` type
- `ui/src/api/queries/commands.ts` — `useUpdateApplicationConfig` accepts `apply?: 'staged' | 'live'`
- `ui/src/router.tsx` — routes `/apps/new` and `/apps/:appId` to `AppDeploymentPage`
- `ui/src/pages/AppsTab/AppsTab.tsx` — shrunk 1387 → 109 lines (list only)

### Docs delivered

- `.claude/rules/ui.md` — Deployments bullet rewritten for the unified page
- `.claude/rules/app-classes.md` — `ApplicationConfigController` gains `?apply` note; `AppController` gains dirty-state endpoint; `PostgresDeploymentRepository` notes the snapshot column
- `docs/superpowers/specs/2026-04-22-app-deployment-page-design.md`
- `docs/superpowers/plans/2026-04-22-app-deployment-page.md`

## Gitea issues opened this session (cameleer/cameleer-server)

### [#147 — Concurrent-edit protection on app deployment page (optimistic locking)](https://gitea.siegeln.net/cameleer/cameleer-server/issues/147)
Deferred during brainstorming. Two browser sessions editing the same app have no last-write-wins protection. Proposed fix is `If-Match` / `ETag` on config + container-config + JAR upload endpoints using `app.updated_at`. Not blocking single-operator use.

### [#148 — Persist deployment-page monitoring fields end-to-end](https://gitea.siegeln.net/cameleer/cameleer-server/issues/148)
**Important.** The Monitoring tab renders five controls that are currently **UI-only**: `payloadSize` + `payloadUnit`, `metricsInterval`, `replayEnabled`, `routeControlEnabled`. They do not persist to the agent because the fields don't exist on `com.cameleer.common.model.ApplicationConfig` and aren't part of the agent protocol. The old `CreateAppView` had the same gap — this is not a new regression, but the user has stated these must actually affect agent behavior. Fix requires cross-repo work (cameleer-common model additions + cameleer-server server wiring + cameleer agent protocol handling + agent-side gating behaviour).

## Open gaps to tackle next session

### 1. Task 13.1 — finish manual browser QA

Partial coverage so far: save/redeploy happy path, ENV pill styling, tab seam, variables view switcher, toast (all landed + verified). Still unverified:

- Checkpoint restore flow (hydrate form from past snapshot → Save → Redeploy)
- Deploy failure path (FAILED status → snapshot stays null → primary button still shows Redeploy)
- Unsaved-changes dialog on in-app navigation (sidebar click with dirty form)
- Env switch with dirty form (should discard silently)
- End-to-end deploy against real Docker daemon — see "Docker deploy setup" below
- Per-tab `*` dirty marker visibility across all 4 staged tabs

### 2. Docker deploy setup (needed to fully exercise E2E)

Current `docker-compose.yml` sets `CAMELEER_SERVER_RUNTIME_ENABLED: "false"` so `DisabledRuntimeOrchestrator` rejects deploys with `UnsupportedOperationException`. To actually test deploy end-to-end, pick one:

- **Path A (quick):** `docker compose up -d cameleer-postgres cameleer-clickhouse` only, then `mvn -pl cameleer-server-app spring-boot:run` on the host + `npm run dev` for the UI. Server uses host Docker daemon directly. Runtime enabled by default via `application.yml`.
- **Path B (compose-native):** enable runtime in compose by mounting `/var/run/docker.sock`, setting `CAMELEER_SERVER_RUNTIME_ENABLED: "true"` + `CAMELEER_SERVER_RUNTIME_DOCKERNETWORK: cameleer-traefik`, pre-creating the `cameleer-traefik` network, adding `CAMELEER_SERVER_RUNTIME_JARDOCKERVOLUME` for shared JAR storage, and adding a Traefik service for routing. This is a fully separate task — would need its own plan.

Recommend Path A for finishing QA; Path B only if you want compose to be fully deployable.

### 3. Deferred code-review items

All flagged during the final integration review. None are blockers; each is a follow-up.

- **DEGRADED deployments aren't checkpoints** — `PostgresDeploymentRepository.findLatestSuccessfulByAppAndEnv` filters `status = 'RUNNING'` but the executor writes the snapshot before the status is resolved (so a DEGRADED deployment has a snapshot). Either include `DEGRADED` in the filter, or skip snapshot on DEGRADED. Pick one; document the choice.
- **`Checkpoints.tsx` restore on null snapshot is a silent no-op** — should surface a toast like "This checkpoint predates snapshotting and cannot be restored." Currently returns early with no feedback.
- **Missing IT: FAILED deploy leaves snapshot NULL** — `DeploymentSnapshotIT` tests the success case and general "snapshot appears on RUNNING" but doesn't explicitly lock in the FAILED → null guarantee. Add a one-line assertion.
- **`HistoryDisclosure` expanded log doesn't `scrollIntoView`** — on long histories the startup-log panel opens off-screen. Minor UX rough edge.
- **OpenAPI `@Parameter` missing on `apply` query param** — not critical, just improves generated Swagger docs. Add `@Parameter(name = "apply", description = "staged | live (default: live)")` to `ApplicationConfigController.updateConfig`.

### 4. Minor tech debt introduced this session

- `samplingRate` normalization hack in `useDeploymentPageState.ts`: `Number.isInteger(x) ? \`${x}.0\` : String(x)` — works around `1.0` parsing back as `1`, but breaks for values like `1.10` (round-trips to `1.1`). A cleaner fix is to compare as numbers, not strings, in `useFormDirty`.
- `useDirtyState` defaults to `?? true` during loading (so the button defaults to `Redeploy`, the fail-safe choice). Spurious Redeploy clicks are harmless, but the "Save (disabled)" UX would be more correct during initial load. Consider a loading-aware ternary if it becomes user-visible.
- `ApplicationConfigController.updateConfig` returns `ResponseEntity.status(400).build()` (empty body) on unknown `apply` values. Consider a structured error body consistent with other 400s in the codebase.
- GitNexus index stats (`AGENTS.md`, `CLAUDE.md`) refreshed several times during the session — these are auto-generated and will refresh again on next `npx gitnexus analyze`.

### 5. Behavioural caveats to know about

- **Agent config writes from the Dashboard / Runtime pages** still use `useUpdateApplicationConfig` with default `apply='live'` — they push SSE immediately as before. Only Deployment-page writes use `apply=staged`. This is by design.
- **Traces & Taps + Route Recording tabs** on the Deployment page write with `apply='live'` (immediate SSE). They do **not** participate in dirty detection. The LiveBanner explains this to the user.
- **Slug is immutable** — enforced both server-side (regex + Jackson drops unknown fields on PUT) and client-side (IdentitySection renders slug as `MonoText`, never `Input`).
- **Environment is immutable after create** — the deployment page has no env selector; the environment chip is read-only and colored via `envColorVar` per the env's configured color.
- **Dirty detection ignores `version`, `updatedAt`, `updatedBy`, `environment`, `application`** on agent config — these get bumped server-side on every save and would otherwise spuriously mark the page dirty. Scrubbing happens in `DirtyStateCalculator.scrubAgentConfig`.

## Recommended next-session kickoff

1. Run `docker compose up -d cameleer-postgres cameleer-clickhouse`, then `mvn -pl cameleer-server-app spring-boot:run` and `npm run dev` in two terminals.
2. Walk through the rest of Task 13.1 (checkpoint restore, deploy failure, unsaved dialog, env switch).
3. File any new bugs found. Address the deferred review items (section 3) in small PR-sized commits.
4. Decide which of #148's cross-repo work to tackle — cleanest path is: (a) extend `ApplicationConfig` in cameleer-common, (b) wire server side, (c) coordinate agent-side behaviour gating.
5. If you want compose-native deploy, open a separate ticket or spec for Path B from "Docker deploy setup" above.

## Commit range summary

```
1a376eb2..0a71bca7  (43 commits)
  ff951877  db(deploy): add deployments.deployed_config_snapshot column (V3)
  d580b6e9  core(deploy): add DeploymentConfigSnapshot record
  06fa7d83  core(deploy): type jarVersionId as UUID (match domain convention)
  7f9cfc7f  core(deploy): add deployedConfigSnapshot field to Deployment model
  d3e86b9d  storage(deploy): persist deployed_config_snapshot as JSONB
  9b851c46  test(deploy): autowire repository in snapshot IT (JavaTimeModule-safe)
  a79eafea  runtime(deploy): capture config snapshot on RUNNING transition
  9b124027  test(deploy): assert containerConfig round-trip + strict RUNNING in snapshot IT
  76129d40  api(config): ?apply=staged|live gates SSE push on PUT /apps/{slug}/config
  e716dbf8  test(config): verify audit action in staged/live config IT
  76352c0d  test(config): tighten audit assertions + @DirtiesContext on ApplicationConfigControllerIT
  e4ccce1e  core(deploy): add DirtyStateCalculator + DirtyStateResult
  24464c07  core(deploy): recurse into nested diffs + unquote scalar values in DirtyStateCalculator
  6591f2fd  api(apps): GET /apps/{slug}/dirty-state returns desired-vs-deployed diff
  97f25b4c  test(deploy): register JavaTimeModule in DirtyStateCalculator unit test
  0434299d  api(schema): regenerate OpenAPI + schema.d.ts for deployment page
  60529757  ui(deploy): scaffold AppDeploymentPage + route /apps/new and /apps/:slug
  52ff385b  ui(api): add useDirtyState + apply=staged|live on useUpdateApplicationConfig
  d067490f  ui(deploy): add deriveAppName pure function + tests
  00c7c0cd  ui(deploy): Identity & Artifact section with filename auto-derive
  08efdfa9  ui(deploy): Checkpoints disclosure (hides current deployment, flags pruned JARs)
  cc193a10  ui(deploy): add useDeploymentPageState orchestrator hook
  4f5a11f7  ui(deploy): extract MonitoringTab component
  5c48b780  ui(deploy): extract ResourcesTab component
  bb06c4c6  ui(deploy): extract VariablesTab component
  f487e6ca  ui(deploy): extract SensitiveKeysTab component
  b7c0a225  ui(deploy): LiveBanner component for live-apply tabs
  e96c3cd0  ui(deploy): Traces & Taps + Route Recording tabs with live banner
  98a7b781  ui(deploy): StatusCard for Deployment tab
  063a4a55  ui(deploy): HistoryDisclosure with inline log expansion
  1579f10a  ui(deploy): DeploymentTab + flex-grow StartupLogPanel
  42fb6c8b  ui(deploy): useFormDirty hook for per-tab dirty markers
  0e4166bd  ui(deploy): PrimaryActionButton + computeMode state-machine helper
  b1bdb88e  ui(deploy): compose page — save/redeploy/checkpoints wired end-to-end
  3a649f40  ui(deploy): router blocker + DS dialog for unsaved edits
  5a7c0ce4  ui(deploy): delete CreateAppView + AppDetailView + ConfigSubTab
  d5957468  docs(rules): update ui.md Deployments bullet for unified deployment page
  6d5ce606  docs(rules): document ?apply flag + snapshot column in app-classes
  d33c039a  fix(deploy): address final review — sensitiveKeys snapshot, dirty scrubbing, transition race, refetch invalidations
  b7b6bd2a  ui(deploy): port missing agent-config fields, var-view switcher, env pill, tab seam
  0a71bca7  fix(deploy): redeploy button after save, disable save when clean, success toast
```

Plus this handoff commit + the GitNexus index-stats refresh.
