From 735322b01696028c34ada8809a0a76ed48ca3199 Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Sun, 4 Oct 2026 06:35:38 +0200 Subject: [PATCH 1/4] ops: build the web app in the Evo deploy and add a preview-capable stack unit (#110) * ops: build the web app in the Evo deploy and add a preview-capable stack unit The Evo deploy restarted the dev stack without ever building the web app, so a broken production build went unnoticed and the server could not serve the build at all. Service workers and web push also need the production web app behind HTTPS, which the deploy notes did not cover. deploy.sh now builds the web app after install into a staging directory and swaps it into apps/web/dist right before the restart, so a failing build stops the deploy before migrations and the restart and never touches the build a running vite preview serves. Any failure after the checkout rolls back the code, reinstalls, regenerates the client, rebuilds the web app when this deploy had already replaced it, restarts and health-checks; the outcome is recorded as rolled-back or rollback-failed. The health check now fails when the API check fails even if the web check passes. Build output goes to a log that is printed only on failure. start-stack.sh runs api, worker and sandbox supervisor as pnpm dev does and serves the web app either from the Vite dev server (default) or from the build with vite preview on the same port (EVO_WEB_MODE=preview). Preview mode first checks that a build exists and that RAKAZO_HOST and SCREEN_PROXY_SECRET resolve the way vite.config.ts resolves them, using the same secret guard. evoo-local.service is a user unit template for it; the README documents installing it, switching modes, the catalog caveat for production builds, and HTTPS through Caddy with EXTRA_TRUSTED_ORIGINS. A testkit test runs the real scripts against fixture checkouts with fake pnpm, curl and restart hooks. It replaces the unwired shell regression test, which exercised a copy of the SHA capture rather than deploy.sh itself. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * ops: show the build error instead of stack frames when a deploy build fails A failing web build printed the last 40 lines of its log, which for a syntax error are mostly bundler stack frames below the message that names the file and line. Drop the frames before printing the tail. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test: look up the home main conversation in the scheduling journey Since every project has its own main conversation, the journey that schedules for a specialist found the user's main conversation with findFirst over all of them. With the Lab project's conversation also matching, it picked either one, so the check failed about one run in three. The lookup now names the home space, where the specialist joined and the routine is created. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --------- Co-authored-by: Claude --- ops/evo-deploy/README.md | 128 ++++++-- ops/evo-deploy/deploy-sha-capture.test.sh | 40 --- ops/evo-deploy/deploy.sh | 94 ++++-- ops/evo-deploy/evoo-local.service | 19 ++ ops/evo-deploy/start-stack.sh | 79 +++++ packages/testkit/src/evo-deploy.test.ts | 352 ++++++++++++++++++++++ 6 files changed, 626 insertions(+), 86 deletions(-) delete mode 100755 ops/evo-deploy/deploy-sha-capture.test.sh create mode 100644 ops/evo-deploy/evoo-local.service create mode 100755 ops/evo-deploy/start-stack.sh create mode 100644 packages/testkit/src/evo-deploy.test.ts diff --git a/ops/evo-deploy/README.md b/ops/evo-deploy/README.md index 3414617..82e1d6a 100644 --- a/ops/evo-deploy/README.md +++ b/ops/evo-deploy/README.md @@ -1,16 +1,16 @@ # Evo auto-deploy (evOo-local) -Idempotent deploy helper for the **source checkout** on the Evo host (`~/Projects/evOo-local`, user `cynar`). Pulls `main` (or a pinned SHA), runs migrations, restarts the existing stack, health-checks API `:3100` and web `:5173`, and rolls back on failure. +Idempotent deploy helper for the **source checkout** on the Evo host, run as ``. Pulls `main` (or a pinned SHA), installs dependencies, builds the web app, runs migrations, restarts the stack, health-checks API `:3100` and web `:5173`, and rolls back on failure. -This is separate from production Docker deploys under `/srv/rakazo` (see `docs/self-host.md` and `.github/workflows/ci.yml` `deploy-production`). +This is separate from production Docker deploys (see `docs/self-host.md` and `.github/workflows/ci.yml` `deploy-production`). ## Prerequisites on Evo -1. Git checkout at `~/Projects/evOo-local` (or set `EVO_DEPLOY_DIR`). +1. Git checkout at `$HOME/Projects/evOo-local` (or set `EVO_DEPLOY_DIR`). 2. `.env` with a working `DATABASE_URL` (Postgres reachable for `pnpm db:migrate`). 3. A restart path the script can use (pick one): - - **systemd (recommended for unattended deploy):** user unit, e.g. `evoo-local.service`, that starts `pnpm dev` or your stack wrapper. Set repo variable `EVO_SYSTEMD_UNIT`. - - **tmux:** session `evoo` running `pnpm dev` (matches `scripts/start-tmux.sh` on Mac; on Evo set `EVO_TMUX_SESSION` if different). + - **systemd (recommended for unattended deploy):** the user unit from [Stack unit](#stack-unit). Set repo variable `EVO_SYSTEMD_UNIT`. + - **tmux:** session `evoo` running `pnpm dev` (set `EVO_TMUX_SESSION` if different). - **compose:** `infra/compose/docker-compose.yml` with `api` / `web` / `worker` (dev stack). - **script:** executable hook at `EVO_RESTART_SCRIPT`. 4. Self-hosted GitHub Actions runner on Evo with labels `[self-hosted, evoo-ci, Linux]` (already used by CI). @@ -20,23 +20,88 @@ No SSH keys or deploy secrets are stored in this repo. The workflow runs **on** ## Enable the workflow 1. Merge this PR (or copy `ops/evo-deploy/` and `.github/workflows/evo-deploy.yml` to `main`). -2. In GitHub → **Settings → Secrets and variables → Actions → Variables** for `cynarAI/evOo-local`: +2. In GitHub → **Settings → Secrets and variables → Actions → Variables** for this repository: - `EVO_DEPLOY_ENABLED` = `true` - - Optional: `EVO_DEPLOY_DIR` (default `~/Projects/evOo-local` on the runner user) + - Optional: `EVO_DEPLOY_DIR` (default `$HOME/Projects/evOo-local` of the runner user) - Optional: `EVO_RESTART_MODE` (`auto`, `systemd`, `tmux`, `compose`, `script`, `none`) - Optional: `EVO_SYSTEMD_UNIT`, `EVO_TMUX_SESSION`, health URLs, timeouts 3. Confirm the runner user can read/write `EVO_DEPLOY_DIR` and restart services (`systemctl --user` or tmux). Until `EVO_DEPLOY_ENABLED` is `true`, pushes to `main` do not deploy (workflow job is skipped). +## Stack unit + +`start-stack.sh` starts api, worker and sandbox supervisor exactly as `pnpm dev` does. The web app runs in one of two modes, chosen by `EVO_WEB_MODE`: + +- `dev` (default): the Vite dev server, same as `pnpm dev`. +- `preview`: `vite preview` serves the production build from `apps/web/dist` on the same port. `deploy.sh` rebuilds it on every deploy. + +`evoo-local.service` is a systemd user unit template that runs the script. Install it once, as `` in the checkout: + +```bash +systemctl --user cat evoo-local.service # note what the current unit runs; keep a copy if needed +install -D -m 0644 ops/evo-deploy/evoo-local.service ~/.config/systemd/user/evoo-local.service +# Edit WorkingDirectory in the installed copy if the checkout is not at ~/Projects/evOo-local. +systemctl --user daemon-reload +systemctl --user enable evoo-local.service +systemctl --user restart evoo-local.service +loginctl enable-linger "$USER" # keep user services running without a login session +``` + +The unit starts the script through a login shell, so node and pnpm come from the same `PATH` that `deploy.sh` uses. It stays in dev mode, so nothing visible changes. Keep the repo variables `EVO_RESTART_MODE=systemd` and `EVO_SYSTEMD_UNIT=evoo-local.service`. Logs: `journalctl --user -u evoo-local.service -f`. + +### Switch to preview + +The production build drops message source text, so a message missing from the Lingui catalogs shows as a hash id instead of English. Switch only once the catalogs are complete (`pnpm --filter @rakazo/web intl:extract` adds no new entries). + +1. In the root `.env`, set `RAKAZO_HOST` to the hostname browsers use (see [HTTPS with Caddy](#https-with-caddy)) and `SCREEN_PROXY_SECRET` to a dedicated random value of at least 32 characters (`openssl rand -hex 32`), different from `BETTER_AUTH_SECRET` and `SANDBOX_SUPERVISOR_TOKEN`. `vite preview` runs with `NODE_ENV=production` and refuses development placeholders; the API reads the same secret. +2. Check the configuration without restarting anything: + ```bash + EVO_WEB_MODE=preview bash ops/evo-deploy/start-stack.sh --check + ``` +3. Switch the unit and restart: + ```bash + systemctl --user edit evoo-local.service + # add: + # [Service] + # Environment=EVO_WEB_MODE=preview + systemctl --user restart evoo-local.service + ``` +4. Verify: `curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5173/`. + +To switch back, remove the override (`systemctl --user revert evoo-local.service`) and restart. + +## HTTPS with Caddy + +Service workers and web push need a secure context, so phones and other devices need HTTPS; `http://:5173` is not one. Run Caddy on the host and proxy everything to the web port. Both the dev server and `vite preview` forward `/api`, `/rpc` and the screen proxy to the API, so `:3100` stays private: + +```Caddyfile +app.example.com { + reverse_proxy 127.0.0.1:5173 +} +``` + +This is the host proxy from `docs/self-host.md`; `infra/compose/Caddyfile.prod` routes the same paths to Compose containers and is not used here. Caddy obtains the certificate itself when the hostname resolves to the host and ports 80 and 443 are reachable; otherwise configure a DNS challenge. + +Root `.env`, then restart the unit: + +```dotenv +RAKAZO_HOST=app.example.com +EXTRA_TRUSTED_ORIGINS=https://app.example.com +``` + +- `RAKAZO_HOST` is the hostname `vite preview` accepts. Caddy forwards the original `Host` header; the dev server accepts any host. +- `EXTRA_TRUSTED_ORIGINS` (comma-separated) lets the HTTPS origin sign in. Keep `BETTER_AUTH_URL` on the plain-http origin and set it explicitly, because it falls back to `WEB_ORIGIN`: an `https://` value makes Better Auth use `__Secure-` cookies, which browsers drop over plain http, so signing in via `http://…:5173` would stop working. +- `WEB_ORIGIN` is the origin of links the API builds, such as the agent computer screen. Set it to the HTTPS origin if that is the address you use; an `http://` screen link is blocked as mixed content on the HTTPS page. + ## Manual / dry-run On Evo (or any machine with the checkout and stack): ```bash -cd ~/Projects/evOo-local +cd -# Show what would happen (no git reset, migrate, restart, or health wait) +# Show what would happen (no git reset, install, build, migrate, restart, or health wait) bash ops/evo-deploy/deploy.sh --dry-run # Deploy main tip @@ -51,30 +116,28 @@ EVO_RESTART_MODE=systemd EVO_SYSTEMD_UNIT=evoo-local.service bash ops/evo-deploy GitHub: **Actions → evo-deploy → Run workflow** (optional `ref`, `dry_run`, `skip_migrate`). -## Rollback behavior +## Deploy and rollback behavior -### Regression: SHA capture vs. git stdout - -`deploy.sh` captures `previousSha` and `targetSha` via process substitution (`read … < <(git_fetch_and_checkout)`). Git commands such as `reset --hard` print progress to **stdout** (`HEAD is now at …`), which previously polluted that capture and produced invalid `.evo-deploy/state.json` (e.g. `deployedSha: "is now at 84bc45e …"`, `previousSha: "HEAD"`). Rollback then became a no-op. - -**Fix:** all git invocations inside `git_fetch_and_checkout` (and rollback) redirect stdout to stderr; only the final `printf '%s %s\n'` emits SHAs on stdout. `write_state` and rollback reject values that are not 7–40 hex characters. - -1. Before `git fetch`, the script records `previousSha` (current `HEAD`). -2. After pull, migrate, and restart, it waits up to `EVO_HEALTH_TIMEOUT_SEC` (default 120s) for: +1. Before `git fetch`, the script records `previousSha` (current `HEAD`), then resets the checkout to the target. +2. `pnpm install --frozen-lockfile`, then the web build into `.evo-deploy/web-dist`. Its output goes to `.evo-deploy/web-build.log` and is printed only when the build fails. +3. `pnpm db:generate && pnpm db:migrate`. +4. The new build replaces `apps/web/dist`, the stack restarts, and the script waits up to `EVO_HEALTH_TIMEOUT_SEC` (default 120s) for: - `EVO_API_HEALTH_URL` (default `http://127.0.0.1:3100/health`) - `EVO_WEB_HEALTH_URL` (default `http://127.0.0.1:5173/`) -3. On failure: `git reset --hard` to `previousSha`, restart services again, exit non-zero. -4. Last outcome is written to `.evo-deploy/state.json` under the checkout. +5. Any failure rolls back: `git reset --hard` to `previousSha`, reinstall, regenerate the client, rebuild the web app if this deploy had already replaced it, restart, health-check, exit non-zero. A failing install, build or migration therefore never restarts the stack on the new commit, and `apps/web/dist` keeps the build that was being served. Migrations only move forward and stay applied. +6. The outcome (`success`, `rolled-back` or `rollback-failed`) is written to `.evo-deploy/state.json` under the checkout. + +`deploy.sh` captures `previousSha` and `targetSha` via process substitution, so every git command inside `git_fetch_and_checkout` and rollback redirects stdout to stderr (`reset --hard` prints `HEAD is now at …`). `write_state` and rollback reject values that are not 7–40 hex characters. `packages/testkit/src/evo-deploy.test.ts` runs the script against a fixture checkout and covers this, the build gate and the rollback. Manual rollback to a known good SHA: ```bash -cd ~/Projects/evOo-local -git reset --hard -EVO_RESTART_MODE=systemd EVO_SYSTEMD_UNIT=evoo-local.service bash ops/evo-deploy/deploy.sh --skip-migrate --ref HEAD +cd +EVO_RESTART_MODE=systemd EVO_SYSTEMD_UNIT=evoo-local.service EVO_DEPLOY_REF= \ + bash ops/evo-deploy/deploy.sh --skip-migrate ``` -(or restart your unit/tmux session by hand, then verify health URLs). +(or `git reset --hard `, `pnpm install --frozen-lockfile`, `pnpm --filter @rakazo/web build`, restart the unit, then verify the health URLs). ## Autopilot / Grok Bot after merge @@ -83,7 +146,7 @@ After a PR merges to `main`: 1. **Preferred:** rely on the `evo-deploy` workflow (once `EVO_DEPLOY_ENABLED=true`). No SSH step required. 2. **Fallback (manual):** SSH to Evo and run: ```bash - cd ~/Projects/evOo-local && bash ops/evo-deploy/deploy.sh + cd && bash ops/evo-deploy/deploy.sh ``` 3. **Verify:** API `curl -fsS http://127.0.0.1:3100/health`, web `curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5173/`. @@ -91,6 +154,8 @@ Do not commit host passwords, tokens, or SSH private keys. Configure restart tar ## Environment reference +`deploy.sh`: + | Variable | Default | Purpose | |----------|---------|---------| | `EVO_DEPLOY_DIR` | `$HOME/Projects/evOo-local` | Git checkout root | @@ -100,10 +165,17 @@ Do not commit host passwords, tokens, or SSH private keys. Configure restart tar | `EVO_SYSTEMD_SCOPE` | `user` | `user` or `system` | | `EVO_TMUX_SESSION` | `evoo` | tmux session name | | `EVO_TMUX_COMMAND` | `pnpm dev` | Command started in new tmux session | -| `EVO_MIGRATE` | `true` | Run `pnpm db:generate` + `pnpm db:migrate` | -| `EVO_INSTALL` | `true` | Run `pnpm install --frozen-lockfile` after checkout | +| `EVO_MIGRATE` | `true` | Run `pnpm db:generate` + `pnpm db:migrate` (`--skip-migrate`) | +| `EVO_INSTALL` | `true` | Run `pnpm install --frozen-lockfile` after checkout (`--skip-install`) | +| `EVO_BUILD` | `true` | Build the web app before the restart (`--skip-build`) | | `EVO_API_HEALTH_URL` | `http://127.0.0.1:3100/health` | API health probe | | `EVO_WEB_HEALTH_URL` | `http://127.0.0.1:5173/` | Web health probe | | `EVO_HEALTH_TIMEOUT_SEC` | `120` | Per-check timeout (seconds) | -If `EVO_RESTART_MODE=auto` cannot detect systemd, tmux, compose, or a restart script, deploy fails with a clear error — set `EVO_RESTART_MODE` and the matching unit/session explicitly. +`start-stack.sh`: + +| Variable | Default | Purpose | +|----------|---------|---------| +| `EVO_WEB_MODE` | `dev` | `dev` (Vite dev server) or `preview` (production build) | + +If `EVO_RESTART_MODE=auto` cannot detect systemd, tmux, compose, or a restart script, it falls back to `none` and only health-checks — set `EVO_RESTART_MODE` and the matching unit/session explicitly. diff --git a/ops/evo-deploy/deploy-sha-capture.test.sh b/ops/evo-deploy/deploy-sha-capture.test.sh deleted file mode 100755 index ea18089..0000000 --- a/ops/evo-deploy/deploy-sha-capture.test.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash -# Regression: git_fetch_and_checkout must emit only two SHAs on stdout. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -TMP="$(mktemp -d)" -trap 'rm -rf "$TMP"' EXIT - -git init -q "$TMP/repo" -git -C "$TMP/repo" config user.email "test@example.com" -git -C "$TMP/repo" config user.name "Test" -echo first >"$TMP/repo/file.txt" -git -C "$TMP/repo" add file.txt -git -C "$TMP/repo" commit -q -m "first" -FIRST_SHA="$(git -C "$TMP/repo" rev-parse HEAD)" -echo second >"$TMP/repo/file.txt" -git -C "$TMP/repo" commit -q -am "second" -SECOND_SHA="$(git -C "$TMP/repo" rev-parse HEAD)" - -# Minimal reimplementation of the capture contract under test. -git_fetch_and_checkout() { - local previous_sha target_sha deploy_dir="$1" ref="$2" - previous_sha="$(git -C "$deploy_dir" rev-parse HEAD)" - git -C "$deploy_dir" fetch origin --prune >&2 2>/dev/null || true - target_sha="$(git -C "$deploy_dir" rev-parse "${ref}^{commit}")" - git -C "$deploy_dir" reset --hard "$target_sha" >&2 - printf '%s %s\n' "$previous_sha" "$target_sha" -} - -git -C "$TMP/repo" remote add origin "$TMP/repo" -git -C "$TMP/repo" checkout -q "$FIRST_SHA" - -capture="$(git_fetch_and_checkout "$TMP/repo" "$SECOND_SHA")" -read -r got_previous got_target <<<"$capture" - -[[ "$got_previous" == "$FIRST_SHA" ]] || { echo "expected previous $FIRST_SHA, got $got_previous"; exit 1; } -[[ "$got_target" == "$SECOND_SHA" ]] || { echo "expected target $SECOND_SHA, got $got_target"; exit 1; } -[[ "$capture" != *"HEAD is now at"* ]] || { echo "git reset output leaked into capture: $capture"; exit 1; } - -echo "ok: deploy-sha-capture regression passed" diff --git a/ops/evo-deploy/deploy.sh b/ops/evo-deploy/deploy.sh index 3dbcf5c..59bc9e5 100755 --- a/ops/evo-deploy/deploy.sh +++ b/ops/evo-deploy/deploy.sh @@ -1,10 +1,9 @@ #!/usr/bin/env bash # evOo-local — idempotent deploy helper for the Evo host checkout. -# Pull (or pin) a revision, migrate, restart services, health-check, rollback on failure. +# Pull (or pin) a revision, install, build the web app, migrate, restart services, health-check, +# roll back on failure. set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - # --- defaults (override via env; see ops/evo-deploy/README.md) --- EVO_DEPLOY_DIR="${EVO_DEPLOY_DIR:-${HOME}/Projects/evOo-local}" EVO_DEPLOY_REF="${EVO_DEPLOY_REF:-main}" @@ -23,6 +22,7 @@ EVO_WEB_HEALTH_URL="${EVO_WEB_HEALTH_URL:-http://127.0.0.1:5173/}" EVO_HEALTH_TIMEOUT_SEC="${EVO_HEALTH_TIMEOUT_SEC:-120}" EVO_MIGRATE="${EVO_MIGRATE:-true}" EVO_INSTALL="${EVO_INSTALL:-true}" +EVO_BUILD="${EVO_BUILD:-true}" EVO_DRY_RUN="${EVO_DRY_RUN:-false}" EVO_STATE_FILE="${EVO_STATE_FILE:-${EVO_DEPLOY_DIR}/.evo-deploy/state.json}" @@ -37,7 +37,7 @@ is_valid_git_sha() { usage() { cat <<'EOF' -Usage: deploy.sh [--dry-run] [--ref REF] [--skip-migrate] [--skip-install] +Usage: deploy.sh [--dry-run] [--ref REF] [--skip-migrate] [--skip-install] [--skip-build] Environment: EVO_DEPLOY_DIR, EVO_DEPLOY_REF, EVO_RESTART_MODE, EVO_SYSTEMD_UNIT, EVO_TMUX_SESSION, EVO_API_HEALTH_URL, EVO_WEB_HEALTH_URL, EVO_HEALTH_TIMEOUT_SEC, … @@ -66,6 +66,10 @@ parse_args() { EVO_INSTALL=false shift ;; + --skip-build) + EVO_BUILD=false + shift + ;; -h | --help) usage exit 0 @@ -212,8 +216,7 @@ wait_for_url() { } health_check() { - wait_for_url "$EVO_API_HEALTH_URL" "API" - wait_for_url "$EVO_WEB_HEALTH_URL" "web" + wait_for_url "$EVO_API_HEALTH_URL" "API" && wait_for_url "$EVO_WEB_HEALTH_URL" "web" } write_state() { @@ -267,19 +270,78 @@ run_migrate() { run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && pnpm db:generate && pnpm db:migrate" } +# Rollback regenerates the client for the previous schema; applied migrations stay applied. +run_generate() { + [[ "$EVO_MIGRATE" == "true" ]] || return 0 + log "running pnpm db:generate" + run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && pnpm db:generate" +} + run_install() { [[ "$EVO_INSTALL" == "true" ]] || { log "skipping install (EVO_INSTALL=false)"; return 0; } log "running pnpm install --frozen-lockfile" run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && pnpm install --frozen-lockfile" } +# The build goes to a staging directory, so a failing build never touches the one a running +# `vite preview` serves; activate_web_build swaps it in right before the restart. +WEB_STAGING_DIR="$EVO_DEPLOY_DIR/.evo-deploy/web-dist" +WEB_BUILD_LOG="$EVO_DEPLOY_DIR/.evo-deploy/web-build.log" +WEB_DIST_DIR="$EVO_DEPLOY_DIR/apps/web/dist" +WEB_BUILD_REPLACED=false + +build_web() { + [[ "$EVO_BUILD" == "true" ]] || { log "skipping web build (EVO_BUILD=false)"; return 0; } + log "building the web app" + if [[ "$DRY_RUN" == true ]]; then + log "[dry-run] pnpm --filter @rakazo/web build --outDir $WEB_STAGING_DIR" + return 0 + fi + rm -rf "$WEB_STAGING_DIR" && mkdir -p "$(dirname "$WEB_BUILD_LOG")" || return 1 + if run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && pnpm --filter @rakazo/web build --outDir $(printf '%q' "$WEB_STAGING_DIR") --emptyOutDir" >"$WEB_BUILD_LOG" 2>&1 && + [[ -f "$WEB_STAGING_DIR/index.html" ]]; then + return 0 + fi + # Stack frames bury the message that names the file and line. + grep -v -E '^[[:space:]]+at ' "$WEB_BUILD_LOG" | tail -n 60 >&2 || true + log "ERROR: web build failed (full log: $WEB_BUILD_LOG)" + return 1 +} + +activate_web_build() { + [[ "$EVO_BUILD" == "true" ]] || return 0 + local outgoing="$WEB_STAGING_DIR.outgoing" + log "activating the web build" + WEB_BUILD_REPLACED=true + run rm -rf "$outgoing" || return 1 + if [[ -d "$WEB_DIST_DIR" ]]; then + run mv "$WEB_DIST_DIR" "$outgoing" || return 1 + fi + run mv "$WEB_STAGING_DIR" "$WEB_DIST_DIR" || return 1 + run rm -rf "$outgoing" +} + +# Restores the previous commit with its dependencies, client and web build, restarts and +# health-checks it. Every step is attempted so the service comes back even if one of them fails. rollback() { - local previous_sha="$1" + local previous_sha="$1" status="rolled-back" is_valid_git_sha "$previous_sha" || die "rollback aborted: invalid previous_sha: $previous_sha" - log "ROLLBACK: resetting to $previous_sha and restarting services" - run git -C "$EVO_DEPLOY_DIR" reset --hard "$previous_sha" >&2 - restart_services - write_state "$previous_sha" "$previous_sha" "rolled-back" + log "ROLLBACK: resetting to $previous_sha" + if ! run git -C "$EVO_DEPLOY_DIR" reset --hard "$previous_sha" >&2; then + write_state "$previous_sha" "$previous_sha" "rollback-failed" + die "deploy failed and the checkout could not be reset to $previous_sha" + fi + run_install || status="rollback-failed" + run_generate || status="rollback-failed" + # Until activation the served build is still the previous one; rebuild only once it was replaced. + if [[ "$WEB_BUILD_REPLACED" == true ]] && ! { build_web && activate_web_build; }; then + status="rollback-failed" + fi + if ! { restart_services && health_check; }; then + status="rollback-failed" + fi + write_state "$previous_sha" "$previous_sha" "$status" + [[ "$status" == "rolled-back" ]] || die "deploy failed; rollback to $previous_sha did not complete" die "deploy failed; rolled back to $previous_sha" } @@ -300,16 +362,12 @@ main() { is_valid_git_sha "$target_sha" || die "invalid target_sha captured from checkout: $target_sha" if [[ "$previous_sha" == "$target_sha" && "$DRY_RUN" != true ]]; then - log "already at $target_sha — continuing with migrate/restart/health" + log "already at $target_sha — continuing with install/build/migrate/restart/health" fi - run_install - run_migrate - restart_services - - if ! health_check; then + # Any failure rolls back; a failing install, build or migration stops before the restart. + if ! { run_install && build_web && run_migrate && activate_web_build && restart_services && health_check; }; then rollback "$previous_sha" - exit 1 fi write_state "$previous_sha" "$target_sha" "success" diff --git a/ops/evo-deploy/evoo-local.service b/ops/evo-deploy/evoo-local.service new file mode 100644 index 0000000..a06cffa --- /dev/null +++ b/ops/evo-deploy/evoo-local.service @@ -0,0 +1,19 @@ +# systemd user unit for the evOo stack in this checkout. Template: install and switch modes as +# described in ops/evo-deploy/README.md. +[Unit] +Description=evOo stack (api, worker, sandbox supervisor, web) + +[Service] +Type=simple +# Same default checkout as deploy.sh (EVO_DEPLOY_DIR); change it if the checkout lives elsewhere. +WorkingDirectory=%h/Projects/evOo-local +# dev (Vite dev server) or preview (production build); see the README before switching. +Environment=EVO_WEB_MODE=dev +# A login shell puts node and pnpm on PATH the same way deploy.sh finds them. +ExecStart=/bin/bash -lc 'exec ./ops/evo-deploy/start-stack.sh' +Restart=on-failure +RestartSec=5 +TimeoutStopSec=30 + +[Install] +WantedBy=default.target diff --git a/ops/evo-deploy/start-stack.sh b/ops/evo-deploy/start-stack.sh new file mode 100755 index 0000000..d7bb853 --- /dev/null +++ b/ops/evo-deploy/start-stack.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# evOo-local — start the stack from this checkout (used by evoo-local.service). +# api, worker and sandbox supervisor run as `pnpm dev` runs them. The web app runs as the Vite dev +# server (EVO_WEB_MODE=dev, the default) or serves the production build with `vite preview` on the +# same port (EVO_WEB_MODE=preview). `--check` validates the configuration and exits. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +EVO_WEB_MODE="${EVO_WEB_MODE:-dev}" + +log() { printf '[evo-stack] %s\n' "$*" >&2; } +die() { log "ERROR: $*"; exit 1; } + +# `vite preview` runs with NODE_ENV=production, so apps/web/vite.config.ts refuses development +# secrets. Resolve the env the way that config does (process env first, then the root .env files) +# and run the same guard, so a missing value fails here instead of inside a restart loop. +check_preview() { + [[ -f "$ROOT/apps/web/dist/index.html" ]] || + die "apps/web/dist has no build; run: pnpm --filter @rakazo/web build" + (cd "$ROOT/apps/web" && NODE_ENV=production "$ROOT/node_modules/.bin/tsx" --input-type=module -e ' +import { loadEnv } from "vite"; +import { resolveScreenProxySecret } from "../../packages/core/src/secrets-guard.ts"; + +const files = loadEnv("production", "../..", ""); +const env = { ...process.env }; +for (const key of ["RAKAZO_HOST", "SCREEN_PROXY_SECRET", "SANDBOX_SUPERVISOR_TOKEN", "BETTER_AUTH_SECRET"]) { + env[key] = files[key]; +} +const problems = []; +if (!env.RAKAZO_HOST?.trim()) problems.push("Set RAKAZO_HOST to the hostname the web app is served on."); +try { + resolveScreenProxySecret(env); +} catch (error) { + problems.push(error.message); +} +for (const problem of problems) console.error("[evo-stack] ERROR: " + problem); +process.exitCode = problems.length > 0 ? 1 : 0; +') +} + +start_preview() { + pnpm dev '--filter=!@rakazo/web' & + SERVICES_PID=$! + NODE_ENV=production pnpm --filter @rakazo/web preview --strictPort & + WEB_PID=$! + trap 'kill "$SERVICES_PID" "$WEB_PID" 2>/dev/null || true' EXIT + trap 'exit 143' TERM + trap 'exit 130' INT + # Polling instead of `wait -n` keeps this working on bash 3.2. + while kill -0 "$SERVICES_PID" 2>/dev/null && kill -0 "$WEB_PID" 2>/dev/null; do + sleep 1 + done + die "a service exited; stopping the stack" +} + +main() { + case "${1:-}" in + "" | --check) ;; + *) die "usage: start-stack.sh [--check]" ;; + esac + case "$EVO_WEB_MODE" in + dev | preview) ;; + *) die "EVO_WEB_MODE must be dev or preview (got: $EVO_WEB_MODE)" ;; + esac + cd "$ROOT" + if [[ "$EVO_WEB_MODE" == preview ]]; then + check_preview + fi + if [[ "${1:-}" == --check ]]; then + log "ok ($EVO_WEB_MODE)" + return 0 + fi + if [[ "$EVO_WEB_MODE" == dev ]]; then + exec pnpm dev + fi + start_preview +} + +main "$@" diff --git a/packages/testkit/src/evo-deploy.test.ts b/packages/testkit/src/evo-deploy.test.ts new file mode 100644 index 0000000..9199d7a --- /dev/null +++ b/packages/testkit/src/evo-deploy.test.ts @@ -0,0 +1,352 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const deployScript = path.join(repoRoot, "ops/evo-deploy/deploy.sh"); +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function write(file: string, content: string, mode?: number) { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, content); + if (mode !== undefined) chmodSync(file, mode); +} + +function temporaryRoot(prefix: string) { + const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), prefix))); + temporaryDirectories.push(root); + return root; +} + +function searchPath(bin: string) { + return [bin, path.dirname(process.execPath), "/usr/bin", "/bin"].join(path.delimiter); +} + +function readLines(file: string) { + return existsSync(file) ? readFileSync(file, "utf8").trim().split("\n") : []; +} + +interface Revision { + build?: "ok" | "fail"; + health?: "ok" | "down"; +} + +/** + * A checkout at revision A whose served build came from A, with revision B to deploy. Fake pnpm, + * curl and restart hook record what ran against which checked-out revision. + */ +function deployFixture(next: Revision, previous: Revision = {}) { + const root = temporaryRoot("evo-deploy-"); + const checkout = path.join(root, "checkout"); + const bin = path.join(root, "bin"); + const home = path.join(root, "home"); + const commandLog = path.join(root, "commands.log"); + const env = { + HOME: home, + PATH: searchPath(bin), + COMMAND_LOG: commandLog, + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", + GIT_CONFIG_NOSYSTEM: "1", + }; + const git = (...args: string[]) => + execFileSync("git", args, { + cwd: checkout, + env, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + + // deploy.sh runs pnpm through a login shell, which may reset PATH from the system profile. + write(path.join(home, ".bash_profile"), `export PATH="${bin}:$PATH"\n`); + write( + path.join(bin, "pnpm"), + `#!/bin/bash +printf '%s %s\\n' "$(cat version.txt)" "$*" >> "$COMMAND_LOG" +if [[ "$1 $2 $3 $4" == "--filter @rakazo/web build --outDir" ]]; then + echo "vite building for production" + mkdir -p "$5" + if [[ "$(cat build.txt)" != ok ]]; then echo "simulated build error" >&2; exit 1; fi + echo "built $(cat version.txt)" > "$5/index.html" +fi +`, + 0o755, + ); + write( + path.join(bin, "curl"), + `#!/bin/bash\n[[ "$(cat "$EVO_DEPLOY_DIR/health.txt")" == ok ]]\n`, + 0o755, + ); + write( + path.join(bin, "restart"), + `#!/bin/bash +served="$(cat "$EVO_DEPLOY_DIR/apps/web/dist/index.html" 2>/dev/null || echo none)" +printf 'restart %s %s\\n' "$(cat "$EVO_DEPLOY_DIR/version.txt")" "$served" >> "$COMMAND_LOG" +`, + 0o755, + ); + + mkdirSync(checkout, { recursive: true }); + git("init", "-q", "-b", "main"); + const commit = (version: string, revision: Revision) => { + write( + path.join(checkout, "package.json"), + '{"scripts":{"db:migrate":"prisma migrate deploy"}}', + ); + write(path.join(checkout, "version.txt"), version); + write(path.join(checkout, "build.txt"), revision.build ?? "ok"); + write(path.join(checkout, "health.txt"), revision.health ?? "ok"); + git("add", "-A"); + git("commit", "-q", "-m", version); + return git("rev-parse", "HEAD"); + }; + const a = commit("A", previous); + const b = commit("B", next); + git("reset", "-q", "--hard", a); + git("remote", "add", "origin", checkout); + write(path.join(checkout, "apps/web/dist/index.html"), "built A"); + + return { + a, + b, + checkout, + run(args: string[] = []) { + const result = spawnSync("/bin/bash", [deployScript, ...args], { + env: { + ...env, + EVO_DEPLOY_DIR: checkout, + EVO_DEPLOY_REF: b, + EVO_RESTART_MODE: "script", + EVO_RESTART_SCRIPT: path.join(bin, "restart"), + EVO_HEALTH_TIMEOUT_SEC: "1", + }, + encoding: "utf8", + timeout: 30_000, + }); + expect(result.error).toBeUndefined(); + return result; + }, + commands: () => readLines(commandLog), + head: () => git("rev-parse", "HEAD"), + served: () => readFileSync(path.join(checkout, "apps/web/dist/index.html"), "utf8").trim(), + state: () => JSON.parse(readFileSync(path.join(checkout, ".evo-deploy/state.json"), "utf8")), + build: (version: string) => + `${version} --filter @rakazo/web build --outDir ${path.join(checkout, ".evo-deploy/web-dist")} --emptyOutDir`, + }; +} + +describe("evo deploy", () => { + it("builds before migrating and restarts on the new build", () => { + const f = deployFixture({}); + const result = f.run(); + + expect(result.status, result.stderr).toBe(0); + expect(f.commands()).toEqual([ + "B install --frozen-lockfile", + f.build("B"), + "B db:generate", + "B db:migrate", + "restart B built B", + ]); + expect(result.stderr).not.toContain("vite building for production"); + expect(f.served()).toBe("built B"); + expect(existsSync(path.join(f.checkout, ".evo-deploy/web-dist"))).toBe(false); + // Regression: git output on stdout once polluted the captured SHAs. + expect(f.state()).toMatchObject({ previousSha: f.a, deployedSha: f.b, status: "success" }); + }); + + it("stops a failing build before the restart and keeps the served build", () => { + const f = deployFixture({ build: "fail" }); + const result = f.run(); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("simulated build error"); + expect(result.stderr).toContain(`deploy failed; rolled back to ${f.a}`); + expect(f.commands()).toEqual([ + "B install --frozen-lockfile", + f.build("B"), + "A install --frozen-lockfile", + "A db:generate", + "restart A built A", + ]); + expect(f.head()).toBe(f.a); + expect(f.served()).toBe("built A"); + expect(f.state()).toMatchObject({ previousSha: f.a, deployedSha: f.a, status: "rolled-back" }); + }); + + it("rolls back code, dependencies and build when the new revision is unhealthy", () => { + const f = deployFixture({ health: "down" }); + const result = f.run(); + + expect(result.status).not.toBe(0); + expect(f.commands()).toEqual([ + "B install --frozen-lockfile", + f.build("B"), + "B db:generate", + "B db:migrate", + "restart B built B", + "A install --frozen-lockfile", + "A db:generate", + f.build("A"), + "restart A built A", + ]); + expect(f.head()).toBe(f.a); + expect(f.served()).toBe("built A"); + expect(f.state()).toMatchObject({ deployedSha: f.a, status: "rolled-back" }); + }); + + it("reports a rollback that does not come back healthy", () => { + const f = deployFixture({ health: "down" }, { health: "down" }); + const result = f.run(); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain(`rollback to ${f.a} did not complete`); + expect(f.state()).toMatchObject({ deployedSha: f.a, status: "rollback-failed" }); + }); + + it("changes nothing in a dry run", () => { + const f = deployFixture({}); + const result = f.run(["--dry-run"]); + + expect(result.status, result.stderr).toBe(0); + expect(f.commands()).toEqual([]); + expect(f.head()).toBe(f.a); + expect(f.served()).toBe("built A"); + expect(existsSync(path.join(f.checkout, ".evo-deploy/state.json"))).toBe(false); + }); +}); + +/** A checkout layout around a copy of start-stack.sh, with the real tsx and vite installs. */ +function stackFixture(options: { build?: boolean; env?: string } = {}) { + const root = temporaryRoot("evo-stack-"); + const bin = path.join(root, "bin"); + const commandLog = path.join(root, "commands.log"); + const script = path.join(root, "ops/evo-deploy/start-stack.sh"); + mkdirSync(path.dirname(script), { recursive: true }); + copyFileSync(path.join(repoRoot, "ops/evo-deploy/start-stack.sh"), script); + for (const linked of ["node_modules", "packages/core", "apps/web/node_modules"]) { + mkdirSync(path.dirname(path.join(root, linked)), { recursive: true }); + symlinkSync(path.join(repoRoot, linked), path.join(root, linked)); + } + if (options.build !== false) write(path.join(root, "apps/web/dist/index.html"), ""); + if (options.env !== undefined) write(path.join(root, ".env"), options.env); + write( + path.join(bin, "pnpm"), + `#!/bin/bash +printf '%s|%s|%s\\n' "$PWD" "\${NODE_ENV:-}" "$*" >> "$COMMAND_LOG" +if [[ "$*" == "dev --filter=!@rakazo/web" ]]; then exec sleep 30; fi +if [[ "$1" == --filter ]]; then + # Exit only after the services were started, so both launches are on record. + for _ in $(seq 50); do + grep -q -e "--filter=!@rakazo/web" "$COMMAND_LOG" && exit 3 + sleep 0.1 + done + exit 4 +fi +`, + 0o755, + ); + return { + root, + run(args: string[] = [], mode?: string) { + const result = spawnSync("/bin/bash", [script, ...args], { + env: { + HOME: path.join(root, "home"), + PATH: searchPath(bin), + COMMAND_LOG: commandLog, + ...(mode ? { EVO_WEB_MODE: mode } : {}), + }, + encoding: "utf8", + timeout: 30_000, + }); + expect(result.error).toBeUndefined(); + return result; + }, + commands: () => readLines(commandLog), + }; +} + +const previewEnv = + "RAKAZO_HOST=app.example.com\nSCREEN_PROXY_SECRET=fake-screen-proxy-secret-for-tests-0123456789\n"; + +describe("evo stack start script", () => { + it("runs pnpm dev by default", () => { + const f = stackFixture(); + const result = f.run(); + + expect(result.status, result.stderr).toBe(0); + expect(f.commands()).toEqual([`${f.root}||dev`]); + }); + + it("rejects an unknown web mode", () => { + const result = stackFixture().run(["--check"], "production"); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("EVO_WEB_MODE must be dev or preview (got: production)"); + }); + + it.each([ + ["no build", { build: false, env: previewEnv }, ["apps/web/dist has no build"]], + [ + "no host or secret", + { env: "" }, + ["Set RAKAZO_HOST", "Set SCREEN_PROXY_SECRET to a dedicated random screen proxy credential."], + ], + [ + "the example secret", + { + env: "RAKAZO_HOST=app.example.com\nSCREEN_PROXY_SECRET=replace-with-32-plus-character-screen-proxy-secret\n", + }, + ["Replace the SCREEN_PROXY_SECRET placeholder"], + ], + ])("refuses preview with %s before starting anything", (_name, options, messages) => { + const f = stackFixture(options); + const result = f.run([], "preview"); + + expect(result.status).toBe(1); + for (const message of messages) expect(result.stderr).toContain(message); + expect(f.commands()).toEqual([]); + }); + + it("accepts a preview configuration from the root .env", () => { + const f = stackFixture({ env: previewEnv }); + const result = f.run(["--check"], "preview"); + + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toContain("ok (preview)"); + expect(f.commands()).toEqual([]); + }); + + it("serves the build next to the other services and stops when one exits", () => { + const f = stackFixture({ env: previewEnv }); + const result = f.run([], "preview"); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("a service exited"); + expect(f.commands().sort()).toEqual([ + `${f.root}|production|--filter @rakazo/web preview --strictPort`, + `${f.root}||dev --filter=!@rakazo/web`, + ]); + }); +}); From 59f4a1dfca49cc310ee2b1584ef08df105df2fe2 Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Sun, 4 Oct 2026 07:41:48 +0200 Subject: [PATCH 2/4] perf(api): filter peer-run activity in the transcript page query (#127) * perf(api): filter peer-run activity in the transcript page query The transcript loaded a raw page, dropped hidden peer-run rows in memory and rescanned when nothing visible was left, so a long delegated stretch cost two queries per hidden page. The page query now joins runs and excludes hidden peer activity in Postgres, so every page is filled with visible rows. The kept block kinds live in one list shared by paging, the in-memory filter and live event forwarding. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01YTJRkJrXAS9e3aEFcFYTy8 * test: port the main-conversation journey fix from #126 Journey 5c fails on main since every space got its own main conversation: the lookup picked the Lab conversation instead of the home one. Same change as #126 (including its chart-links warm-up), so this PR can go green; it no-ops once #126 lands. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01YTJRkJrXAS9e3aEFcFYTy8 --------- Co-authored-by: Claude --- .../src/thread-message-pages.postgres.test.ts | 143 ++++++++++++++++ apps/api/src/thread-message-pages.test.ts | 162 ++---------------- apps/api/src/thread-message-pages.ts | 96 +++++++---- apps/api/src/thread-target.test.ts | 24 ++- packages/testkit/src/cli/harness.ts | 1 + 5 files changed, 235 insertions(+), 191 deletions(-) create mode 100644 apps/api/src/thread-message-pages.postgres.test.ts diff --git a/apps/api/src/thread-message-pages.postgres.test.ts b/apps/api/src/thread-message-pages.postgres.test.ts new file mode 100644 index 0000000..b5b11e7 --- /dev/null +++ b/apps/api/src/thread-message-pages.postgres.test.ts @@ -0,0 +1,143 @@ +import type { MessageBlock } from "@rakazo/contracts"; +import type { PrismaClient } from "@rakazo/db"; +import { createDb, provisionMessagingIdentity } from "@rakazo/db"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { loadMessagePage } from "./thread-message-pages.js"; + +const databaseUrl = process.env.DATABASE_URL; +const describePostgres = + process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; + +describePostgres("loadMessagePage (PostgreSQL)", () => { + let prisma: PrismaClient; + let close: () => Promise; + let identity: { userId: string; spaceId: string; botId: string; threadId: string }; + let userRunId: string; + let peerRunId: string; + let seq = 0; + + beforeAll(async () => { + const db = createDb(databaseUrl!); + prisma = db.prisma; + close = async () => { + await db.prisma.$disconnect(); + await db.pool.end(); + }; + identity = await provisionMessagingIdentity( + prisma, + { provider: "sendblue", address: `+1555${Date.now().toString().slice(-7)}` }, + { signupsEnabled: undefined, signupAllowlist: undefined }, + ); + const { spaceId, botId, threadId, userId } = identity; + const owner = { spaceId, botId, threadId, userId }; + const run = async (trigger: string) => { + const task = await prisma.task.create({ + data: { ...owner, prompt: "fixture", status: "completed" }, + }); + const created = await prisma.run.create({ + data: { ...owner, taskId: task.id, status: "completed", trigger }, + }); + return created.id; + }; + userRunId = await run("user_message"); + peerRunId = await run("bot_message"); + seq = + ( + await prisma.message.aggregate({ + where: { threadId: identity.threadId }, + _max: { seq: true }, + }) + )._max.seq ?? 0; + }); + + afterAll(async () => { + if (identity) { + await prisma.messagingIdentity.deleteMany({ where: { userId: identity.userId } }); + await prisma.organization.deleteMany({ where: { id: identity.spaceId } }); + await prisma.user.deleteMany({ where: { id: identity.userId } }); + } + await close?.(); + }); + + async function message(id: string, runId: string | null, blocks: MessageBlock[]) { + seq += 1; + await prisma.message.create({ + data: { + id: `${identity.threadId}-${id}`, + threadId: identity.threadId, + seq, + role: "bot", + runId, + blocks, + }, + }); + return seq; + } + + const ids = (page: Awaited>) => + page.messages.map((row) => row.id.slice(identity.threadId.length + 1)); + + it("fills a page with visible rows across a long stretch of hidden peer activity", async () => { + await message("older", userRunId, [{ kind: "text", text: "Older answer" }]); + await message("newer", userRunId, [{ kind: "text", text: "Newer answer" }]); + for (let index = 0; index < 30; index += 1) { + await message(`peer-steps-${index}`, peerRunId, [ + { kind: "steps", steps: [{ label: "Working", count: 1 }] }, + ]); + } + await message("peer-receipt", peerRunId, [ + { kind: "bot_message_received", fromBotId: "bot-2", fromBotName: "Coder", text: "Done." }, + ]); + await message("peer-reply", peerRunId, [{ kind: "text", text: "Peer summary" }]); + await message("orphan-run", "run-that-no-longer-exists", [{ kind: "text", text: "Kept" }]); + + const first = await loadMessagePage(prisma, identity.threadId, undefined, 3); + expect(ids(first)).toEqual(["peer-receipt", "peer-reply", "orphan-run"]); + + const second = await loadMessagePage(prisma, identity.threadId, first.olderCursor!, 2); + expect(ids(second)).toEqual(["older", "newer"]); + + const peerView = await loadMessagePage( + prisma, + identity.threadId, + undefined, + 3, + undefined, + true, + ); + expect(ids(peerView)).toEqual(["peer-receipt", "peer-reply", "orphan-run"]); + const peerOlder = await loadMessagePage( + prisma, + identity.threadId, + peerView.olderCursor!, + 1, + undefined, + true, + ); + expect(ids(peerOlder)).toEqual(["peer-steps-29"]); + }); + + it("skips a receipt-only page for web but returns it when receipts are displayed", async () => { + await message("before-receipts", userRunId, [{ kind: "text", text: "Answer" }]); + await message("sent", userRunId, [ + { kind: "bot_message_sent", toBotId: "bot-2", toBotName: "Coder", text: "Check this." }, + ]); + await message("received", peerRunId, [ + { kind: "bot_message_received", fromBotId: "bot-2", fromBotName: "Coder", text: "Done." }, + ]); + + const web = await loadMessagePage(prisma, identity.threadId, undefined, 2); + expect(ids(web)).toEqual(["orphan-run", "before-receipts"]); + + const mobile = await loadMessagePage( + prisma, + identity.threadId, + undefined, + 2, + undefined, + false, + true, + ); + expect(ids(mobile)).toEqual(["sent", "received"]); + }); +}); diff --git a/apps/api/src/thread-message-pages.test.ts b/apps/api/src/thread-message-pages.test.ts index e290385..6a19d63 100644 --- a/apps/api/src/thread-message-pages.test.ts +++ b/apps/api/src/thread-message-pages.test.ts @@ -43,98 +43,6 @@ describe("thread message pages", () => { ).toBe(false); }); - it("keeps peer receipt rows when filtering peer-run output from pages", async () => { - const findMany = vi.fn(async () => [ - { - id: "message-reply", - threadId: "thread-1", - seq: 3, - role: "bot", - blocks: [{ kind: "text", text: "Echoed peer reply" }], - botId: "bot-1", - replyToMessageId: null, - runId: "run-peer", - createdAt: new Date("2026-08-16T00:00:03.000Z"), - }, - { - id: "message-received", - threadId: "thread-1", - seq: 2, - role: "user", - blocks: [ - { - kind: "bot_message_received", - fromBotId: "bot-2", - fromBotName: "Coder", - text: "Done.", - }, - ], - botId: null, - replyToMessageId: null, - runId: "run-peer", - createdAt: new Date("2026-08-16T00:00:02.000Z"), - }, - { - id: "message-user", - threadId: "thread-1", - seq: 1, - role: "bot", - blocks: [{ kind: "text", text: "Visible answer" }], - botId: "bot-1", - replyToMessageId: null, - runId: "run-user", - createdAt: new Date("2026-08-16T00:00:01.000Z"), - }, - ]); - const prisma = { - message: { findMany }, - run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) }, - } as unknown as PrismaClient; - - const page = await loadMessagePage(prisma, "thread-1", undefined, 3); - - expect(page.messages.map((message) => message.id)).toEqual([ - "message-user", - "message-received", - "message-reply", - ]); - }); - - it("filters peer-run activity when its receipt is outside the loaded page", async () => { - const findMany = vi.fn(async () => [ - { - id: "message-peer", - threadId: "thread-1", - seq: 2, - role: "bot", - blocks: [{ kind: "steps", steps: [{ label: "Echoed peer reply", count: 1 }] }], - botId: "bot-1", - replyToMessageId: null, - runId: "run-peer", - createdAt: new Date("2026-08-16T00:00:02.000Z"), - }, - { - id: "message-user", - threadId: "thread-1", - seq: 1, - role: "bot", - blocks: [{ kind: "text", text: "Visible answer" }], - botId: "bot-1", - replyToMessageId: null, - runId: "run-user", - createdAt: new Date("2026-08-16T00:00:01.000Z"), - }, - ]); - const prisma = { - message: { findMany }, - run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) }, - } as unknown as PrismaClient; - - const page = await loadMessagePage(prisma, "thread-1", undefined, 2); - - expect(page.messages.map((message) => message.id)).toEqual(["message-user"]); - }); - it("keeps peer text around-page targets and omits peer activity", async () => { const findMany = vi.fn(async () => [ { @@ -281,45 +189,6 @@ describe("thread message pages", () => { expect(page.messages.map((message) => message.id)).toEqual(["message-peer"]); }); - it("scans past a page containing only hidden peer-run activity", async () => { - const row = (seq: number, runId: string, kind: "text" | "steps" = "text") => ({ - id: `message-${seq}`, - threadId: "thread-1", - seq, - role: "bot", - blocks: - kind === "steps" - ? [{ kind: "steps", steps: [{ label: String(seq), count: 1 }] }] - : [{ kind: "text", text: String(seq) }], - botId: "bot-1", - replyToMessageId: null, - runId, - createdAt: new Date("2026-08-16T00:00:00.000Z"), - }); - const findMany = vi - .fn() - .mockResolvedValueOnce([ - row(4, "run-peer", "steps"), - row(3, "run-peer", "steps"), - row(2, "run-peer", "steps"), - ]) - .mockResolvedValueOnce([row(1, "run-user")]); - const prisma = { - message: { findMany }, - run: { - findMany: vi - .fn() - .mockResolvedValueOnce([{ id: "run-peer" }]) - .mockResolvedValueOnce([]), - }, - } as unknown as PrismaClient; - - const page = await loadMessagePage(prisma, "thread-1", undefined, 2); - - expect(page.messages.map((message) => message.id)).toEqual(["message-1"]); - expect(findMany).toHaveBeenCalledTimes(2); - }); - it("scans past a receipt-only page so web can reach older user-visible rows", async () => { const receiptRows = [ { @@ -383,21 +252,13 @@ describe("thread message pages", () => { createdAt: new Date("2026-08-16T00:00:01.000Z"), }, ]; - const findMany = vi.fn().mockResolvedValueOnce(receiptRows).mockResolvedValueOnce(olderRows); - const prisma = { - message: { findMany }, - run: { - findMany: vi - .fn() - .mockResolvedValueOnce([{ id: "run-peer" }]) - .mockResolvedValueOnce([]), - }, - } as unknown as PrismaClient; + const queryRaw = vi.fn().mockResolvedValueOnce(receiptRows).mockResolvedValueOnce(olderRows); + const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient; const page = await loadMessagePage(prisma, "thread-1", undefined, 2); expect(page.messages.map((message) => message.id)).toEqual(["message-user"]); - expect(findMany).toHaveBeenCalledTimes(2); + expect(queryRaw).toHaveBeenCalledTimes(2); }); it("returns a receipt-only page when the client displays peer receipts", async () => { @@ -419,11 +280,8 @@ describe("thread message pages", () => { runId: "run-peer", createdAt: new Date(`2026-08-16T00:00:0${seq}.000Z`), }); - const findMany = vi.fn(async () => [receipt(3), receipt(2), receipt(1)]); - const prisma = { - message: { findMany }, - run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) }, - } as unknown as PrismaClient; + const queryRaw = vi.fn(async () => [receipt(3), receipt(2), receipt(1)]); + const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient; const page = await loadMessagePage(prisma, "thread-1", undefined, 2, undefined, false, true); @@ -432,10 +290,10 @@ describe("thread message pages", () => { "message-receipt-3", ]); expect(page.olderCursor).toBe(2); - expect(findMany).toHaveBeenCalledTimes(1); + expect(queryRaw).toHaveBeenCalledTimes(1); }); - it("queries before the cursor and returns an ascending bounded page", async () => { + it("queries the bot messages view before the cursor as an ascending bounded page", async () => { const findMany = vi.fn(async () => [5, 4, 3].map((seq) => ({ id: `message-${seq}`, @@ -449,7 +307,7 @@ describe("thread message pages", () => { ); const prisma = { message: { findMany } } as unknown as PrismaClient; - const page = await loadMessagePage(prisma, "thread-1", 6, 2); + const page = await loadMessagePage(prisma, "thread-1", 6, 2, undefined, true); expect(findMany).toHaveBeenCalledWith({ where: { threadId: "thread-1", seq: { lt: 6 } }, @@ -461,7 +319,7 @@ describe("thread message pages", () => { }); it("ends pagination when the database returns no lookahead row", async () => { - const findMany = vi.fn(async () => [ + const queryRaw = vi.fn(async () => [ { id: "message-0", threadId: "thread-1", @@ -472,7 +330,7 @@ describe("thread message pages", () => { createdAt: new Date("2026-08-16T00:00:00.000Z"), }, ]); - const prisma = { message: { findMany } } as unknown as PrismaClient; + const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient; const page = await loadMessagePage(prisma, "thread-1", 1, 2); diff --git a/apps/api/src/thread-message-pages.ts b/apps/api/src/thread-message-pages.ts index cbec6ba..4d4fb20 100644 --- a/apps/api/src/thread-message-pages.ts +++ b/apps/api/src/thread-message-pages.ts @@ -1,9 +1,18 @@ import type { MessageBlock, ThreadMessage, ThreadMessagePage } from "@rakazo/contracts"; import { isPeerReceiptBlocks } from "@rakazo/core"; -import type { Prisma, PrismaClient } from "@rakazo/db"; +import type { PrismaClient } from "@rakazo/db"; +import { Prisma } from "@rakazo/db"; type MessageDb = PrismaClient | Prisma.TransactionClient; +/** Peer-run rows that stay in the normal transcript: receipts, ask cards and the bot's own reply. */ +const PEER_VISIBLE_BLOCK_KINDS: readonly string[] = [ + "bot_message_sent", + "bot_message_received", + "ask", + "text", +]; + export async function loadMessagePage( prisma: MessageDb, threadId: string, @@ -47,37 +56,69 @@ export async function loadMessagePage( } } - let cursor = before; - while (true) { + if (includePeerRuns) { const rows = await prisma.message.findMany({ where: { threadId, - ...(cursor === undefined ? {} : { seq: { lt: cursor } }), + ...(before === undefined ? {} : { seq: { lt: before } }), }, orderBy: { seq: "desc" }, take: pageSize + 1, }); - const hasOlder = rows.length > pageSize; - const pageRows = rows.slice(0, pageSize).reverse(); - const visibleRows = includePeerRuns ? pageRows : await withoutPeerRunMessages(prisma, pageRows); + return toPage(threadId, rows, pageSize); + } + + let cursor = before; + while (true) { + const rows = await visibleMessageRows(prisma, threadId, cursor, pageSize + 1); + const page = toPage(threadId, rows, pageSize); // Web hides receipts client-side, so its receipt-only pages keep scanning. // Mobile explicitly retains them and must receive each page for pagination. - const hasSubstantive = visibleRows.some( - (row) => !isPeerReceiptBlocks(row.blocks as MessageBlock[]), - ); - if (hasSubstantive || includePeerReceipts || !hasOlder || includePeerRuns) { - return { - threadId, - messages: visibleRows.map(toThreadMessage), - olderCursor: hasOlder ? (pageRows[0]?.seq ?? null) : null, - }; - } - // TODO: only rescan when a raw page is entirely peer output. Consider a run relation if - // long peer-only histories make this path hot. - cursor = pageRows[0]?.seq; + const hasSubstantive = page.messages.some((message) => !isPeerReceiptBlocks(message.blocks)); + if (hasSubstantive || includePeerReceipts || page.olderCursor === null) return page; + cursor = page.olderCursor; } } +type MessageRow = Parameters[0]; + +function toPage(threadId: string, rows: MessageRow[], pageSize: number): ThreadMessagePage { + const pageRows = rows.slice(0, pageSize).reverse(); + return { + threadId, + messages: pageRows.map(toThreadMessage), + olderCursor: rows.length > pageSize ? (pageRows[0]?.seq ?? null) : null, + }; +} + +/** + * Newest-first transcript rows with peer-run activity excluded in the database, so a long + * peer-only stretch costs one query instead of a rescan per hidden page. + */ +function visibleMessageRows( + prisma: MessageDb, + threadId: string, + before: number | undefined, + limit: number, +): Promise { + return prisma.$queryRaw` + SELECT m.id, m."threadId", m.seq, m.role, m.blocks, m."botId", m."replyToMessageId", + m."replyQuote", m."runId", m."createdAt" + FROM messages m + LEFT JOIN runs r ON r.id = m."runId" + WHERE m."threadId" = ${threadId} + ${before === undefined ? Prisma.empty : Prisma.sql`AND m.seq < ${before}`} + AND (r.trigger IS DISTINCT FROM 'bot_message' OR ${Prisma.join( + PEER_VISIBLE_BLOCK_KINDS.map( + (kind) => Prisma.sql`m.blocks @> ${JSON.stringify([{ kind }])}::jsonb`, + ), + " OR ", + )}) + ORDER BY m.seq DESC + LIMIT ${limit} + `; +} + export async function loadAllMessages( prisma: PrismaClient, threadId: string, @@ -106,15 +147,8 @@ async function withoutPeerRunMessages run.id)); return rows.filter((row) => { if (!row.runId || !peerRunIds.has(row.runId)) return true; - // Keep peer receipts (chips), ask cards, and the bot's own text reply. const blocks = row.blocks as MessageBlock[]; - return blocks.some( - (block) => - block.kind === "bot_message_sent" || - block.kind === "bot_message_received" || - block.kind === "ask" || - block.kind === "text", - ); + return blocks.some((block) => PEER_VISIBLE_BLOCK_KINDS.includes(block.kind)); }); } @@ -159,10 +193,8 @@ export function shouldForwardPeerThreadEvent(event: { !!block && typeof block === "object" && "kind" in block && - (block.kind === "bot_message_received" || - block.kind === "bot_message_sent" || - block.kind === "ask" || - block.kind === "text"), + typeof block.kind === "string" && + PEER_VISIBLE_BLOCK_KINDS.includes(block.kind), ) ); } diff --git a/apps/api/src/thread-target.test.ts b/apps/api/src/thread-target.test.ts index 8658532..199eec9 100644 --- a/apps/api/src/thread-target.test.ts +++ b/apps/api/src/thread-target.test.ts @@ -183,7 +183,7 @@ describe("threadSnapshot", () => { }, ]); const tx = { - $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), + $queryRaw: threadQueries(), message: { findMany: vi.fn().mockResolvedValue([]) }, event: { findFirst: vi.fn().mockResolvedValue({ seq: 4 }), @@ -203,7 +203,10 @@ describe("threadSnapshot", () => { const snapshot = await threadSnapshot({ prisma }, target); - expect(tx.$queryRaw).toHaveBeenCalledOnce(); + expect(tx.$queryRaw).toHaveBeenCalledWith( + expect.arrayContaining([expect.stringContaining("FOR SHARE")]), + "thread-1", + ); expect(findManyEvents).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ @@ -245,7 +248,7 @@ describe("threadSnapshot", () => { const findManyEvents = vi.fn(); const findFirstRun = botRunFindFirst([run]); const tx = { - $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), + $queryRaw: threadQueries(), message: { findMany: vi.fn().mockResolvedValue([]) }, event: { findFirst: vi.fn().mockResolvedValue(null), @@ -330,7 +333,7 @@ describe("threadSnapshot", () => { prisma: { $transaction: vi.fn(async (callback: (client: unknown) => unknown) => callback({ - $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), + $queryRaw: threadQueries(), message: { findMany: vi.fn().mockResolvedValue([]) }, event: { findFirst: vi.fn().mockResolvedValue(null), @@ -385,7 +388,7 @@ describe("threadSnapshot", () => { }; const findFirstRun = botRunFindFirst([failed, completed]); const tx = { - $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), + $queryRaw: threadQueries(), message: { findMany: vi.fn().mockResolvedValue([]) }, event: { findFirst: vi.fn().mockResolvedValue(null), @@ -421,7 +424,7 @@ describe("threadSnapshot", () => { const findManyEvents = vi.fn(); const findFirstRun = botRunFindFirst([]); const tx = { - $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), + $queryRaw: threadQueries(), message: { findMany: vi.fn().mockResolvedValue([]) }, event: { findFirst: vi.fn().mockResolvedValue(null), @@ -925,9 +928,16 @@ function groupRunFindMany(input: { active?: unknown[]; terminals?: unknown[] }) ); } +/** The snapshot's thread lock finds the thread; its message page query finds no rows. */ +function threadQueries() { + return vi.fn(async (sql: TemplateStringsArray) => + sql.join("").includes("FOR SHARE") ? [{ id: "thread-1" }] : [], + ); +} + function groupPrisma(findManyRuns: ReturnType) { const tx = { - $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), + $queryRaw: threadQueries(), message: { findMany: vi.fn().mockResolvedValue([]) }, event: { findFirst: vi.fn().mockResolvedValue(null), diff --git a/packages/testkit/src/cli/harness.ts b/packages/testkit/src/cli/harness.ts index 27415a4..03067b7 100644 --- a/packages/testkit/src/cli/harness.ts +++ b/packages/testkit/src/cli/harness.ts @@ -110,6 +110,7 @@ async function main() { "packages/db/src/main-agent.postgres.test.ts", "packages/db/src/user-profile.postgres.test.ts", "packages/db/src/messaging.postgres.test.ts", + "apps/api/src/thread-message-pages.postgres.test.ts", "packages/memory/src/commit.postgres.test.ts", "packages/adapters/src/wakeup.postgres.test.ts", "packages/adapters/src/realtime.postgres.test.ts", From ece3638ce7d046b78335c36232bad4efaaf6ffb9 Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:04:18 +0200 Subject: [PATCH 3/4] fix(ui-web): teach cn() the design system's text and control scales (#115) * fix(ui-web): teach cn() the design system's text and control scales cn() used plain tailwind-merge, which only knows Tailwind's default scales. It read the custom font sizes (text-ui-label, text-ui-caption, ...) as text colors, so of a size and a color in one class list only the last survived: - Small, extra-small and large buttons lost their variant's text color. A small primary button rendered foreground text on the primary fill (ink on ink in the light theme, near-white on white in the dark theme), and destructive, secondary and link buttons lost their colors the same way. - Default-size and icon buttons, tab triggers and the typography helpers lost their font size and inherited the parent's instead. The control and icon spacing scales (h-control-sm, size-icon-md, ...) were unknown as well, so they never merged with other sizes: an explicit h-auto or h-9 on a Button, Input or Toggle lost to the component's own height, and large buttons and toggles drew their icons at the medium size. cn() now extends tailwind-merge with the text and spacing keys styles.css adds under @theme. A unit test probes cn() with every @theme key in styles.css, so a new key that is not registered fails the test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test: look up the home main conversation in the scheduling journey Since every project has its own main conversation, the journey that schedules for a specialist found the user's main conversation with findFirst over all of them. With the Lab project's conversation also matching, it picked either one, so the check failed about one run in three. The lookup now names the home space, where the specialist joined and the routine is created. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --------- Co-authored-by: Claude --- packages/ui-web/src/lib/utils.test.ts | 80 +++++++++++++++++++++++++++ packages/ui-web/src/lib/utils.ts | 39 ++++++++++++- 2 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 packages/ui-web/src/lib/utils.test.ts diff --git a/packages/ui-web/src/lib/utils.test.ts b/packages/ui-web/src/lib/utils.test.ts new file mode 100644 index 0000000..4452e9a --- /dev/null +++ b/packages/ui-web/src/lib/utils.test.ts @@ -0,0 +1,80 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { buttonVariants } from "../components/ui/button.js"; +import { cn } from "./utils.js"; + +describe("cn", () => { + it("keeps a text color next to a ui text size", () => { + expect(cn("text-primary-foreground", "text-ui-label")).toBe( + "text-primary-foreground text-ui-label", + ); + expect(cn("text-ui-body-sm", "text-muted-foreground")).toBe( + "text-ui-body-sm text-muted-foreground", + ); + }); + + it("merges two text sizes to the last one", () => { + expect(cn("text-ui-body-sm", "text-ui-label")).toBe("text-ui-label"); + expect(cn("text-sm", "text-ui-caption")).toBe("text-ui-caption"); + expect(cn("text-ui-caption", "text-lg")).toBe("text-lg"); + }); + + it("merges two text colors to the last one", () => { + expect(cn("text-ui-label text-primary-foreground", "text-destructive")).toBe( + "text-ui-label text-destructive", + ); + }); + + it("merges ui control sizes with other sizes", () => { + expect(cn("h-control-sm", "h-8")).toBe("h-8"); + expect(cn("h-auto", "h-control-md")).toBe("h-control-md"); + expect(cn("[&_svg]:size-icon-md", "[&_svg]:size-icon-lg")).toBe("[&_svg]:size-icon-lg"); + }); + + it("keeps small buttons readable", () => { + for (const size of ["xs", "sm", "default", "lg"] as const) { + const classes = cn(buttonVariants({ variant: "default", size })).split(" "); + expect(classes, size).toContain("text-primary-foreground"); + expect(classes.filter((name) => name.startsWith("text-ui-")).length, size).toBe(1); + } + expect(cn(buttonVariants({ variant: "destructive", size: "sm" })).split(" ")).toEqual( + expect.arrayContaining(["text-destructive", "text-ui-label"]), + ); + }); +}); + +/** One merge check per `@theme` namespace: [earlier classes, later classes, expected]. */ +const NAMESPACE_PROBES: Record [string, string, string]> = { + color: (name) => ["text-ui-body text-foreground", `text-${name}`, `text-ui-body text-${name}`], + radius: (name) => ["rounded-sm", `rounded-${name}`, `rounded-${name}`], + spacing: (name) => [ + "h-4 p-2 gap-2", + `h-${name} p-${name} gap-${name}`, + `h-${name} p-${name} gap-${name}`, + ], + text: (name) => ["text-foreground text-sm", `text-${name}`, `text-foreground text-${name}`], +}; + +describe("cn knows every @theme key in styles.css", () => { + const css = readFileSync(new URL("../styles.css", import.meta.url), "utf8"); + const theme = css.match(/@theme inline \{([\s\S]*?)\n\}/)?.[1] ?? ""; + const keys = [...theme.matchAll(/^\s*--([a-z0-9-]+):/gm)] + .map((match) => match[1] ?? "") + .filter((key) => !key.includes("--")); + + it("finds the theme keys", () => { + expect(keys).toContain("text-ui-label"); + expect(keys).toContain("spacing-control-sm"); + }); + + for (const key of keys) { + it(`--${key}`, () => { + const namespace = Object.keys(NAMESPACE_PROBES).find((ns) => key.startsWith(`${ns}-`)); + expect(namespace, `add a probe for the @theme namespace of --${key}`).toBeDefined(); + const [earlier, later, expected] = NAMESPACE_PROBES[namespace ?? ""]!( + key.slice(`${namespace}-`.length), + ); + expect(cn(earlier, later)).toBe(expected); + }); + } +}); diff --git a/packages/ui-web/src/lib/utils.ts b/packages/ui-web/src/lib/utils.ts index 365058c..7cdcf9e 100644 --- a/packages/ui-web/src/lib/utils.ts +++ b/packages/ui-web/src/lib/utils.ts @@ -1,5 +1,40 @@ -import { type ClassValue, clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; +import type { ClassValue } from "clsx"; +import { clsx } from "clsx"; +import { extendTailwindMerge } from "tailwind-merge"; + +/** + * The scales `styles.css` adds under `@theme`. tailwind-merge only knows Tailwind's + * defaults, so without them it reads `text-ui-label` as a text color (dropping the + * real color) and never merges `h-control-sm` with `h-8`. `utils.test.ts` fails + * when `@theme` gains a key that is missing here. + */ +const twMerge = extendTailwindMerge({ + extend: { + theme: { + text: [ + "ui-caption", + "ui-label", + "ui-body-sm", + "ui-body", + "ui-reading", + "ui-title-sm", + "ui-title", + ], + spacing: [ + "control-xs", + "control-sm", + "control-md", + "control-lg", + "icon-sm", + "icon-md", + "icon-lg", + "rk-shell", + "rk-sidebar-x", + "rk-sidebar-y", + ], + }, + }, +}); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); From 0c3e3f3d29d4139cfbeed32ada8264ee62ef4923 Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:04:21 +0200 Subject: [PATCH 4/4] feat(web): complete German, English sources and catalogs that cannot go stale (#111) * fix(web): use English Lingui sources for German UI text Several web surfaces hard-coded German (the project card, roster and activity status labels, the sidebar "Heute" label) or used German text as the Lingui source message (sidebar sections, project timeline, bot picker, voice entry, group stage, copy and read-aloud buttons). English users saw German, and because production builds keep only message ids, none of these strings could be translated per locale. Convert them to English source messages, keeping the meaning: - ProjectCreatedCard: Lingui Trans/Plural instead of literals. - agent-activity: labels go through i18n._ with literal ids so the module stays importable from plain unit tests; the extractor still collects them. - Shell, ProjectSessionTimeline, bot-picker, StageVoiceEntry, OoStage: English sources, reusing existing messages where one already fit ("just now", "{n}m ago", "Search", "No bots match", "Set up voice", "Group conversation", "Transcript"). - The conversation picker matches its create entry against the translated label instead of a fixed German string. The German wording returns through the de catalog in the next commit. Unit and E2E tests now assert the English labels. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(web): keep Lingui catalogs complete and in sync The committed catalogs were last extracted weeks ago: about 330 source messages were missing from every locale, and production builds keep only message ids, so those strings rendered as hash ids (sidebar sections, privacy settings, profile, timeline, ...). German also had 226 empty entries, and every extraction rewrote the files because references carried line numbers. - lingui.config.ts: po references without line numbers, and a stable sort that breaks Lingui's ordering ties (explicit ids, equal text) by id, so the output no longer depends on file-system order. - Re-extract all catalogs with --clean (drops 25-44 obsolete entries per locale; no existing translation changed). - Translate every German entry (du form, consistent with the existing catalog). Other locales keep empty entries and fall back to English. - i18n-catalog-sync.test.ts extracts into a copy of the catalogs and fails when the committed files differ or a German entry is empty, so stale catalogs fail the unit test job (offline, about 5 s). Adds @lingui/format-po (already installed through @lingui/cli) as a direct dev dependency to configure the formatter. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test(web): find the sidebar Today button by its English label The shell contrast test added on main looks up the sidebar button by the old German source text, which this branch replaces with "Today". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * chore(web): extract catalogs and translate the in-app browser strings Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test: look up the home main conversation in the scheduling journey Since every project has its own main conversation, the journey that schedules for a specialist found the user's main conversation with findFirst over all of them. With the Lab project's conversation also matching, it picked either one, so the check failed about one run in three. The lookup now names the home space, where the specialist joined and the routine is created. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --------- Co-authored-by: Claude --- apps/web/e2e/chat-contrast.spec.ts | 2 +- apps/web/e2e/heute-brief.spec.ts | 2 +- apps/web/e2e/project-main-agent.spec.ts | 2 +- apps/web/e2e/sidebar-nav.spec.ts | 11 +- apps/web/e2e/smoke.spec.ts | 4 +- apps/web/e2e/spaces.spec.ts | 6 +- apps/web/e2e/unterhaltungen-sidebar.spec.ts | 6 +- apps/web/lingui.config.ts | 25 + apps/web/package.json | 1 + .../src/components/ai/ProjectCreatedCard.tsx | 11 +- apps/web/src/components/stage/OoStage.tsx | 4 +- .../src/components/stage/StageVoiceEntry.tsx | 8 +- apps/web/src/lib/agent-activity.test.ts | 20 +- apps/web/src/lib/agent-activity.ts | 36 +- apps/web/src/lib/i18n-catalog-sync.test.ts | 68 + apps/web/src/locales/de/messages.po | 4421 ++++++++++------- apps/web/src/locales/en/messages.po | 3895 +++++++++------ apps/web/src/locales/es/messages.po | 3735 +++++++++----- apps/web/src/locales/hi/messages.po | 3735 +++++++++----- apps/web/src/locales/ko/messages.po | 3735 +++++++++----- apps/web/src/locales/pt-BR/messages.po | 3735 +++++++++----- apps/web/src/locales/ru/messages.po | 3733 +++++++++----- apps/web/src/locales/tr/messages.po | 3735 +++++++++----- apps/web/src/locales/zh-CN/messages.po | 3735 +++++++++----- apps/web/src/pages/ProjectSessionTimeline.tsx | 47 +- apps/web/src/pages/Shell.tsx | 48 +- apps/web/src/pages/shell/bot-picker.tsx | 18 +- pnpm-lock.yaml | 3 + 28 files changed, 22311 insertions(+), 12470 deletions(-) create mode 100644 apps/web/src/lib/i18n-catalog-sync.test.ts diff --git a/apps/web/e2e/chat-contrast.spec.ts b/apps/web/e2e/chat-contrast.spec.ts index c388194..2ea5310 100644 --- a/apps/web/e2e/chat-contrast.spec.ts +++ b/apps/web/e2e/chat-contrast.spec.ts @@ -253,7 +253,7 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t await expectReadable(sidePanel, `${theme}: the bot settings form`, { inside: true }); }); - await sidebar.getByRole("button", { name: "Heute", exact: true }).click(); + await sidebar.getByRole("button", { name: "Today", exact: true }).click(); const heute = page.getByTestId("heute-brief"); await expect(heute.getByTestId("heute-mission-control-link")).toBeVisible(); await inEachTheme(page, async (theme) => { diff --git a/apps/web/e2e/heute-brief.spec.ts b/apps/web/e2e/heute-brief.spec.ts index 28bf669..6347490 100644 --- a/apps/web/e2e/heute-brief.spec.ts +++ b/apps/web/e2e/heute-brief.spec.ts @@ -6,7 +6,7 @@ test("Heute shows native brief instead of Mission Control iframe", async ({ page await signup(page, `heute-${stamp}@rakazo.test`, "password12", "Heute"); await completeOnboarding(page); - await page.getByRole("button", { name: "Heute", exact: true }).click(); + await page.getByRole("button", { name: "Today", exact: true }).click(); await expect(page).toHaveURL(/\/app\/[^/]+$/); const brief = page.getByTestId("heute-brief"); diff --git a/apps/web/e2e/project-main-agent.spec.ts b/apps/web/e2e/project-main-agent.spec.ts index 52488a2..7d0fedd 100644 --- a/apps/web/e2e/project-main-agent.spec.ts +++ b/apps/web/e2e/project-main-agent.spec.ts @@ -30,7 +30,7 @@ test("a new project opens straight into the conversation with its own main agent name: "Chief, main conversation", }); await expect(projectEntry).toBeVisible({ timeout: 30_000 }); - await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toHaveCount(0); + await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0); const groupId = new URL(page.url()).pathname.split("/").at(-1)!; const snapshot = await rpc<{ leadBotId?: string }>(page, "threads/get", { groupId }); expect(snapshot.leadBotId).toBeTruthy(); diff --git a/apps/web/e2e/sidebar-nav.spec.ts b/apps/web/e2e/sidebar-nav.spec.ts index e5e502c..0f87641 100644 --- a/apps/web/e2e/sidebar-nav.spec.ts +++ b/apps/web/e2e/sidebar-nav.spec.ts @@ -21,20 +21,19 @@ test("sidebar has expected navigation items and no integrations", async ({ page // Actual sidebar structure (verified by snapshot): // - button "Activity" (top action) // - textbox "Search" (search input) - // - button "Heute" (today) - // - text "Sprich mit deinen Oo's" (conversations section header) + // - button "Today" + // - text "Talk to your Oos" (conversations section header) // - NO "Integration(en)" button or text // Search: it's a textbox, not a button const searchInput = sidebar.locator('input[placeholder="Search"], textbox[name="Search"]'); await expect(searchInput).toBeVisible({ timeout: 5000 }); - // "Heute" (Today) - const todayBtn = sidebar.getByRole("button", { name: "Heute", exact: true }); + const todayBtn = sidebar.getByRole("button", { name: "Today", exact: true }); await expect(todayBtn).toBeVisible({ timeout: 5000 }); - // "Sprich mit deinen Oo's" is plain text, not a button - const conversationsText = sidebar.getByText("Sprich mit deinen Oo's"); + // "Talk to your Oos" is plain text, not a button + const conversationsText = sidebar.getByText("Talk to your Oos"); await expect(conversationsText).toBeVisible({ timeout: 5000 }); // No "Integration(en)" button diff --git a/apps/web/e2e/smoke.spec.ts b/apps/web/e2e/smoke.spec.ts index 6d634bd..d5228e9 100644 --- a/apps/web/e2e/smoke.spec.ts +++ b/apps/web/e2e/smoke.spec.ts @@ -41,8 +41,8 @@ test("app loads without React hook errors", async ({ page }) => { bodyText.includes("Sign in") || bodyText.includes("Anmelden") || bodyText.includes("EvOo") || - bodyText.includes("Projekte") || - bodyText.includes("Heute") || + bodyText.includes("Projects") || + bodyText.includes("Today") || bodyText.includes("Team"); expect(hasContent).toBe(true); diff --git a/apps/web/e2e/spaces.spec.ts b/apps/web/e2e/spaces.spec.ts index aa0da90..e540978 100644 --- a/apps/web/e2e/spaces.spec.ts +++ b/apps/web/e2e/spaces.spec.ts @@ -16,7 +16,7 @@ function projectSpace(projekte: Locator, name: string) { }); } -test("Projekte + opens new project dialog instead of group form", async ({ page }, testInfo) => { +test("Projects + opens new project dialog instead of group form", async ({ page }, testInfo) => { const stamp = Date.now(); await signup(page, `proj-create-${stamp}@rakazo.test`, "password12", "Project Create"); await completeOnboarding(page); @@ -135,7 +135,7 @@ test("spaces stay invisible by default and chat creation requires approval", asy await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible(); await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1); await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(1); - await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toHaveCount(0); + await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0); await captureScreenshot(page, testInfo, "spaces-sidebar"); await page.evaluate((id) => { @@ -171,7 +171,7 @@ test("a new space opens its main conversation and can be deleted from its menu", await expect( projectSpace(projekte, "Temporary").getByRole("button", { name: "Chief, main conversation" }), ).toBeVisible(); - await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toHaveCount(0); + await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0); expect(await rpc>(page, "bots/list", {})).toEqual([ expect.objectContaining({ kind: "main" }), ]); diff --git a/apps/web/e2e/unterhaltungen-sidebar.spec.ts b/apps/web/e2e/unterhaltungen-sidebar.spec.ts index fbe99e4..ec03c7f 100644 --- a/apps/web/e2e/unterhaltungen-sidebar.spec.ts +++ b/apps/web/e2e/unterhaltungen-sidebar.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from "@playwright/test"; import { completeOnboarding, openNewBot, signup, unterhaltungenOnly } from "./helpers"; -test("Unterhaltungen and Projekte stay separate; + opens bot picker", async ({ page }) => { +test("conversations and projects stay separate; + opens bot picker", async ({ page }) => { const stamp = Date.now(); await signup(page, `unterhaltungen-${stamp}@rakazo.test`, "password12", "Unterhaltungen UX"); await completeOnboarding(page); @@ -11,8 +11,8 @@ test("Unterhaltungen and Projekte stay separate; + opens bot picker", async ({ p const unterhaltungen = page.locator(".sidebar-unterhaltungen"); const projekte = page.locator(".sidebar-projekte"); - await expect(unterhaltungen.getByText("Sprich mit deinen Oo's", { exact: true })).toBeVisible(); - await expect(projekte.getByText("Projekte", { exact: true })).toBeVisible(); + await expect(unterhaltungen.getByText("Talk to your Oos", { exact: true })).toBeVisible(); + await expect(projekte.getByText("Projects", { exact: true })).toBeVisible(); await expect(unterhaltungen.getByRole("button", { name: /^Chief/ })).toBeVisible(); await unterhaltungen.getByTestId("conversation-picker-trigger").click(); diff --git a/apps/web/lingui.config.ts b/apps/web/lingui.config.ts index 772bfe0..aab31c9 100644 --- a/apps/web/lingui.config.ts +++ b/apps/web/lingui.config.ts @@ -1,4 +1,28 @@ +import type { CatalogFormatter, CatalogType } from "@lingui/conf"; import { defineConfig } from "@lingui/conf"; +import { formatter } from "@lingui/format-po"; + +// File references without line numbers, so catalogs change only when messages change. +const po = formatter({ lineNumbers: false }); +const collator = new Intl.Collator("en-US"); + +// Lingui orders entries by source text but leaves ties (explicit ids, equal text) in the order the +// files were found, which differs between file systems. Break ties by id so output is stable. +function sortCatalog(catalog: CatalogType): CatalogType { + return Object.fromEntries( + Object.entries(catalog).sort( + ([aId, a], [bId, b]) => + collator.compare(a.message ?? aId, b.message ?? bId) || + collator.compare(a.context ?? "", b.context ?? "") || + (aId < bId ? -1 : aId > bId ? 1 : 0), + ), + ); +} + +const stablePo: CatalogFormatter = { + ...po, + serialize: (catalog, ctx) => po.serialize(sortCatalog(catalog), ctx), +}; export default defineConfig({ sourceLocale: "en", @@ -10,5 +34,6 @@ export default defineConfig({ exclude: ["**/locales/**", "**/*.test.*"], }, ], + format: stablePo, compileNamespace: "es", }); diff --git a/apps/web/package.json b/apps/web/package.json index 1f121dc..b00900a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,6 +36,7 @@ "@lingui/babel-plugin-lingui-macro": "6.6.0", "@lingui/cli": "6.6.0", "@lingui/conf": "6.6.0", + "@lingui/format-po": "6.6.0", "@lingui/vite-plugin": "6.6.0", "@playwright/test": "1.63.0", "@rolldown/plugin-babel": "0.2.4", diff --git a/apps/web/src/components/ai/ProjectCreatedCard.tsx b/apps/web/src/components/ai/ProjectCreatedCard.tsx index d6a6047..831e8c6 100644 --- a/apps/web/src/components/ai/ProjectCreatedCard.tsx +++ b/apps/web/src/components/ai/ProjectCreatedCard.tsx @@ -1,3 +1,4 @@ +import { Plural, Trans } from "@lingui/react/macro"; import { FolderKanban } from "lucide-react"; export interface ProjectCreatedCardProps { @@ -22,17 +23,19 @@ export function ProjectCreatedCard({ name, memberCount, onClick }: ProjectCreate
{name}
- Projekt + Project - Erstellt + Created

- {memberCount} Agent{memberCount === 1 ? "" : "en"} eingeladen +

-
Projekt öffnen →
+
+ Open project → +
); diff --git a/apps/web/src/components/stage/OoStage.tsx b/apps/web/src/components/stage/OoStage.tsx index 25fa582..e30c59d 100644 --- a/apps/web/src/components/stage/OoStage.tsx +++ b/apps/web/src/components/stage/OoStage.tsx @@ -421,7 +421,7 @@ export function OoGroupStage({ if (sparkLabel && groupSpark?.kind === "peer-reply") return sparkLabel; if (awarenessLabel) return awarenessLabel; if (sparkLabel) return sparkLabel; - if (!speaker) return t`Gruppenunterhaltung`; + if (!speaker) return t`Group conversation`; if (running && workingBots.length > 1) { return t`${workingBots.length} Oos are working — ${speaker.name} has the floor`; } @@ -495,7 +495,7 @@ export function OoGroupStage({

{groupName ?? t`Group`}

-

{t`Gruppenunterhaltung`}

+

{t`Group conversation`}

{awarenessLabel && awarenessEvent && speaker ? ( diff --git a/apps/web/src/components/stage/StageVoiceEntry.tsx b/apps/web/src/components/stage/StageVoiceEntry.tsx index 14c4088..dd32f8b 100644 --- a/apps/web/src/components/stage/StageVoiceEntry.tsx +++ b/apps/web/src/components/stage/StageVoiceEntry.tsx @@ -21,8 +21,8 @@ export function StageVoiceEntry({ variant="outline" onClick={onStartVoice} data-testid="stage-voice-primary" - aria-label={voiceReady ? t`Sprechen starten` : t`Sprache einrichten`} - title={voiceReady ? t`Sprechen starten` : t`Sprache einrichten`} + aria-label={voiceReady ? t`Start talking` : t`Set up voice`} + title={voiceReady ? t`Start talking` : t`Set up voice`} className={`tactile-press h-control-lg w-full max-w-sm gap-2.5 rounded-full px-6 text-ui-body font-semibold transition-all ${ voiceReady ? "relative overflow-hidden border-border bg-foreground/5 text-foreground hover:bg-foreground/10" @@ -33,10 +33,10 @@ export function StageVoiceEntry({ ) : null}